uht
Minimal HTTP server for MicroPython and CircuitPython: nmattia/uht
Supports HTTP/1.0 request parsing, routing based on method and path, and response generation using asyncio streams.
Example:
import uht
server = uht.HTTPServer()
@server.route("/hello/<name>")
async def greet(req, resp, name):
await resp.send("Hello, " + name)
server.run("0.0.0.0", 8080)
See the main HTTPServer
class for details.
Project README, examples and more: https://github.com/nmattia/uht
Copyright 2025 Nicolas Mattia
1""" 2Minimal HTTP server for MicroPython and CircuitPython: `nmattia/uht <https://github.com/nmattia/uht>`_ 3 4Supports HTTP/1.0 request parsing, routing based on method and path, and response generation using asyncio streams. 5 6Example: 7```python 8import uht 9 10server = uht.HTTPServer() 11 12@server.route("/hello/<name>") 13async def greet(req, resp, name): 14 await resp.send("Hello, " + name) 15 16server.run("0.0.0.0", 8080) 17``` 18 19See the main :class:`HTTPServer` class for details. 20 21Project README, examples and more: https://github.com/nmattia/uht 22 23Copyright 2025 `Nicolas Mattia <https://github.com/nmattia>`_ 24""" 25 26import logging 27import asyncio 28import gc 29import errno 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61_log = logging.getLogger("WEB") 62 63 64def _match_url_paths(route_path , req_path ) : 65 """ 66 Match a request path against a route path and extract any path parameters. 67 68 Parameters: 69 route_path: The route pattern (e.g., b'/user/<id>'). 70 req_path: The requested URL path (e.g., b'/user/42'). 71 72 Returns: 73 A list of (parameter name, parameter value) pairs if matched; otherwise None. 74 """ 75 path_params = [] 76 77 route_parts = route_path.split(b"/") 78 req_parts = req_path.split(b"/") 79 80 if len(route_parts) != len(req_parts): 81 return None 82 83 # go through the parts, accumulating any path parameters found 84 # along the way. 85 for route_part, req_part in zip(route_parts, req_parts): 86 if route_part.startswith(b"<") and route_part.endswith(b">"): 87 param_key = route_part[1:-1] 88 param_val = req_part 89 90 path_params.append((param_key, param_val)) 91 continue 92 93 if route_part != req_part: 94 return None 95 96 return path_params 97 98 99class HTTPException(Exception): 100 """HTTP protocol exceptions""" 101 102 def __init__(self, code=400): 103 self.code = code 104 105 106# per https://www.rfc-editor.org/rfc/rfc9110#table-4 107_SUPPORTED_METHODS = [ 108 b"GET", 109 b"HEAD", 110 b"POST", 111 b"PUT", 112 b"DELETE", 113 b"CONNECT", 114 b"OPTIONS", 115 b"TRACE", 116] 117 118 119def _parse_request_line(line ) : 120 """ 121 Parse an HTTP request line according to RFC 9112. 122 123 As per https://www.rfc-editor.org/rfc/rfc9112.html#name-request-line 124 request-line = method SP request-target SP HTTP-version 125 where SP is "single space" 126 where method is defined in https://www.rfc-editor.org/rfc/rfc9110#section-9 127 where request-target is arbitrary for our purposes 128 where HTTP-version is 'HTTP-version = HTTP-name "/" DIGIT "." DIGIT' 129 (https://www.rfc-editor.org/rfc/rfc9112.html#name-http-version) 130 131 Parameters: 132 line: The raw request line as bytes (e.g., b'GET / HTTP/1.1'). 133 134 Returns: 135 A dictionary with 'method', 'target', and 'version', or None if invalid. 136 """ 137 fragments = line.split(b" ") 138 if len(fragments) != 3: 139 return None 140 141 if fragments[0] not in _SUPPORTED_METHODS: 142 return None 143 144 if not fragments[1]: 145 return None 146 147 http_version_fragments = fragments[2].split(b"/") 148 if len(http_version_fragments) != 2: 149 return None 150 151 if http_version_fragments[0] != b"HTTP": 152 return None 153 154 version_fragments = http_version_fragments[1].split(b".") 155 156 if len(version_fragments) != 2: 157 return None 158 159 try: 160 version_major = int(version_fragments[0]) 161 version_minor = int(version_fragments[1]) 162 except ValueError: # failed to parse as int 163 return None 164 165 return { 166 "method": fragments[0], 167 "target": fragments[1], 168 "version": (version_major, version_minor), 169 } 170 171 172class Request: 173 """HTTP Request class 174 175 :class:`HTTPServer` 176 177 """ 178 179 def __init__(self, _reader): 180 self.reader = _reader 181 # headers are 'None' until `_read_headers` is called 182 self.headers = None 183 self.method = b"" 184 self.path = b"" 185 self.query_string = b"" 186 self.version = "1.0" 187 self.params = { 188 "save_headers": [], 189 } 190 191 async def _read_request_line(self): 192 """ 193 Read and parse the HTTP request line from the client. 194 195 Updates self.method, self.path, and self.query_string. 196 197 Raises: 198 HTTPException(400): If the request line is malformed. 199 200 This is a coroutine. 201 """ 202 while True: 203 rl_raw = await self.reader.readline() 204 # skip empty lines 205 if rl_raw == b"\r\n" or rl_raw == b"\n": 206 continue 207 break 208 209 rl = _parse_request_line(rl_raw) 210 if not rl: 211 raise HTTPException(400) 212 213 self.method = rl["method"] 214 215 url_frags = rl["target"].split(b"?", 1) 216 217 self.path = url_frags[0] 218 if len(url_frags) > 1: 219 self.query_string = url_frags[1] 220 221 async def _read_headers(self, save_headers=[]): 222 """ 223 Read HTTP headers from the stream and store selected ones. 224 225 Parameters: 226 save_headers: List of header names (bytes or strings) to preserve in self.headers. 227 228 Raises: 229 HTTPException(400): If a header line is malformed. 230 231 This is a coroutine. 232 """ 233 self.headers = {} 234 while True: 235 gc.collect() 236 line = await self.reader.readline() 237 if line == b"\r\n": 238 break 239 frags = line.split(b":", 1) 240 if len(frags) != 2: 241 raise HTTPException(400) 242 243 if frags[0].lower() in [header.lower() for header in save_headers]: 244 self.headers[frags[0].lower()] = frags[1].strip() 245 246 247class Response: 248 """HTTP Response class""" 249 250 VERSION = b"1.0" # we only support 1.0 251 252 def __init__(self, _writer): 253 self._writer = _writer 254 255 # Request line fields 256 257 self._status_code = 200 258 self._reason_phrase = None # optional as per HTTP spec 259 260 # Set to 'True' once the request line has been sent 261 self._status_line_sent = False 262 263 # Header fields 264 265 self.headers = {} 266 # Set to 'True' once the header lines have been sent 267 self._headers_sent = False 268 269 async def _ensure_ready_for_body(self): 270 """ 271 Ensure the status line and headers are sent before sending a response body. 272 273 Raises: 274 Exception: If headers are sent before the status line. 275 276 This is a coroutine. 277 """ 278 status_line_sent = self._status_line_sent 279 headers_sent = self._headers_sent 280 281 if not status_line_sent: 282 if headers_sent: 283 raise Exception("Headers were sent before status line") 284 await self._send_status_line() 285 286 if not headers_sent: 287 await self._send_headers() 288 289 def set_status_code(self, value ): 290 """ 291 Set the HTTP status code to send. 292 293 Parameters: 294 value: Integer status code (e.g., 200, 404). 295 296 Raises: 297 Exception: If the status line has already been sent. 298 """ 299 if self._status_line_sent: 300 raise Exception("status line already sent") 301 302 self._status_code = value 303 304 def set_reason_phrase(self, value ): 305 """ 306 Set the optional reason phrase for the HTTP status line. 307 308 Parameters: 309 value: A string like 'OK' or 'NOT FOUND'. 310 311 Raises: 312 Exception: If the status line has already been sent. 313 """ 314 if self._status_line_sent: 315 raise Exception("status line already sent") 316 317 self._reason_phrase = value 318 319 async def _send_status_line(self): 320 """ 321 Send the HTTP status line to the client. 322 323 Raises: 324 Exception: If the status line has already been sent. 325 326 This is a coroutine. 327 """ 328 if self._status_line_sent: 329 raise Exception("status line already sent") 330 331 # even if reason phrase is empty, the "preceding" space must be present 332 # https://www.rfc-editor.org/rfc/rfc9112.html#section-4-9 333 334 sl = "HTTP/%s %s %s\r\n" % ( 335 Response.VERSION.decode(), 336 self._status_code, 337 self._reason_phrase or "", 338 ) 339 self._writer.write(sl) 340 self._status_line_sent = True 341 await self._writer.drain() 342 343 async def _send_headers(self): 344 """ 345 Send all HTTP headers followed by a blank line. 346 347 Raises: 348 Exception: If headers have already been sent. 349 350 This is a coroutine. 351 """ 352 353 if self.headers is None: 354 raise Exception("Headers already sent") 355 356 hdrs = "" 357 # Headers 358 for k, v in self.headers.items(): 359 hdrs += "%s: %s\r\n" % (k, v) 360 hdrs += "\r\n" 361 362 self._writer.write(hdrs) 363 self._headers_sent = True 364 await self._writer.drain() 365 # Collect garbage after small mallocs 366 gc.collect() 367 368 async def send(self, content, **kwargs): 369 """ 370 Send the response body content to the client. 371 372 May be called as many times as needed, the content will be appended to the 373 body. 374 375 Parameters: 376 content: The data to send as response body. 377 378 Raises: 379 Exception: If headers or status line are not ready. 380 381 This is a coroutine. 382 """ 383 await self._ensure_ready_for_body() 384 385 self._writer.write(content) 386 await self._writer.drain() 387 388 def add_header(self, key, value): 389 """ 390 Add a header to the response. 391 392 Parameters: 393 key: The header name. 394 value: The header value. 395 396 Raises: 397 Exception: If headers have already been sent. 398 """ 399 if self._headers_sent: 400 raise Exception("Headers already sent") 401 402 self.headers[key.lower()] = value 403 404 405class HTTPServer: 406 def __init__(self, backlog=16): 407 """ 408 HTTPServer class. 409 410 See the :func:`route` decorator for specifying routes. 411 See :func:`run` for starting the server. 412 413 """ 414 self._backlog = backlog 415 self._routes = [] 416 self._catch_all_handler = None 417 418 def run(self, host="127.0.0.1", port=8081): 419 """ 420 Start the HTTP server (blocking) on the specified host and port and run forever. 421 422 Parameters: 423 host: Interface to bind to (default "127.0.0.1"). 424 port: Port number to listen on (default 8081). 425 """ 426 asyncio.run(self.arun(host, port)) 427 428 def _find_url_handler(self, req) : 429 """ 430 Find the registered handler matching the given request method and path. 431 432 Parameters: 433 req: A Request instance. 434 435 Returns: 436 A tuple of (handler, params, path_parameters). 437 438 Raises: 439 HTTPException(404): If no matching path. 440 HTTPException(405): If path matches but method does not. 441 HTTPException(501): For unsupported methods (CONNECT, OPTIONS, TRACE). 442 """ 443 444 # we only support basic (GET, PUT, etc) requests 445 if ( 446 req.method == b"CONNECT" 447 or req.method == b"OPTIONS" 448 or req.method == b"TRACE" 449 ): 450 raise HTTPException(501) 451 452 # tracks whether there was an exact path match to differentiate 453 # between 404 and 405 454 path_matched = False 455 456 for method, path, handler, params in self._routes: 457 result = _match_url_paths(path, req.path) 458 if result is not None: 459 if method == req.method: 460 return (handler, params, result) 461 462 path_matched = True 463 464 if self._catch_all_handler: 465 return self._catch_all_handler 466 467 if path_matched: 468 raise HTTPException(405) 469 470 # No handler found 471 raise HTTPException(404) 472 473 async def _handle_connection(self, reader, writer): 474 """ 475 Handle a client TCP connection, parse request (HTTP/1.0), call handler, return response. 476 477 Parameters: 478 reader: StreamReader for reading the connection. 479 writer: StreamWriter for writing the connection. 480 481 This is a coroutine. 482 """ 483 gc.collect() 484 485 try: 486 req = Request(reader) 487 resp = Response(writer) 488 await req._read_request_line() 489 490 # Find URL handler and parse headers 491 (handler, req_params, path_params) = self._find_url_handler(req) 492 await req._read_headers(req_params.get("save_headers") or []) 493 494 gc.collect() # free up some memory before the handler runs 495 496 path_param_values = [v.decode() for (_, v) in path_params] 497 await handler(req, resp, *path_param_values) 498 499 # ensure the status line & headers are sent even if there 500 # was no body 501 await resp._ensure_ready_for_body() 502 # Done here 503 except (asyncio.CancelledError, asyncio.TimeoutError): 504 pass 505 except OSError as e: 506 # Do not send response for connection related errors - too late :) 507 # P.S. code 32 - is possible BROKEN PIPE error (TODO: is it true?) 508 if e.args[0] not in (errno.ECONNABORTED, errno.ECONNRESET, 32): 509 _log.exception(f"Connection error: {e}") 510 try: 511 resp.set_status_code(500) 512 await resp._ensure_ready_for_body() 513 except Exception as e: 514 pass 515 except HTTPException as e: 516 try: 517 if req.headers is None: 518 await req._read_headers() 519 resp.set_status_code(e.code) 520 await resp._ensure_ready_for_body() 521 except Exception as e: 522 _log.exception( 523 f"Failed to send error after HTTPException. Original error: {e}" 524 ) 525 except Exception as e: 526 # Unhandled expection in user's method 527 _log.error(req.path.decode()) 528 _log.exception(f"Unhandled exception in user's method. Original error: {e}") 529 try: 530 resp.set_status_code(500) 531 await resp._ensure_ready_for_body() 532 except Exception as e: 533 pass 534 finally: 535 writer.close() 536 await writer.wait_closed() 537 538 def add_route( 539 self, 540 url , 541 f, 542 methods = ["GET"], 543 save_headers = [], 544 ): 545 """ 546 Register a route handler for a URL pattern and list of HTTP methods. 547 548 Parameters: 549 url: The route path pattern (e.g., '/hello/<name>'). 550 f: The handler function (async). 551 methods: List of allowed HTTP methods. 552 save_headers: Headers to preserve from the request. 553 """ 554 if url == "" or "?" in url: 555 raise ValueError("Invalid URL") 556 _save_headers = [x.encode() if isinstance(x, str) else x for x in save_headers] 557 _save_headers = [x.lower() for x in _save_headers] 558 # Initial params for route 559 params = { 560 "save_headers": _save_headers, 561 } 562 563 for method in [x.encode().upper() for x in methods]: 564 self._routes.append((method, url.encode(), f, params)) 565 566 def catchall(self): 567 """ 568 Decorator to register a catch-all handler when no routes match. 569 570 Returns: 571 A decorator function. 572 """ 573 params = { 574 "save_headers": [], 575 } 576 577 def _route(f): 578 self._catch_all_handler = (f, params, {}) 579 return f 580 581 return _route 582 583 def route(self, url, **kwargs): 584 """ 585 Decorator to register a route handler. 586 587 Parameters: 588 url: The route path pattern. 589 kwargs: Arguments passed to add_route(), e.g., methods or save_headers. 590 591 Returns: 592 A decorator function. 593 """ 594 595 def _route(f): 596 self.add_route(url, f, **kwargs) 597 return f 598 599 return _route 600 601 async def arun(self, host="127.0.0.1", port=8081): 602 """ 603 Asynchronously start the server and wait for it to close. 604 605 Parameters: 606 host: Interface to bind to. 607 port: Port number. 608 609 This is a coroutine. 610 """ 611 aserver = await self.start(host, port) 612 server = await aserver 613 await server.wait_closed() 614 615 async def start(self, host, port) : 616 """ 617 Start the server and return the asyncio.Server instance. 618 619 Parameters: 620 host: Interface to bind to. 621 port: Port number. 622 623 Returns: 624 An asyncio.Server instance. 625 """ 626 return asyncio.start_server( 627 self._handle_connection, host, port, backlog=self._backlog 628 )
100class HTTPException(Exception): 101 """HTTP protocol exceptions""" 102 103 def __init__(self, code=400): 104 self.code = code
HTTP protocol exceptions
173class Request: 174 """HTTP Request class 175 176 :class:`HTTPServer` 177 178 """ 179 180 def __init__(self, _reader): 181 self.reader = _reader 182 # headers are 'None' until `_read_headers` is called 183 self.headers = None 184 self.method = b"" 185 self.path = b"" 186 self.query_string = b"" 187 self.version = "1.0" 188 self.params = { 189 "save_headers": [], 190 } 191 192 async def _read_request_line(self): 193 """ 194 Read and parse the HTTP request line from the client. 195 196 Updates self.method, self.path, and self.query_string. 197 198 Raises: 199 HTTPException(400): If the request line is malformed. 200 201 This is a coroutine. 202 """ 203 while True: 204 rl_raw = await self.reader.readline() 205 # skip empty lines 206 if rl_raw == b"\r\n" or rl_raw == b"\n": 207 continue 208 break 209 210 rl = _parse_request_line(rl_raw) 211 if not rl: 212 raise HTTPException(400) 213 214 self.method = rl["method"] 215 216 url_frags = rl["target"].split(b"?", 1) 217 218 self.path = url_frags[0] 219 if len(url_frags) > 1: 220 self.query_string = url_frags[1] 221 222 async def _read_headers(self, save_headers=[]): 223 """ 224 Read HTTP headers from the stream and store selected ones. 225 226 Parameters: 227 save_headers: List of header names (bytes or strings) to preserve in self.headers. 228 229 Raises: 230 HTTPException(400): If a header line is malformed. 231 232 This is a coroutine. 233 """ 234 self.headers = {} 235 while True: 236 gc.collect() 237 line = await self.reader.readline() 238 if line == b"\r\n": 239 break 240 frags = line.split(b":", 1) 241 if len(frags) != 2: 242 raise HTTPException(400) 243 244 if frags[0].lower() in [header.lower() for header in save_headers]: 245 self.headers[frags[0].lower()] = frags[1].strip()
HTTP Request class
248class Response: 249 """HTTP Response class""" 250 251 VERSION = b"1.0" # we only support 1.0 252 253 def __init__(self, _writer): 254 self._writer = _writer 255 256 # Request line fields 257 258 self._status_code = 200 259 self._reason_phrase = None # optional as per HTTP spec 260 261 # Set to 'True' once the request line has been sent 262 self._status_line_sent = False 263 264 # Header fields 265 266 self.headers = {} 267 # Set to 'True' once the header lines have been sent 268 self._headers_sent = False 269 270 async def _ensure_ready_for_body(self): 271 """ 272 Ensure the status line and headers are sent before sending a response body. 273 274 Raises: 275 Exception: If headers are sent before the status line. 276 277 This is a coroutine. 278 """ 279 status_line_sent = self._status_line_sent 280 headers_sent = self._headers_sent 281 282 if not status_line_sent: 283 if headers_sent: 284 raise Exception("Headers were sent before status line") 285 await self._send_status_line() 286 287 if not headers_sent: 288 await self._send_headers() 289 290 def set_status_code(self, value ): 291 """ 292 Set the HTTP status code to send. 293 294 Parameters: 295 value: Integer status code (e.g., 200, 404). 296 297 Raises: 298 Exception: If the status line has already been sent. 299 """ 300 if self._status_line_sent: 301 raise Exception("status line already sent") 302 303 self._status_code = value 304 305 def set_reason_phrase(self, value ): 306 """ 307 Set the optional reason phrase for the HTTP status line. 308 309 Parameters: 310 value: A string like 'OK' or 'NOT FOUND'. 311 312 Raises: 313 Exception: If the status line has already been sent. 314 """ 315 if self._status_line_sent: 316 raise Exception("status line already sent") 317 318 self._reason_phrase = value 319 320 async def _send_status_line(self): 321 """ 322 Send the HTTP status line to the client. 323 324 Raises: 325 Exception: If the status line has already been sent. 326 327 This is a coroutine. 328 """ 329 if self._status_line_sent: 330 raise Exception("status line already sent") 331 332 # even if reason phrase is empty, the "preceding" space must be present 333 # https://www.rfc-editor.org/rfc/rfc9112.html#section-4-9 334 335 sl = "HTTP/%s %s %s\r\n" % ( 336 Response.VERSION.decode(), 337 self._status_code, 338 self._reason_phrase or "", 339 ) 340 self._writer.write(sl) 341 self._status_line_sent = True 342 await self._writer.drain() 343 344 async def _send_headers(self): 345 """ 346 Send all HTTP headers followed by a blank line. 347 348 Raises: 349 Exception: If headers have already been sent. 350 351 This is a coroutine. 352 """ 353 354 if self.headers is None: 355 raise Exception("Headers already sent") 356 357 hdrs = "" 358 # Headers 359 for k, v in self.headers.items(): 360 hdrs += "%s: %s\r\n" % (k, v) 361 hdrs += "\r\n" 362 363 self._writer.write(hdrs) 364 self._headers_sent = True 365 await self._writer.drain() 366 # Collect garbage after small mallocs 367 gc.collect() 368 369 async def send(self, content, **kwargs): 370 """ 371 Send the response body content to the client. 372 373 May be called as many times as needed, the content will be appended to the 374 body. 375 376 Parameters: 377 content: The data to send as response body. 378 379 Raises: 380 Exception: If headers or status line are not ready. 381 382 This is a coroutine. 383 """ 384 await self._ensure_ready_for_body() 385 386 self._writer.write(content) 387 await self._writer.drain() 388 389 def add_header(self, key, value): 390 """ 391 Add a header to the response. 392 393 Parameters: 394 key: The header name. 395 value: The header value. 396 397 Raises: 398 Exception: If headers have already been sent. 399 """ 400 if self._headers_sent: 401 raise Exception("Headers already sent") 402 403 self.headers[key.lower()] = value
HTTP Response class
253 def __init__(self, _writer): 254 self._writer = _writer 255 256 # Request line fields 257 258 self._status_code = 200 259 self._reason_phrase = None # optional as per HTTP spec 260 261 # Set to 'True' once the request line has been sent 262 self._status_line_sent = False 263 264 # Header fields 265 266 self.headers = {} 267 # Set to 'True' once the header lines have been sent 268 self._headers_sent = False
290 def set_status_code(self, value ): 291 """ 292 Set the HTTP status code to send. 293 294 Parameters: 295 value: Integer status code (e.g., 200, 404). 296 297 Raises: 298 Exception: If the status line has already been sent. 299 """ 300 if self._status_line_sent: 301 raise Exception("status line already sent") 302 303 self._status_code = value
Set the HTTP status code to send.
Parameters: value: Integer status code (e.g., 200, 404).
Raises: Exception: If the status line has already been sent.
305 def set_reason_phrase(self, value ): 306 """ 307 Set the optional reason phrase for the HTTP status line. 308 309 Parameters: 310 value: A string like 'OK' or 'NOT FOUND'. 311 312 Raises: 313 Exception: If the status line has already been sent. 314 """ 315 if self._status_line_sent: 316 raise Exception("status line already sent") 317 318 self._reason_phrase = value
Set the optional reason phrase for the HTTP status line.
Parameters: value: A string like 'OK' or 'NOT FOUND'.
Raises: Exception: If the status line has already been sent.
369 async def send(self, content, **kwargs): 370 """ 371 Send the response body content to the client. 372 373 May be called as many times as needed, the content will be appended to the 374 body. 375 376 Parameters: 377 content: The data to send as response body. 378 379 Raises: 380 Exception: If headers or status line are not ready. 381 382 This is a coroutine. 383 """ 384 await self._ensure_ready_for_body() 385 386 self._writer.write(content) 387 await self._writer.drain()
Send the response body content to the client.
May be called as many times as needed, the content will be appended to the body.
Parameters: content: The data to send as response body.
Raises: Exception: If headers or status line are not ready.
This is a coroutine.
389 def add_header(self, key, value): 390 """ 391 Add a header to the response. 392 393 Parameters: 394 key: The header name. 395 value: The header value. 396 397 Raises: 398 Exception: If headers have already been sent. 399 """ 400 if self._headers_sent: 401 raise Exception("Headers already sent") 402 403 self.headers[key.lower()] = value
Add a header to the response.
Parameters: key: The header name. value: The header value.
Raises: Exception: If headers have already been sent.
406class HTTPServer: 407 def __init__(self, backlog=16): 408 """ 409 HTTPServer class. 410 411 See the :func:`route` decorator for specifying routes. 412 See :func:`run` for starting the server. 413 414 """ 415 self._backlog = backlog 416 self._routes = [] 417 self._catch_all_handler = None 418 419 def run(self, host="127.0.0.1", port=8081): 420 """ 421 Start the HTTP server (blocking) on the specified host and port and run forever. 422 423 Parameters: 424 host: Interface to bind to (default "127.0.0.1"). 425 port: Port number to listen on (default 8081). 426 """ 427 asyncio.run(self.arun(host, port)) 428 429 def _find_url_handler(self, req) : 430 """ 431 Find the registered handler matching the given request method and path. 432 433 Parameters: 434 req: A Request instance. 435 436 Returns: 437 A tuple of (handler, params, path_parameters). 438 439 Raises: 440 HTTPException(404): If no matching path. 441 HTTPException(405): If path matches but method does not. 442 HTTPException(501): For unsupported methods (CONNECT, OPTIONS, TRACE). 443 """ 444 445 # we only support basic (GET, PUT, etc) requests 446 if ( 447 req.method == b"CONNECT" 448 or req.method == b"OPTIONS" 449 or req.method == b"TRACE" 450 ): 451 raise HTTPException(501) 452 453 # tracks whether there was an exact path match to differentiate 454 # between 404 and 405 455 path_matched = False 456 457 for method, path, handler, params in self._routes: 458 result = _match_url_paths(path, req.path) 459 if result is not None: 460 if method == req.method: 461 return (handler, params, result) 462 463 path_matched = True 464 465 if self._catch_all_handler: 466 return self._catch_all_handler 467 468 if path_matched: 469 raise HTTPException(405) 470 471 # No handler found 472 raise HTTPException(404) 473 474 async def _handle_connection(self, reader, writer): 475 """ 476 Handle a client TCP connection, parse request (HTTP/1.0), call handler, return response. 477 478 Parameters: 479 reader: StreamReader for reading the connection. 480 writer: StreamWriter for writing the connection. 481 482 This is a coroutine. 483 """ 484 gc.collect() 485 486 try: 487 req = Request(reader) 488 resp = Response(writer) 489 await req._read_request_line() 490 491 # Find URL handler and parse headers 492 (handler, req_params, path_params) = self._find_url_handler(req) 493 await req._read_headers(req_params.get("save_headers") or []) 494 495 gc.collect() # free up some memory before the handler runs 496 497 path_param_values = [v.decode() for (_, v) in path_params] 498 await handler(req, resp, *path_param_values) 499 500 # ensure the status line & headers are sent even if there 501 # was no body 502 await resp._ensure_ready_for_body() 503 # Done here 504 except (asyncio.CancelledError, asyncio.TimeoutError): 505 pass 506 except OSError as e: 507 # Do not send response for connection related errors - too late :) 508 # P.S. code 32 - is possible BROKEN PIPE error (TODO: is it true?) 509 if e.args[0] not in (errno.ECONNABORTED, errno.ECONNRESET, 32): 510 _log.exception(f"Connection error: {e}") 511 try: 512 resp.set_status_code(500) 513 await resp._ensure_ready_for_body() 514 except Exception as e: 515 pass 516 except HTTPException as e: 517 try: 518 if req.headers is None: 519 await req._read_headers() 520 resp.set_status_code(e.code) 521 await resp._ensure_ready_for_body() 522 except Exception as e: 523 _log.exception( 524 f"Failed to send error after HTTPException. Original error: {e}" 525 ) 526 except Exception as e: 527 # Unhandled expection in user's method 528 _log.error(req.path.decode()) 529 _log.exception(f"Unhandled exception in user's method. Original error: {e}") 530 try: 531 resp.set_status_code(500) 532 await resp._ensure_ready_for_body() 533 except Exception as e: 534 pass 535 finally: 536 writer.close() 537 await writer.wait_closed() 538 539 def add_route( 540 self, 541 url , 542 f, 543 methods = ["GET"], 544 save_headers = [], 545 ): 546 """ 547 Register a route handler for a URL pattern and list of HTTP methods. 548 549 Parameters: 550 url: The route path pattern (e.g., '/hello/<name>'). 551 f: The handler function (async). 552 methods: List of allowed HTTP methods. 553 save_headers: Headers to preserve from the request. 554 """ 555 if url == "" or "?" in url: 556 raise ValueError("Invalid URL") 557 _save_headers = [x.encode() if isinstance(x, str) else x for x in save_headers] 558 _save_headers = [x.lower() for x in _save_headers] 559 # Initial params for route 560 params = { 561 "save_headers": _save_headers, 562 } 563 564 for method in [x.encode().upper() for x in methods]: 565 self._routes.append((method, url.encode(), f, params)) 566 567 def catchall(self): 568 """ 569 Decorator to register a catch-all handler when no routes match. 570 571 Returns: 572 A decorator function. 573 """ 574 params = { 575 "save_headers": [], 576 } 577 578 def _route(f): 579 self._catch_all_handler = (f, params, {}) 580 return f 581 582 return _route 583 584 def route(self, url, **kwargs): 585 """ 586 Decorator to register a route handler. 587 588 Parameters: 589 url: The route path pattern. 590 kwargs: Arguments passed to add_route(), e.g., methods or save_headers. 591 592 Returns: 593 A decorator function. 594 """ 595 596 def _route(f): 597 self.add_route(url, f, **kwargs) 598 return f 599 600 return _route 601 602 async def arun(self, host="127.0.0.1", port=8081): 603 """ 604 Asynchronously start the server and wait for it to close. 605 606 Parameters: 607 host: Interface to bind to. 608 port: Port number. 609 610 This is a coroutine. 611 """ 612 aserver = await self.start(host, port) 613 server = await aserver 614 await server.wait_closed() 615 616 async def start(self, host, port) : 617 """ 618 Start the server and return the asyncio.Server instance. 619 620 Parameters: 621 host: Interface to bind to. 622 port: Port number. 623 624 Returns: 625 An asyncio.Server instance. 626 """ 627 return asyncio.start_server( 628 self._handle_connection, host, port, backlog=self._backlog 629 )
419 def run(self, host="127.0.0.1", port=8081): 420 """ 421 Start the HTTP server (blocking) on the specified host and port and run forever. 422 423 Parameters: 424 host: Interface to bind to (default "127.0.0.1"). 425 port: Port number to listen on (default 8081). 426 """ 427 asyncio.run(self.arun(host, port))
Start the HTTP server (blocking) on the specified host and port and run forever.
Parameters: host: Interface to bind to (default "127.0.0.1"). port: Port number to listen on (default 8081).
539 def add_route( 540 self, 541 url , 542 f, 543 methods = ["GET"], 544 save_headers = [], 545 ): 546 """ 547 Register a route handler for a URL pattern and list of HTTP methods. 548 549 Parameters: 550 url: The route path pattern (e.g., '/hello/<name>'). 551 f: The handler function (async). 552 methods: List of allowed HTTP methods. 553 save_headers: Headers to preserve from the request. 554 """ 555 if url == "" or "?" in url: 556 raise ValueError("Invalid URL") 557 _save_headers = [x.encode() if isinstance(x, str) else x for x in save_headers] 558 _save_headers = [x.lower() for x in _save_headers] 559 # Initial params for route 560 params = { 561 "save_headers": _save_headers, 562 } 563 564 for method in [x.encode().upper() for x in methods]: 565 self._routes.append((method, url.encode(), f, params))
Register a route handler for a URL pattern and list of HTTP methods.
Parameters:
url: The route path pattern (e.g., '/hello/
567 def catchall(self): 568 """ 569 Decorator to register a catch-all handler when no routes match. 570 571 Returns: 572 A decorator function. 573 """ 574 params = { 575 "save_headers": [], 576 } 577 578 def _route(f): 579 self._catch_all_handler = (f, params, {}) 580 return f 581 582 return _route
Decorator to register a catch-all handler when no routes match.
Returns: A decorator function.
584 def route(self, url, **kwargs): 585 """ 586 Decorator to register a route handler. 587 588 Parameters: 589 url: The route path pattern. 590 kwargs: Arguments passed to add_route(), e.g., methods or save_headers. 591 592 Returns: 593 A decorator function. 594 """ 595 596 def _route(f): 597 self.add_route(url, f, **kwargs) 598 return f 599 600 return _route
Decorator to register a route handler.
Parameters: url: The route path pattern. kwargs: Arguments passed to add_route(), e.g., methods or save_headers.
Returns: A decorator function.
602 async def arun(self, host="127.0.0.1", port=8081): 603 """ 604 Asynchronously start the server and wait for it to close. 605 606 Parameters: 607 host: Interface to bind to. 608 port: Port number. 609 610 This is a coroutine. 611 """ 612 aserver = await self.start(host, port) 613 server = await aserver 614 await server.wait_closed()
Asynchronously start the server and wait for it to close.
Parameters: host: Interface to bind to. port: Port number.
This is a coroutine.
616 async def start(self, host, port) : 617 """ 618 Start the server and return the asyncio.Server instance. 619 620 Parameters: 621 host: Interface to bind to. 622 port: Port number. 623 624 Returns: 625 An asyncio.Server instance. 626 """ 627 return asyncio.start_server( 628 self._handle_connection, host, port, backlog=self._backlog 629 )
Start the server and return the asyncio.Server instance.
Parameters: host: Interface to bind to. port: Port number.
Returns: An asyncio.Server instance.