Copied
This commit is contained in:
8
lib/httpserver/__init__.py
Normal file
8
lib/httpserver/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
# Minimal HTTP server
|
||||
#
|
||||
# Copyright 2021 (c) Erik de Lange
|
||||
# Released under MIT license
|
||||
|
||||
from .response import HTTPResponse
|
||||
from .sendfile import sendfile
|
||||
from .server import CONNECTION_CLOSE, CONNECTION_KEEP_ALIVE, HTTPServer
|
96
lib/httpserver/response.py
Normal file
96
lib/httpserver/response.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# Components of HTTP/1.1 responses
|
||||
#
|
||||
# Use when manually composing an HTTP response
|
||||
# Expand as required for your use
|
||||
#
|
||||
# For HTTP/1.1 specification see: https://www.ietf.org/rfc/rfc2616.txt
|
||||
# For MIME types see: https://www.iana.org/assignments/media-types/media-types.xhtml
|
||||
#
|
||||
# Copyright 2021 (c) Erik de Lange
|
||||
# Released under MIT license
|
||||
|
||||
from lib.httpserver.sendfile import sendfile
|
||||
|
||||
|
||||
reason = {
|
||||
200: "OK",
|
||||
301: "Moved Permanently",
|
||||
302: "Found",
|
||||
400: "Bad Request",
|
||||
404: "Not Found",
|
||||
418: "I'm a teapot"
|
||||
}
|
||||
|
||||
mimetype = {
|
||||
"HTML": b"Content-Type: text/html\r\n",
|
||||
"EVENT_STREAM": b"Content-Type: text/event-stream\r\n",
|
||||
"X_ICON": b"Content-Type: image/x-icon\r\n",
|
||||
"JSON": b"Content-Type: application/json\r\n"
|
||||
}
|
||||
|
||||
response_header = {
|
||||
"CLOSE": b"Connection: close\r\n",
|
||||
"KEEP_ALIVE": b"Connection: keep-alive\r\n"
|
||||
}
|
||||
|
||||
|
||||
class HTTPResponse:
|
||||
def __init__(self, con, status, mimetype=None, close=True, header=None):
|
||||
""" Create a response object
|
||||
|
||||
:param int status: HTTP status code
|
||||
:param socket con: socket
|
||||
:param str mimetype: HTTP mime type
|
||||
:param bool close: if true close connection else keep alive
|
||||
:param dict header: key,value pairs for HTTP response header fields
|
||||
"""
|
||||
self.status = status
|
||||
self.con = con
|
||||
self.mimetype = mimetype
|
||||
self.close = close
|
||||
|
||||
if header is None:
|
||||
self.header = {}
|
||||
else:
|
||||
self.header = header
|
||||
|
||||
def redirect(self, location):
|
||||
""" Redirect client """
|
||||
self.con.write(f"HTTP/1.1 {self.status} {reason.get(self.status, 'NA')}\nLocation: {location}\n")
|
||||
self.con.write(response_header.get("CLOSE"))
|
||||
|
||||
def send_file(self, filepath: str = ""):
|
||||
""" Send response to stream writer """
|
||||
self.con.write(f"HTTP/1.1 {self.status} {reason.get(self.status, 'NA')}\n")
|
||||
if self.mimetype:
|
||||
self.con.write(mimetype.get(self.mimetype))
|
||||
|
||||
if self.close:
|
||||
self.con.write(response_header.get("CLOSE"))
|
||||
else:
|
||||
self.con.write(response_header.get("KEEP_ALIVE"))
|
||||
|
||||
if len(self.header) > 0:
|
||||
for key, value in self.header.items():
|
||||
self.con.write(f"{key}: {value}\n")
|
||||
|
||||
sendfile(self.con, filepath)
|
||||
|
||||
def send_raw(self, raw: bytes):
|
||||
""" Send response to stream writer """
|
||||
self.con.write(f"HTTP/1.1 {self.status} {reason.get(self.status, 'NA')}\n")
|
||||
if self.mimetype:
|
||||
self.con.write(mimetype.get(self.mimetype))
|
||||
|
||||
if self.close:
|
||||
self.con.write(response_header.get("CLOSE"))
|
||||
else:
|
||||
self.con.write(response_header.get("KEEP_ALIVE"))
|
||||
|
||||
if len(self.header) > 0:
|
||||
for key, value in self.header.items():
|
||||
self.con.write(f"{key}: {value};\n")
|
||||
|
||||
self.con.write(b'\r\n\r\n')
|
||||
self.con.write(raw)
|
||||
|
25
lib/httpserver/sendfile.py
Normal file
25
lib/httpserver/sendfile.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Memory efficient file transfer
|
||||
#
|
||||
# Copyright 2021 (c) Erik de Lange
|
||||
# Released under MIT license
|
||||
|
||||
_buffer = bytearray(512) # adjust size to your systems available memory
|
||||
_bmview = memoryview(_buffer) # reuse pre-allocated _buffer
|
||||
|
||||
|
||||
def sendfile(conn, filename):
|
||||
""" Send a file to a connection in chunks - lowering memory usage.
|
||||
|
||||
:param socket conn: connection to send the file content to
|
||||
:param str filename: name of file the send
|
||||
"""
|
||||
try:
|
||||
with open(filename, "rb") as fp:
|
||||
while True:
|
||||
n = fp.readinto(_buffer)
|
||||
if n == 0:
|
||||
break
|
||||
conn.write(_bmview[:n])
|
||||
except:
|
||||
print(f"WEB:File {filename} not found")
|
||||
|
145
lib/httpserver/server.py
Normal file
145
lib/httpserver/server.py
Normal file
@@ -0,0 +1,145 @@
|
||||
# Minimal HTTP server
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# from httpserver import HTTPServer, sendfile, CONNECTION_CLOSE
|
||||
#
|
||||
# app = HTTPServer()
|
||||
|
||||
# @app.route("GET", "/")
|
||||
# def root(conn, request):
|
||||
# response = HTTPResponse(200, "text/html", close=True)
|
||||
# response.send(conn)
|
||||
# sendfile(conn, "index.html")
|
||||
#
|
||||
# app.start()
|
||||
#
|
||||
# Handlers for the (method, path) combinations must be decorated with @route,
|
||||
# and declared before the server is started (via a call to start).
|
||||
# Every handler receives the connection socket and an object with all the
|
||||
# details from the request (see url.py for exact content). The handler must
|
||||
# construct and send a correct HTTP response. To avoid typos use the
|
||||
# HTTPResponse component from response.py.
|
||||
# When leaving the handler the connection will be closed, unless the return
|
||||
# code of the handler is CONNECTION_KEEP_ALIVE.
|
||||
# Any (method, path) combination which has not been declared using @route
|
||||
# will, when received by the server, result in a 404 HTTP error.
|
||||
# The server cannot be stopped unless an alert is raised. A KeyboardInterrupt
|
||||
# will cause a controlled exit.
|
||||
#
|
||||
# Copyright 2021 (c) Erik de Lange
|
||||
# Released under MIT license
|
||||
|
||||
import errno
|
||||
import socket
|
||||
from micropython import const
|
||||
|
||||
from .response import HTTPResponse
|
||||
from .url import HTTPRequest, InvalidRequest
|
||||
|
||||
CONNECTION_CLOSE = const(0)
|
||||
CONNECTION_KEEP_ALIVE = const(1)
|
||||
|
||||
|
||||
class HTTPServerError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HTTPServer:
|
||||
|
||||
def __init__(self, host="0.0.0.0", port=80, backlog=5, timeout=30):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.backlog = backlog
|
||||
self.timeout = timeout
|
||||
self._routes = dict() # stores link between (method, path) and function to execute
|
||||
|
||||
def route(self, method="GET", path="/"):
|
||||
""" Decorator which connects method and path to the decorated function. """
|
||||
|
||||
if (method, path) in self._routes:
|
||||
raise HTTPServerError(f"route{(method, path)} already registered")
|
||||
|
||||
def wrapper(function):
|
||||
self._routes[(method, path)] = function
|
||||
|
||||
return wrapper
|
||||
|
||||
def start(self):
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
|
||||
server.bind((self.host, self.port))
|
||||
server.listen(self.backlog)
|
||||
|
||||
print(f"HTTP server started on {self.host}:{self.port}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
conn, addr = server.accept()
|
||||
conn.settimeout(self.timeout)
|
||||
|
||||
request_line = conn.readline()
|
||||
if request_line is None:
|
||||
raise OSError(errno.ETIMEDOUT)
|
||||
|
||||
if request_line in [b"", b"\r\n"]:
|
||||
print(f"empty request line from {addr[0]}")
|
||||
conn.close()
|
||||
continue
|
||||
|
||||
print(f"request line {request_line} from {addr[0]}")
|
||||
|
||||
try:
|
||||
request = HTTPRequest(request_line)
|
||||
except InvalidRequest as e:
|
||||
while True:
|
||||
# read and discard header fields
|
||||
line = conn.readline()
|
||||
if line is None:
|
||||
raise OSError(errno.ETIMEDOUT)
|
||||
if line in [b"", b"\r\n"]:
|
||||
break
|
||||
HTTPResponse(conn, 400, "HTML", close=True).send_file()
|
||||
conn.write(repr(e).encode("utf-8"))
|
||||
conn.close()
|
||||
continue
|
||||
|
||||
while True:
|
||||
# read header fields and add name / value to dict 'header'
|
||||
line = conn.readline()
|
||||
if line is None:
|
||||
raise OSError(errno.ETIMEDOUT)
|
||||
|
||||
if line in [b"", b"\r\n"]:
|
||||
break
|
||||
else:
|
||||
if line.find(b":") != 1:
|
||||
name, value = line.split(b':', 1)
|
||||
request.header[name] = value.strip()
|
||||
|
||||
# search function which is connected to (method, path)
|
||||
func = self._routes.get((request.method, request.path))
|
||||
if func:
|
||||
if func(conn, request) != CONNECTION_KEEP_ALIVE:
|
||||
# close connection unless explicitly kept alive
|
||||
conn.close()
|
||||
else: # no function found for (method, path) combination
|
||||
response = HTTPResponse(conn, 404).send_file()
|
||||
conn.close()
|
||||
|
||||
except KeyboardInterrupt: # will stop the server
|
||||
conn.close()
|
||||
break
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
if type(e) is OSError and e.errno == errno.ETIMEDOUT: # communication timeout
|
||||
pass
|
||||
elif type(e) is OSError and e.errno == errno.ECONNRESET: # client reset the connection
|
||||
pass
|
||||
else:
|
||||
server.close()
|
||||
raise e
|
||||
|
||||
server.close()
|
||||
print("HTTP server stopped")
|
54
lib/httpserver/sse.py
Normal file
54
lib/httpserver/sse.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Server-sent event support
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# import _thread, time
|
||||
#
|
||||
# from httpserver.sse import EventSource
|
||||
#
|
||||
# @app.route("GET", "/api/greeting")
|
||||
# def api_greeting(conn, request):
|
||||
# """ Say hello every 5 seconds """
|
||||
#
|
||||
# def greeting_task(conn, eventsource):
|
||||
# while True:
|
||||
# time.sleep(5)
|
||||
# try:
|
||||
# eventsource.send(event="greeting", data="hello")
|
||||
# except Exception: # catch (a.o.) ECONNRESET when the client has disappeared
|
||||
# conn.close()
|
||||
# break # exit thread
|
||||
#
|
||||
# _thread.start_new_thread(greeting_task, (conn, EventSource(conn)))
|
||||
#
|
||||
# Copyright 2022 (c) Erik de Lange
|
||||
# Released under MIT license
|
||||
|
||||
from .response import HTTPResponse
|
||||
|
||||
|
||||
class EventSource:
|
||||
""" Open and use an event stream connection to the client """
|
||||
|
||||
def __init__(self, conn):
|
||||
""" Set up an event stream connection """
|
||||
self.conn = conn
|
||||
|
||||
HTTPResponse(conn, 200, "EVENT_STREAM", close=False, header={"Cache-Control": "no-cache"}).send_file()
|
||||
|
||||
def send(self, data=":", id=None, event=None, retry=None):
|
||||
""" Send event to client following the event stream format
|
||||
|
||||
:param str data: event data to send to client. mandatory
|
||||
:param int id: optional event id
|
||||
:param str event: optional event type, used for dispatching at client
|
||||
:param int retry: retry interval in milliseconds
|
||||
"""
|
||||
writer = self.conn
|
||||
if id is not None:
|
||||
writer.write(f"id: {id}\n")
|
||||
if event is not None:
|
||||
writer.write(f"event: {event}\n")
|
||||
if retry is not None:
|
||||
writer.write(f"retry: {retry}\n")
|
||||
writer.write(f"data: {data}\n\n")
|
120
lib/httpserver/url.py
Normal file
120
lib/httpserver/url.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# Routines for decoding an HTTP request line.
|
||||
#
|
||||
# HTTP request line as understood by this package:
|
||||
#
|
||||
# Request line: Method SP Request-URL SP HTTP-Version CRLF
|
||||
# Request URL: Path ? Query
|
||||
# Query: key=value&key=value
|
||||
#
|
||||
# Example: b"GET /page?key1=0.07&key2=0.03&key3=0.13 HTTP/1.1\r\n"
|
||||
#
|
||||
# Method: GET
|
||||
# Request URL: /page?key1=0.07&key2=0.03&key3=0.13
|
||||
# HTTP version: HTTP/1.1
|
||||
# Path: /page
|
||||
# Query: key1=0.07&key2=0.03&key3=0.13
|
||||
#
|
||||
# See also: https://www.tutorialspoint.com/http/http_requests.htm
|
||||
# https://en.wikipedia.org/wiki/Uniform_Resource_Identifier
|
||||
#
|
||||
# For MicroPython applications which process HTTP requests.
|
||||
#
|
||||
# Copyright 2021,2022 (c) Erik de Lange
|
||||
# Released under MIT license
|
||||
|
||||
|
||||
class InvalidRequest(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HTTPRequest:
|
||||
|
||||
def __init__(self, request_line) -> None:
|
||||
""" Separate an HTTP request line in its elements.
|
||||
|
||||
:param bytes request_line: the complete HTTP request line
|
||||
:return Request: instance containing
|
||||
method the request method ("GET", "PUT", ...)
|
||||
url the request URL, including the query string (if any)
|
||||
path the request path from the URL
|
||||
query the query string from the URL (if any, else "")
|
||||
version the HTTP version
|
||||
parameters dictionary with key-value pairs from the query string
|
||||
header empty dict, placeholder for key-value pairs from request header fields
|
||||
:raises InvalidRequest: if line does not contain exactly 3 components separated by spaces
|
||||
if method is not in IETF standardized set
|
||||
aside from these no other checks done here
|
||||
"""
|
||||
try:
|
||||
self.method, self.url, self.version = request_line.decode("utf-8").split()
|
||||
# note that method, url and version are str, not bytes
|
||||
except ValueError:
|
||||
raise InvalidRequest(f"Expected 3 elements in {request_line}")
|
||||
|
||||
if self.version.find("/") != -1:
|
||||
self.version = self.version.split("/", 1)[1]
|
||||
|
||||
if self.method not in ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE"]:
|
||||
raise InvalidRequest(f"Invalid method {self.method} in {request_line}")
|
||||
|
||||
if self.url.find("?") != -1:
|
||||
self.path, self.query = self.url.split("?", 1)
|
||||
self.parameters = query(self.query)
|
||||
else:
|
||||
self.path = self.url
|
||||
self.query = ""
|
||||
self.parameters = dict()
|
||||
|
||||
self.header = dict()
|
||||
|
||||
|
||||
def query(query):
|
||||
""" Place all key-value pairs from a request URLs query string into a dict.
|
||||
|
||||
Example: request b"GET /page?key1=0.07&key2=0.03&key3=0.13 HTTP/1.1\r\n"
|
||||
yields dictionary {'key1': '0.07', 'key2': '0.03', 'key3': '0.13'}.
|
||||
|
||||
:param str query: the query part (everything after the '?') from an HTTP request line
|
||||
:return dict: dictionary with zero or more entries
|
||||
"""
|
||||
d = dict()
|
||||
if len(query) > 0:
|
||||
for pair in query.split("&"):
|
||||
try:
|
||||
key, value = pair.split("=", 1)
|
||||
if key not in d: # only first occurrence taken into account
|
||||
d[key] = value
|
||||
except ValueError: # skip malformed parameter (like missing '=')
|
||||
pass
|
||||
|
||||
return d
|
||||
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# request_lines = [b"GET / HTTP/1.1\r\n",
|
||||
# b"GET /page/sub HTTP/1.1\r\n",
|
||||
# b"GET /page?key1=0.07&key2=0.03 HTTP/1.1\r\n",
|
||||
# b"GET /page?key1=0.07&key1=0.03 HTTP/1.1\r\n",
|
||||
# b"GET /page?key1=0.07& HTTP/1.1\r\n",
|
||||
# b"GET /page?key1=0.07 HTTP/1.1\r\n",
|
||||
# b"GET /page?key1= HTTP/1.1\r\n",
|
||||
# b"GET /page?key1 HTTP/1.1\r\n",
|
||||
# b"GET /page? HTTP/1.1\r\n",
|
||||
# b"GET HTTP/1.1\r\n",
|
||||
# b"GET / HTTP/1.1 one_too_much\r\n",
|
||||
# b"UNKNOWN / HTTP/1.1\r\n"]
|
||||
#
|
||||
# for line in request_lines:
|
||||
# print("request line", line)
|
||||
# try:
|
||||
# request = HTTPRequest(line)
|
||||
# print("method:", request.method)
|
||||
# print("url:", request.url)
|
||||
# print("version:", request.version)
|
||||
# print("path:", request.path)
|
||||
# print("query:", request.query)
|
||||
# print("parameters:", request.parameters)
|
||||
# except Exception as e:
|
||||
# print("exception", repr(e))
|
||||
# finally:
|
||||
# print()
|
Reference in New Issue
Block a user