├── .gitignore ├── LICENSE ├── README.md ├── examples ├── basic_crud │ ├── inmemory_db.py │ └── main.py └── simple_auth │ ├── main.py │ └── requirements.txt ├── pymicrohttp ├── __init__.py └── server.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .venv 3 | dist 4 | build 5 | *.egg-info 6 | *.sh -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # Released under MIT License 2 | 3 | Copyright (c) 2013 Mark Otto. 4 | 5 | Copyright (c) 2017 Andrew Fong. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyMicroHTTP 2 | 3 | PyMicroHTTP is a lightweight, flexible HTTP framework built from scratch in Python. It provides a simple way to create HTTP services without heavy external dependencies, making it ideal for learning purposes or small projects. 4 | 5 | __NOTE: this is a toy project and not production ready.__ 6 | 7 | ## Content Table 8 | 9 | ### PyMicroHTTP 10 | 11 | * **[PyMicroHTTP](#pymicrohttp)** 12 | * What is PyMicroHTTP? (Introduction) 13 | * **[Features](#features)** (List of functionalities) 14 | * **[Installation](#installation)** (How to install) 15 | * **[Quick Start](#quick-start)** (Getting started example) 16 | * Running the example 17 | * **[Routing](#routing)** (Defining routes) 18 | * Basic Routing Syntax 19 | * **[Path Parameters](#path-parameters)** (Accessing dynamic parts of the URL) 20 | * **[Query Parameters](#query-parameters)** (Accessing data after the '?' in the URL) 21 | * **[Request Object](#request-object)** (Understanding the request data) 22 | * Available properties in the request object 23 | * Accessing headers, path parameters, query parameters, body and verb 24 | * **[Response Handling](#response-handling)** (Sending data back to the client) 25 | * Returning dictionaries (converted to JSON) 26 | * Returning strings 27 | * Returning custom status codes and headers 28 | * **[Middleware](#middleware)** (Adding custom logic before request handling) 29 | * Creating middleware functions 30 | * **[Before all](#before-all)** (Running middleware before every request) 31 | * **[Middleware chaining](#middleware-chaining)** 32 | * **[Running the Server](#running-the-server)** (Starting the server) 33 | * **[Contributing](#contributing)** (How to contribute to the project) 34 | 35 | ## Features 36 | 37 | - Built on raw TCP sockets 38 | - Routing with HTTP verb and path matching 39 | - Middleware support with easy chaining 40 | - JSON response handling 41 | - Zero external dependencies 42 | 43 | ## Installation 44 | 45 | You can install the package via pip: 46 | ``` 47 | $ pip install pymicrohttp 48 | ``` 49 | 50 | ## Quick Start 51 | 52 | Here's a simple example to get you started: 53 | 54 | ```python 55 | from pymicrohttp.server import Server 56 | 57 | s = Server() 58 | 59 | @s.register('GET /hello') 60 | def hello(request): 61 | return {"message": "Hello, World!"} 62 | 63 | @s.register('GET /hello/:name') 64 | def hello_name(request): 65 | name = request['params'].get('name') 66 | return {"message": f"Hello, {name}!"} 67 | 68 | 69 | if __name__ == "__main__": 70 | s.start_server(port=8080) 71 | ``` 72 | 73 | Run this script, and you'll have a server running on `http://localhost:8080`. Access it with: 74 | 75 | ``` 76 | curl http://localhost:8080/hello 77 | ``` 78 | 79 | ## Routing 80 | 81 | Routes are defined using the `@s.register` decorator: 82 | 83 | ```python 84 | @s.register('GET /ping') 85 | def ping_handler(request): 86 | return "pong" 87 | ``` 88 | 89 | Following this syntax: 90 | ``` 91 | VERB / 92 | ``` 93 | ### Supported verbs: 94 | - `*`: to match any verb 95 | - GET 96 | - POST 97 | - PUT 98 | - PATCH 99 | - DELETE 100 | - HEAD 101 | - OPTIONS 102 | 103 | With a signle space separating between the verb and the request path. 104 | 105 | Example: 106 | 107 | ```python 108 | @s.register('POST /login') 109 | def login_handler(request): 110 | try: 111 | body = json.loads(request['body']) 112 | if 'username' not in body or 'password' not in body: 113 | # do somthing 114 | except: 115 | return { 'error': 'invalid data' } 116 | ``` 117 | 118 | ### Path parameters 119 | 120 | You can declare dynamic path params using a colon, for example: 121 | ``` 122 | GET /users/:group/:channel 123 | ``` 124 | To read these params you can access them via the request object: 125 | ```py 126 | @s.register('GET /users/:group/:channel') 127 | def handler(request): 128 | ... 129 | group = request['params']['group'] 130 | channel = request['params']['channel'] 131 | ... 132 | ``` 133 | 134 | ### Query parameters 135 | You can read query parameters via the request obejct: 136 | ```py 137 | @s.register('GET /products') 138 | def handler(request): 139 | ... 140 | name = request['query'].get('name', '') 141 | category = request['query'].get('category', 'shoes') 142 | ... 143 | ``` 144 | Note that it is better to use `.get(key, default_value)` because query params are optional and may not exist, and accessing them without the `.get()` method may result in key errors. 145 | 146 | ## Request Object 147 | 148 | The request object is a dict containing these key and value: 149 | ``` 150 | { 151 | 'verb': ... 152 | 'path': ... 153 | 'body': ... 154 | 'headers': ... # { 'key': 'value' } 155 | 'params': ... # { 'key': 'value' } 156 | 'query': ... # { 'key': 'value' } 157 | } 158 | ``` 159 | 160 | You can access it via the handler: 161 | ```py 162 | @s.register('* /ping') 163 | def ping_handler(request): 164 | # accessing request headers 165 | if 'double' in request['headers']: 166 | return "pong-pong" 167 | return "pong" 168 | ``` 169 | 170 | Examples: 171 | 172 | 1. Accessing headers: 173 | ```py 174 | # say hello 175 | s.register('GET /hello/:name') 176 | def hello(request): 177 | name = request['params']['name'] 178 | return "Hello " + name 179 | ``` 180 | 181 | 1. Accessing dynamic path params: 182 | ```py 183 | # say hello `n` times 184 | s.register('GET /hello/:name/:n') 185 | def hello(request): 186 | name, n = request['params']['name'], request['params']['n'] 187 | return "Hello " * int(n) + name 188 | ``` 189 | 190 | 1. Accessing query params: 191 | ```py 192 | # say hello `n` times 193 | # read n from query params 194 | # with default value of 3 195 | s.register('GET /hello/:name') 196 | def hello(request): 197 | name = request['params']['name'] 198 | n = request['query'].get('n', 3) 199 | return "Hello " * n + name 200 | ``` 201 | 202 | ## Response Handling 203 | 204 | The framework supports different types of responses: 205 | 206 | 1. Dictionary (automatically converted to JSON): 207 | ```python 208 | return {"key": "value"} 209 | ``` 210 | 211 | 2. String: 212 | ```python 213 | return "Hello, World!" 214 | ``` 215 | 216 | 3. Tuple for custom status codes and headers: 217 | ```python 218 | return "Not Found", 404 219 | # or 220 | return "Created", 201, {"Location": "/resource/1"} 221 | ``` 222 | 223 | 224 | ## Middleware 225 | Middleware functions can be used to add functionality to your routes: 226 | 227 | ```python 228 | def log_middleware(next): 229 | def handler(request): 230 | print(f"Request: {request['verb']} {request['path']}") 231 | return next(request) 232 | return handler 233 | 234 | @s.register('GET /logged', log_middleware) 235 | def logged_route(request): 236 | return {"message": "This is a logged route"} 237 | ``` 238 | 239 | ### Before all 240 | 241 | If you want to run a middleware before every single request you can use the `s.beforeAll()` decorator: 242 | ```py 243 | @s.beforeAll() 244 | def logger(next): 245 | def handler(request): 246 | verb, path = request['verb'], request['path'] 247 | print(f'{datetime.datetime.now()} {verb} {path}') 248 | return next(request) 249 | return handler 250 | ``` 251 | 252 | ### Middleware chaining 253 | You can chain multiple middlwares together 254 | ```py 255 | def log_middleware(next): 256 | def handler(request): 257 | # do your logging logic here 258 | return next(request) 259 | return handler 260 | 261 | def auth_middleware(next): 262 | def handler(request): 263 | # do your auth logic here 264 | return next(request) 265 | return handler 266 | 267 | @s.register('GET /protected', [log_middleware, auth_middleware]) 268 | def protected_route(request): 269 | return {"message": "This is a protected route"} 270 | ``` 271 | 272 | ## Running the Server 273 | 274 | To run the server: 275 | 276 | ```python 277 | if __name__ == "__main__": 278 | s = Server() 279 | # Register your routes here 280 | s.start_server(port=8080) 281 | ``` 282 | 283 | ## Contributing 284 | 285 | Contributions are welcome! Please feel free to submit a Pull Request. 286 | -------------------------------------------------------------------------------- /examples/basic_crud/inmemory_db.py: -------------------------------------------------------------------------------- 1 | class TodosDB: 2 | def __init__(self): 3 | self.todos = [] 4 | 5 | def find(self, id = None): 6 | if not id: 7 | return self.todos 8 | 9 | for i in range(len(self.todos)): 10 | if self.todos[i]['id'] == id: 11 | return self.todos[i] 12 | 13 | def create(self, title): 14 | index = len(self.todos) 15 | todo = { 16 | 'id': str(index), 17 | 'title': title, 18 | 'checked': False 19 | } 20 | self.todos.append(todo) 21 | return todo 22 | 23 | def update(self, id, todo): 24 | for i in range(len(self.todos)): 25 | if self.todos[i]['id'] == id: 26 | self.todos[i] = todo 27 | return self.todos[i] 28 | 29 | def toggle(self, id): 30 | for i in range(len(self.todos)): 31 | if self.todos[i]['id'] == id: 32 | self.todos[i]['checked'] = not self.todos[i]['checked'] 33 | return self.todos[i] 34 | 35 | def delete(self, id): 36 | for i in range(len(self.todos)): 37 | if self.todos[i]['id'] == id: 38 | self.todos.pop(i) 39 | return 1 40 | -------------------------------------------------------------------------------- /examples/basic_crud/main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | from inmemory_db import TodosDB 4 | from pymicrohttp.server import Server 5 | 6 | 7 | in_memory_db = TodosDB() 8 | s = Server() 9 | 10 | 11 | @s.register('GET /') 12 | def show(request): 13 | todos = in_memory_db.find() or [] 14 | checked = request['query'].get('checked', '') 15 | if not checked: 16 | return todos 17 | 18 | return [t for t in todos if t['checked'] == (checked not in ['0', 'false'])] 19 | 20 | 21 | @s.register('GET /:id') 22 | def find(request): 23 | id = request['params'].get('id') 24 | todo = in_memory_db.find(id) 25 | return todo if todo else ({'error': 'id not found'}, 404) 26 | 27 | 28 | @s.register('POST /') 29 | def create(request): 30 | try: 31 | btitle = json.loads(request['body']).get('title') 32 | if not btitle: 33 | return {'error': 'please provide a todo title'}, 400 34 | return in_memory_db.create(btitle) 35 | except json.JSONDecodeError: 36 | return {'error': 'can not parse json'}, 422 37 | 38 | 39 | @s.register('PUT /:id') 40 | def toggle(request): 41 | id = request['params'].get('id') 42 | todo = in_memory_db.toggle(id) 43 | return todo if todo else ({'error': 'id not found'}, 404) 44 | 45 | 46 | @s.register('DELETE /:id') 47 | def delete(request): 48 | id = request['params'].get('id') 49 | result = in_memory_db.delete(id) 50 | return ('', 204) if result else ({'error': 'id not found'}, 404) 51 | 52 | 53 | if __name__ == '__main__': 54 | s.start_server(port=int(sys.argv[1])) 55 | -------------------------------------------------------------------------------- /examples/simple_auth/main.py: -------------------------------------------------------------------------------- 1 | import sys, datetime 2 | import jwt 3 | from pymicrohttp.server import Server 4 | 5 | 6 | JWT_SECRET = '123' 7 | s = Server() 8 | 9 | 10 | @s.before_all() 11 | def loggerMiddleware(next): 12 | def handler(request): 13 | verb, path = request['verb'], request['path'] 14 | print(f'{datetime.datetime.now()} {verb} {path}') 15 | return next(request) 16 | return handler 17 | 18 | 19 | def authMiddleware(next): 20 | def handler(request): 21 | token = request['headers'].get('Authorization', '') 22 | if not token: return "token not found", 401 23 | try: 24 | request['auth_payload'] = jwt.decode(token, JWT_SECRET, algorithms=['HS256']) 25 | return next(request) 26 | except: return "token can not be decoded", 401 27 | return handler 28 | 29 | 30 | @s.register('GET /auth', authMiddleware) 31 | def handleAuth(request): 32 | username = request['auth_payload']['username'] 33 | return f"Hello {username}!!" 34 | 35 | 36 | @s.register('POST /auth') 37 | def handleLogin(request): 38 | username = request['headers'].get('username', '') 39 | if not username: 40 | return "username can not be found", 422 41 | payload = { 42 | 'username': username, 43 | 'iat': datetime.datetime.utcnow(), 44 | 'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=1) 45 | } 46 | token = jwt.encode(payload, JWT_SECRET, algorithm='HS256') 47 | return '', 200, { "Authorization": token } 48 | 49 | 50 | if __name__ == "__main__": 51 | s.start_server(port=int(sys.argv[1])) 52 | -------------------------------------------------------------------------------- /examples/simple_auth/requirements.txt: -------------------------------------------------------------------------------- 1 | PyJWT==2.9.0 2 | -------------------------------------------------------------------------------- /pymicrohttp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hasssanezzz/PyMicroHTTP/52870cf261015390561fad038d2de704b0eb7c95/pymicrohttp/__init__.py -------------------------------------------------------------------------------- /pymicrohttp/server.py: -------------------------------------------------------------------------------- 1 | import re, socket, threading, json 2 | from typing import Callable 3 | from http.client import responses 4 | 5 | class Server: 6 | routes: dict[str, Callable] = {} 7 | before_all_middlewares = [] 8 | 9 | 10 | def start_server(self, host='localhost', port = 9090, listen_msg=False): 11 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket: 12 | server_socket.bind((host, port)) 13 | server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 14 | server_socket.listen() 15 | if listen_msg: 16 | print(f'Server listening on {host}:{port}') 17 | while True: 18 | conn, addr = server_socket.accept() 19 | threading.Thread(target=self.__handle_connection, args=(conn,)).start() 20 | 21 | 22 | def register_handler(self, path: str, handler): 23 | if not self.__is_path_valid(path): 24 | raise ValueError('invalid provided path:', path) 25 | self.routes[path] = handler 26 | 27 | 28 | def register(self, path, middleware = None): 29 | if not self.__is_path_valid(path): 30 | raise ValueError('invalid provided path:', path) 31 | 32 | def decorator(func): 33 | if middleware: 34 | if isinstance(middleware, list): 35 | self.routes[path] = self.__chain_middlewares(middleware, func) 36 | else: 37 | self.routes[path] = middleware(func) 38 | else: 39 | self.routes[path] = func 40 | 41 | return decorator 42 | 43 | 44 | def before_all(self): 45 | def decorator(func): 46 | self.before_all_middlewares.append(func) 47 | return decorator 48 | 49 | 50 | def __handle_connection(self, conn: socket.socket): 51 | with conn: 52 | while True: 53 | data = conn.recv(1024 * 2) 54 | if not data: 55 | break 56 | 57 | try: 58 | reqstr = data.rstrip(b'\x00').decode() 59 | parsed_request = self.__parse_request(reqstr) 60 | except: 61 | return conn.sendall(self.__create_response('can not parse request', 400)) 62 | 63 | path_key = parsed_request["verb"] + ' ' + parsed_request["path"] 64 | 65 | try: 66 | parsed_request['query'] = self.__parse_query_params(parsed_request['path']) 67 | parsed_request['path'] = self.__remove_qparams(parsed_request['path']) 68 | handler, params = self.__find_handler(parsed_request['verb'], parsed_request['path']) 69 | 70 | # 404 - route not found 71 | if not handler: 72 | return self.__create_internal_error_response(conn, '', 404) 73 | 74 | if params: 75 | parsed_request['params'] = params or {} 76 | 77 | result = self.__create_handler(handler)(parsed_request) 78 | if not isinstance(result, tuple): 79 | return conn.sendall(self.__create_response(self.__serialize_response(result))) 80 | 81 | resultLen = len(result) 82 | if resultLen == 3: 83 | response, code, headers = result 84 | return conn.sendall(self.__create_response(self.__serialize_response(response), code, headers)) 85 | if resultLen == 2: 86 | response, code = result 87 | return conn.sendall(self.__create_response(self.__serialize_response(response), code)) 88 | # 500 - invalid number of return values 89 | conn.sendall(self.__create_response('', 500)) 90 | raise ValueError('internal server error: handler returned bad number of values:', resultLen) 91 | except Exception as e: 92 | self.__create_internal_error_response(conn) 93 | raise RuntimeError('internal server error:', e) 94 | 95 | 96 | def __create_handler(self, handler): 97 | if self.before_all_middlewares: 98 | return self.__chain_middlewares(self.before_all_middlewares, handler) 99 | else: 100 | return handler 101 | 102 | 103 | def __create_internal_error_response(self, conn: socket.socket, msg = '', code = 500): 104 | conn.sendall(self.__create_response(msg, code)) 105 | 106 | 107 | def __serialize_response(self, result): 108 | if isinstance(result, dict) or isinstance(result, list): 109 | return json.dumps(result) 110 | return result 111 | 112 | 113 | def __create_response(self, resp, code = 200, headers = {}, contentType = 'application/json'): 114 | resp = resp if isinstance(resp, str) else str(resp) 115 | code_message = responses.get(code, 'Eshta') 116 | http_resp = ( 117 | f'HTTP/1.1 {code} {code_message}\r\n' 118 | f'Content-Type: {contentType}\r\n' 119 | f'Content-Length: {len(resp)}\r\n' 120 | ) 121 | for header, value in headers.items(): 122 | header, value = str(header), str(value) 123 | http_resp += f'{header}: {value}\r\n' 124 | return (http_resp + f'\r\n{resp}').encode() 125 | 126 | 127 | def __parse_request(self, request_string: str): 128 | headers = {} 129 | sep = '\r\n' 130 | try: 131 | head, body = request_string.split(sep + sep) 132 | headlines = head.split(sep) 133 | request_line, headlines = headlines[0], headlines[1:] 134 | verb, path, _ = request_line.split(' ') 135 | for line in headlines: 136 | idx = line.index(':') 137 | header, value = line[:idx], line[idx+2:] 138 | headers[header] = value 139 | return { 140 | 'verb': verb, 141 | 'path': path, 142 | 'headers': headers, 143 | 'body': body 144 | } 145 | except: 146 | raise ValueError('could not parse request') 147 | 148 | 149 | def __is_path_valid(self, path: str) -> bool: 150 | pattern = r'^(\*|GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH) /[^\s]*$' 151 | return bool(re.match(pattern, path)) 152 | 153 | 154 | def __chain_middlewares(self, middlewares, func): 155 | for middleware in reversed(middlewares): 156 | func = middleware(func) 157 | return func 158 | 159 | 160 | def __match_path(self, pattern: str, path: str) -> dict | None: 161 | results = {} 162 | pattern_chunks, path_chunks = pattern.split('/'), path.split('/') 163 | if len(pattern_chunks) != len(path_chunks): 164 | return None 165 | 166 | for pattern_chunk, path_chunk in zip(pattern_chunks, path_chunks): 167 | if pattern_chunk.startswith(':'): 168 | param = pattern_chunk[1:] 169 | results[param] = path_chunk 170 | elif pattern_chunk != path_chunk: 171 | return None 172 | 173 | return results 174 | 175 | 176 | def __parse_query_params(self, path: str) -> dict: 177 | params = {} 178 | 179 | parts = path.split('?', 1) 180 | if len(parts) < 2: 181 | return params # No query parameters 182 | 183 | query_string = parts[1] 184 | param_pairs = query_string.split('&') 185 | for pair in param_pairs: 186 | if '=' in pair: 187 | key, value = pair.split('=', 1) 188 | params[key] = value 189 | else: 190 | params[pair] = '' 191 | return params 192 | 193 | 194 | def __remove_qparams(self, path: str) -> str: 195 | try: 196 | question_mark_index = path.rindex('?') 197 | return path[:question_mark_index] 198 | except ValueError: 199 | return path 200 | 201 | 202 | def __find_handler(self, verb: str, path: str): 203 | fullpath = verb + ' ' + path 204 | if fullpath in self.routes: 205 | return self.routes[fullpath], {} 206 | 207 | for route in self.routes: 208 | route_verb, route_path = route.split(' ') 209 | if route_verb == verb or route_verb == '*': 210 | params = self.__match_path(route_path, path) 211 | if params: 212 | return self.routes[route], params 213 | 214 | return None, None 215 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='pymicrohttp', 5 | version='0.1.2', 6 | packages=find_packages(), 7 | description='Tiny lightweight, flexible HTTP framework.', 8 | long_description=open('README.md').read(), 9 | long_description_content_type='text/markdown', 10 | author='Hassan Ezz', 11 | author_email='dhassanezz@gmail.com', 12 | url='https://github.com/hasssanezzz/PyMicroHTTP', 13 | classifiers=[ 14 | 'Programming Language :: Python :: 3', 15 | 'Operating System :: OS Independent', 16 | ], 17 | python_requires='>=3.6', 18 | ) 19 | --------------------------------------------------------------------------------