Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
625e458
Step 3 server, partially functional
bgarnaat Mar 12, 2016
64fd0ac
changes to server.py
bgarnaat Mar 12, 2016
8563ed8
Dynamic testing using fixtures. Identifying correct ValueError.
WillWeatherford Mar 12, 2016
aa70528
Working navigation of directories. Working tests.
WillWeatherford Mar 13, 2016
bff0a04
passing all tests
WillWeatherford Mar 13, 2016
5bfe7cd
Merge pull request #8 from bgarnaat/will
bgarnaat Mar 13, 2016
323776d
replaced \n with \r\n
bgarnaat Mar 13, 2016
50f53cb
Merge pull request #9 from bgarnaat/bgarnaat
bgarnaat Mar 13, 2016
087ea6c
Made small adjustment to encoding procedure in server.py (only encode…
WillWeatherford Mar 13, 2016
ae6635d
Good tests for path joining. Using os.path.join instead of abspath.
WillWeatherford Mar 14, 2016
7705777
concurrent server
bgarnaat Mar 14, 2016
3f192a3
Merge pull request #11 from bgarnaat/will
bgarnaat Mar 14, 2016
2f814d9
concurrency server post merge conflict
bgarnaat Mar 14, 2016
72f44dd
now includes README.md!
bgarnaat Mar 14, 2016
24d106f
test_system working for many variations of client requests to server,…
WillWeatherford Mar 14, 2016
ae5524a
Merge pull request #12 from bgarnaat/will
WillWeatherford Mar 14, 2016
942c4d3
Wrote final docstrings and finished README.md
WillWeatherford Mar 14, 2016
4988e35
Changed resolve_uri to always return content as bytes. test_resolove_…
WillWeatherford Mar 14, 2016
174db8e
Added a context manager to http_server and updated both server.py and…
WillWeatherford Mar 14, 2016
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added ._webroot
Binary file not shown.
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
# code_401_wk2_http
Code 401 week 2 HTTP server project

Step 1:
- open listening socket using socket library
- server loop to take incoming requests and send them back
- client loop to receive requests and return them to console


Step 2:
- response_ok function to send HTTP 200 code response
- response_error function to send HTTP 500 server error response


Step 3:
- added request validation
- added error codes for bad requests
- extract URI from valid response.
- return appropriate HTTP response to client


Step Final:
- added gevent to support server concurrency
- modified tests to be OS independent (in parallel with step 3)
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
author_email='weatherford.william@gmail.com',
license='MIT',
packages=[], # all your python packages with an __init__ file
py_modules=['server', 'client'], # your python modules to include
py_modules=['server', 'client', 'con_server'],
package_dir={'': 'src'},
install_requires=[],
extras_require={'test': ['pytest', 'pytest-xdist', 'tox']}
Expand Down
7 changes: 4 additions & 3 deletions src/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ def client(msg):
cli_sock = socket.socket(*stream_info[:3])
cli_sock.connect(stream_info[-1])

cli_sock.sendall(msg.encode('utf8'))
cli_sock.sendall(msg.encode('utf-8'))
cli_sock.shutdown(socket.SHUT_WR)
response = ''
response_parts = []
while True:
part = cli_sock.recv(BUFFER_LENGTH)
response += part.decode('utf-8')
response_parts.append(part)
if len(part) < BUFFER_LENGTH:
break
response = b''.join(response_parts).decode('utf-8')
cli_sock.close()
return response

Expand Down
20 changes: 20 additions & 0 deletions src/con_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
"""Gevent powered concurrency server."""
import sys


if __name__ == '__main__':
from gevent.server import StreamServer
from gevent.monkey import patch_all
from server import http_server
patch_all()
server = StreamServer(('127.0.0.1', 5000), http_server)
print('Starting http server on port 5000')
try:
server.serve_forever()
except Exception as e:
print(e.msg)
finally:
print('\nShutting down the server...\n')
server.close()
sys.exit()
250 changes: 173 additions & 77 deletions src/server.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,59 @@
# -*- coding: utf-8 -*-
"""Server module to to take HTTP requests."""
import io
import mimetypes
import os
import socket
import sys
import time
from contextlib import closing

OK_200 = b'200 OK'
ERR_400 = b'400: Bad Request'
ERR_405 = b'405: Method Not Allowed'
ERR_505 = b'505: HTTP Version Not Supported'
BUFFER_LENGTH = 8
ADDRINFO = ('127.0.0.1', 5000)


# TODO: update response assemble and tests for more sophisticated
# testing of each component. Assert for required parts while disrgarding
# optional components (but making sure they are in the right place).
# split on \r\n\r\n assert there are 2 pieces
# for first line of response, use maxsplit arg to split
# e.g. "HTTP/1.1 500 Internal Server Error".split(' ', maxsplit=2)
# >>> ["HTTP/1.1", "500", "Internal Server Error"]
PARENT_DIR = os.path.dirname(__file__)
GRANDPARENT_DIR = os.path.join(PARENT_DIR, '..')
WEBROOT_PATH = os.path.join(GRANDPARENT_DIR, 'webroot', '')

# Unicode constants for standard re-usable parts.
CRLF = '\r\n'
GET = 'GET'
HTTP1_1 = 'HTTP/1.1'
HOST = 'Host: '
CONTENT_TYPE = 'Content-Type: {}'
CONTENT_LENGTH = 'Content-Length: {}'

TEXT_HTML = 'text/html; charset=utf-8'
TEXT_PLAIN = 'text/plain; charset=utf-8'

# Easy Reference dictionary with int keys for code/message part.
HTTP_CODES = {
200: '200 OK',
400: '400 Bad Request',
404: '404 Not Found',
405: '405 Method Not Allowed',
505: '505 HTTP Version Not Supported',
}

# updated HTML templates to use unicode strings
HTML_TEMPLATE = '''
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<h3>{header}</h3>
<ul>
{list_items}
</ul>
</body>
</html>
'''
HTML_LI_TEMPLATE = '''
<li><a href="{full_path}">{short_path}</a></li>
'''
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice use of "templating" to solve the problem of building a good HTML representation of your directories. Well thought out!



def server():
Expand All @@ -33,94 +68,155 @@ def server():
try:
while True:
conn, addr = serv_sock.accept()
request = []
while True:
part = conn.recv(BUFFER_LENGTH)
request.append(part)
# conn.sendall(part)
if len(part) < BUFFER_LENGTH:
break
request = b''.join(request)
print('Request received:')
print(request.decode('utf-8'))
try:
uri = parse_request(request)
response = response_ok()
except ValueError as e:
response = response_error(*e.args)
conn.sendall(response)
conn.close()
time.sleep(0.01)
except KeyboardInterrupt:
pass
http_server(conn, addr)
except Exception as e:
print(e.msg)
finally:
try:
conn.close()
except NameError:
pass
print('\nShutting down the server...\n')
serv_sock.close()
sys.exit()


def parse_request(request):
"""TOOD: Docstring."""
# only accept GET
# only HTTP/1.1
# validate that proper Host: header was specified
# other requests raise appropriate Python error
# if no conditions arise, should return the URI
method = b''
uri = b''
protocol = b''
headers = b''
def http_server(conn, addr):
"""Take HTTP requests and return appropriate HTTP response."""
with closing(conn):
request_parts = []
while True:
part = conn.recv(BUFFER_LENGTH)
request_parts.append(part)
if len(part) < BUFFER_LENGTH:
break
# Immediately decode all of incoming request into unicode.
# In future, may need to check Content-type of incoming request?
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

absolutely yes. If the incoming request has a body, then you'd want to check both the content type and the encoding before decoding.

request = b''.join(request_parts).decode('utf-8')
print('Request received:\n{}'.format(request))
try:
uri = parse_request(request)
# Here body might be a bytestring.
body, content_type = resolve_uri(uri)
body_length = len(body)
response_headers = response_ok(content_type, body_length)

try:
headers, body = request.split(b'\r\n\r\n')
except ValueError:
raise ValueError(ERR_400)
except ValueError as e:
err_code = e.args[0]
response_headers = response_error(err_code)
body = HTTP_CODES[err_code].encode('utf-8')

# Re-encode into bytes on the way out.
response_headers = response_headers.encode('utf-8')

response = b''.join([response_headers, body])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that the implicit contract here is that your response_ok and response_error functions will return only the header portion of the response. And that you will attach the body later. This could come in handy at some point.


conn.sendall(response)
conn.close()
time.sleep(0.01)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

out of curiosity, what's this about?


try:
headers = headers.split(b'\r\n')
first_line = headers[0]
except (ValueError, IndexError):
raise ValueError(ERR_400)

def parse_request(request):
"""Parse client request."""
method = ''
uri = ''
protocol = ''
headers = ''

# I was able to simplify this into one try/except block.
# The key was to organize it by error type.
try:
headers, body = request.split(CRLF * 2)
headers = headers.split(CRLF)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like this approach. Split the headers away from the body, then divide up the headers into lines.

try:
first_line = headers[0]
except IndexError:
raise ValueError(400)
method, uri, protocol = first_line.split()
headers = headers[1:]
for h in headers:
if h.startswith(HOST):
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Be a little careful here. Remember that the names of headers are not case sensitive. I would suggest that you actually want to do something a bit more like this:

if h.lower().startswith(HOST.lower()):
    break

break
else:
raise ValueError(400)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very nice use of the else clause of a loop!

except ValueError:
raise ValueError(ERR_400)
raise ValueError(400)
if method != GET:
raise ValueError(405)
if protocol != HTTP1_1:
raise ValueError(505)

return uri


def resolve_uri(uri):
"""Return tuple of content and content type, or raise 404 error."""
print('Requested URI: ', uri)
uri = full_uri(uri)
print('URI after join: ', uri)

headers = headers[1:]
for h in headers:
if h.startswith(b'Host: '):
break
if os.path.isfile(uri):
content_type = mimetypes.guess_type(uri)[0]
body = read_file_bytes(uri)

elif os.path.isdir(uri):
content_type = TEXT_HTML
body = display(next(os.walk(uri)))
body = body.encode('utf-8')
else:
raise ValueError(ERR_400)
print(uri, 'is not a file or dir.')
raise ValueError(404)
return (body, content_type)

if method != b'GET':
raise ValueError(ERR_405)
if protocol != b'HTTP/1.1':
raise ValueError(ERR_505)

def full_uri(uri):
"""Take a unicode uri from webroot and return the absolute path."""
uri = os.path.join(*uri.split('/'))
uri = os.path.join(WEBROOT_PATH, uri)
return uri


def response_ok():
def web_uri(uri):
"""Take a unicode uri and return the uri from webroot."""
return uri.replace(WEBROOT_PATH, '')


def read_file_bytes(path):
"""Return the data in bytestring format from the file at a give path."""
f = io.open(path, 'rb')
data = f.read()
f.close()
return data


# Response_ok doesn't parse body (might be bytes as for an image).
# References key 200 from HTTP_CODES reference dictionary.
def response_ok(content_type, body_length):
"""Return 'HTTP/1.1 200 OK' for when connection ok."""
return (b'HTTP/1.1 %s\r\n'
b'Content-Type: text/plain\r\n'
b'\r\n'
b'Welcome to Imperial Space, rebel scum.\n'
b'|-o-| <-o-> |-o-|') % OK_200
return CRLF.join([' '.join([HTTP1_1, HTTP_CODES[200]]),
CONTENT_TYPE.format(content_type),
CONTENT_LENGTH.format(body_length),
CRLF])


def response_error(err_msg):
# Response_error take an int as err_code, to reference HTTP_CODES dict.
def response_error(err_code):
"""Return 'Internal Server Error' for when problem occurs."""
return (b'HTTP/1.1 %s\r\n'
b'Content-Type: text/plain\r\n'
b'\r\n'
b'Death Star Error. Please build again.') % err_msg
return CRLF.join([' '.join([HTTP1_1, HTTP_CODES[err_code]]),
CONTENT_TYPE.format(TEXT_PLAIN),
CRLF])


def display(threeple):
"""Split dir threeple into components and return as HTML."""
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

threeple LOL 🍺

cur_dir, dir_subdir, dir_files = threeple
cur_dir = web_uri(cur_dir)
dir_list = []
for i in dir_subdir + dir_files:
if i.startswith('._'):
continue
full_path = os.path.join(cur_dir, i)
html_li = HTML_LI_TEMPLATE.format(full_path=full_path, short_path=i)
dir_list.append(html_li)

return HTML_TEMPLATE.format(header=cur_dir,
list_items=''.join(dir_list))


if __name__ == '__main__':
Expand Down
Loading