Skip to content

Instantly share code, notes, and snippets.

@andystanton
Last active March 25, 2024 22:43
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save andystanton/2ec0dca0bf6de90c2000025319f63e2d to your computer and use it in GitHub Desktop.
Save andystanton/2ec0dca0bf6de90c2000025319f63e2d to your computer and use it in GitHub Desktop.
Tiny Python 3 HTTP/1.1 Server
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
from signal import signal, SIGINT
from sys import exit
from json import loads, dumps
from argparse import ArgumentParser
class HttpHandler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
error_content_type = 'text/plain'
error_message_format = "Error %(code)d: %(message)s"
def do_GET(self):
path, args = self.parse_url()
if path == '/hello' and 'name' in args:
name = args['name'][0]
self.write_response(200, "text/plain", f"Hello, {name}!")
else:
self.send_error(404, 'Not found')
def do_POST(self):
path, _ = self.parse_url()
body = self.read_body()
if path == '/echo' and body and self.headers['Content-Type'] == "application/json":
json_body = self.parse_json(body)
if json_body:
self.write_response(202, "application/json",
dumps({"message": "Accepted", "request": json_body}, indent=2))
else:
self.send_error(400, 'Invalid json received')
elif path == '/echo' and body:
self.write_response(202, "text/plain", f"Accepted: {body}")
else:
self.send_error(404, 'Not found')
def parse_url(self):
url_components = urlparse(self.path)
return url_components.path, parse_qs(url_components.query)
def parse_json(self, content):
try:
return loads(content)
except Exception:
return None
def read_body(self):
try:
content_length = int(self.headers['Content-Length'])
return self.rfile.read(content_length).decode('utf-8')
except Exception:
return None
def write_response(self, status_code, content_type, content):
response = content.encode('utf-8')
self.send_response(status_code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(response)))
self.end_headers()
self.wfile.write(response)
def version_string(self):
return "Tiny Http Server"
def log_error(self, format, *args):
pass
def start_server(host, port):
server_address = (host, port)
httpd = ThreadingHTTPServer(server_address, HttpHandler)
print(f"Server started on {host}:{port}")
httpd.serve_forever()
def shutdown_handler(signum, frame):
print('Shutting down server')
exit(0)
def main():
signal(SIGINT, shutdown_handler)
parser = ArgumentParser(description='Start a tiny HTTP/1.1 server')
parser.add_argument('--host', type=str, action='store',
default='127.0.0.1', help='Server host (default: 127.0.0.1)')
parser.add_argument('--port', type=int, action='store',
default=8000, help='Server port (default: 8000)')
args = parser.parse_args()
start_server(args.host, args.port)
if __name__ == "__main__":
main()

What?

This is a Python 3 HTTP/1.1 server bound to 127.0.0.1 on port 8000 using only the standard library.

Why?

I wanted a simple Python 3 web server using HTTP 1.1 that can perform simple HTTP operations and depends only on Python and standard library modules. I couldn't find an example so I made one.

The example demonstrates how to accept different methods, parse the url and query strings, read data sent to the server, parse and return json, return responses with different status codes and headers, handle graceful termination, and override the default error logging.

How?

Start the server:

$ python3 app.py

Stop the server using ctrl-c. You can optionally specify the host and port to bind to using --host and --port command line arguments. By default the host "127.0.0.1" and port 8000 are used.

GET

The GET endpoint demonstrates how to parse the query string and returns a message based on the name parameter.

$ curl -i '127.0.0.1:8000/hello?name=World'

HTTP/1.1 200 OK
Server: Tiny Http Server
Date: Fri, 21 May 2021 22:35:02 GMT
Content-Type: text/plain
Content-Length: 13

Hello, World

POST

The POST endpoint demonstrates how to read content sent to the server and returns a message based on this content:

$ curl -X POST --data 'foo bar' -i '127.0.0.1:8000/echo'

HTTP/1.1 202 Accepted
Server: Tiny Http Server
Date: Fri, 21 May 2021 22:35:28 GMT
Content-Type: text/plain
Content-Length: 17

Accepted: foo bar

The same endpoint can also send and receive json by specifying the Content-Type header:

$ curl -X POST -H 'Content-Type: application/json' -i --data '{"foo": "bar"}' '127.0.0.1:8000/echo'

HTTP/1.1 202 Accepted
Server: Tiny Http Server
Date: Sat, 22 May 2021 17:06:04 GMT
Content-Type: application/json
Content-Length: 64

{
  "message": "Accepted",
  "request": {
    "foo": "bar"
  }
}
@VAkris
Copy link

VAkris commented Oct 20, 2021

Hi Andy, How to use multiple ports or set a range of ports to listen on?

@andystanton
Copy link
Author

hi @VAkris

I've updated the script to accept a host and a port from the command line. If you want to use multiple ports, you could change the script to run on multiple ports as follows:

...

from threading import Thread

...

def main():
    signal(SIGINT, shutdown_handler)
    parser = ArgumentParser(description='Start a tiny HTTP/1.1 server')
    parser.add_argument('--host', type=str, action='store',
                        default='127.0.0.1', help='Server host (default: 127.0.0.1)')
    parser.add_argument('--port', type=int, action='append',
                        default=[], help='Server port (default: 8000)')
    args = parser.parse_args()
    ports = list(set(args.port))
    if len(ports) == 0:
        ports = [8000]
    for port in ports[1:]:
        Thread(target=start_server, daemon=True,
               args=[args.host, port]).start()
    start_server(args.host, ports[0])

...

Then you will be able to start it as follows:

python3 app.py --port 8000 --port 8001 --port 8002

Ranges are possible as an extension of this - you would have to work out how to specify a range, and then convert it into a list of ports.

@VAkris
Copy link

VAkris commented Oct 21, 2021

hi @VAkris

I've updated the script to accept a host and a port from the command line. If you want to use multiple ports, you could change the script to run on multiple ports as follows:

...

from threading import Thread

...

def main():
    signal(SIGINT, shutdown_handler)
    parser = ArgumentParser(description='Start a tiny HTTP/1.1 server')
    parser.add_argument('--host', type=str, action='store',
                        default='127.0.0.1', help='Server host (default: 127.0.0.1)')
    parser.add_argument('--port', type=int, action='append',
                        default=[], help='Server port (default: 8000)')
    args = parser.parse_args()
    ports = list(set(args.port))
    if len(ports) == 0:
        ports = [8000]
    for port in ports[1:]:
        Thread(target=start_server, daemon=True,
               args=[args.host, port]).start()
    start_server(args.host, ports[0])

...

Then you will be able to start it as follows:

python3 app.py --port 8000 --port 8001 --port 8002

Ranges are possible as an extension of this - you would have to work out how to specify a range, and then convert it into a list of ports.

Thanks AndyStanton, This is very helpful :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment