one-ring-http 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- one_ring_http-0.1.0/.gitignore +43 -0
- one_ring_http-0.1.0/CLAUDE.md +28 -0
- one_ring_http-0.1.0/PKG-INFO +73 -0
- one_ring_http-0.1.0/README.md +50 -0
- one_ring_http-0.1.0/justfile +20 -0
- one_ring_http-0.1.0/pyproject.toml +44 -0
- one_ring_http-0.1.0/src/one_ring_http/__init__.py +3 -0
- one_ring_http-0.1.0/src/one_ring_http/log.py +55 -0
- one_ring_http-0.1.0/src/one_ring_http/py.typed +0 -0
- one_ring_http-0.1.0/src/one_ring_http/request.py +86 -0
- one_ring_http-0.1.0/src/one_ring_http/response.py +38 -0
- one_ring_http-0.1.0/src/one_ring_http/router.py +39 -0
- one_ring_http-0.1.0/src/one_ring_http/server.py +79 -0
- one_ring_http-0.1.0/src/one_ring_http/static.py +61 -0
- one_ring_http-0.1.0/src/one_ring_http/typedef.py +12 -0
- one_ring_http-0.1.0/tests/conftest.py +37 -0
- one_ring_http-0.1.0/tests/test_log.py +37 -0
- one_ring_http-0.1.0/tests/test_request.py +159 -0
- one_ring_http-0.1.0/tests/test_response.py +37 -0
- one_ring_http-0.1.0/tests/test_router.py +72 -0
- one_ring_http-0.1.0/tests/test_server.py +266 -0
- one_ring_http-0.1.0/tests/test_static.py +249 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
|
|
8
|
+
# Virtual environments
|
|
9
|
+
.venv/
|
|
10
|
+
|
|
11
|
+
# Testing
|
|
12
|
+
.coverage
|
|
13
|
+
htmlcov/
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
|
|
16
|
+
# Tools
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
|
|
19
|
+
# IDE
|
|
20
|
+
.idea/
|
|
21
|
+
.vscode/*
|
|
22
|
+
!.vscode/settings.json
|
|
23
|
+
!.vscode/extensions.json
|
|
24
|
+
*.swp
|
|
25
|
+
*.swo
|
|
26
|
+
|
|
27
|
+
# Docs
|
|
28
|
+
site/
|
|
29
|
+
|
|
30
|
+
# Secrets
|
|
31
|
+
.env
|
|
32
|
+
.env.*
|
|
33
|
+
*.pem
|
|
34
|
+
|
|
35
|
+
# OS
|
|
36
|
+
.DS_Store
|
|
37
|
+
Thumbs.db
|
|
38
|
+
|
|
39
|
+
# Personal testing.
|
|
40
|
+
tmp/
|
|
41
|
+
|
|
42
|
+
# Agents
|
|
43
|
+
.claude
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# one-ring-http — Package Context
|
|
2
|
+
|
|
3
|
+
Part of the **one-ring** monorepo. See the root `CLAUDE.md` for shared conventions.
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
<!-- Describe what this package does -->
|
|
8
|
+
|
|
9
|
+
## Package Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# From this directory:
|
|
13
|
+
just test # Run tests for this package
|
|
14
|
+
just test-cov # Tests with coverage
|
|
15
|
+
just typecheck # Type check this package
|
|
16
|
+
|
|
17
|
+
# From monorepo root:
|
|
18
|
+
just test-pkg one-ring-http # Test this package
|
|
19
|
+
just check # Run all checks (all packages)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Layout
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
src/one_ring_http/ # Source code
|
|
26
|
+
tests/ # Tests
|
|
27
|
+
pyproject.toml # Package metadata (tool config inherited from root)
|
|
28
|
+
```
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: one-ring-http
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HTTP/1.1 server built on one-ring-loop and io_uring
|
|
5
|
+
Project-URL: Homepage, https://github.com/otto-sellerstam/one-ring
|
|
6
|
+
Project-URL: Repository, https://github.com/otto-sellerstam/one-ring
|
|
7
|
+
Project-URL: Issues, https://github.com/otto-sellerstam/one-ring/issues
|
|
8
|
+
Author-email: Otto Sellerstam <ottosellerstam@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: async,http,io_uring,linux,server
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.14
|
|
20
|
+
Requires-Dist: one-ring-loop>=0.1.0
|
|
21
|
+
Requires-Dist: structlog>=24.0
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# one-ring-http
|
|
25
|
+
|
|
26
|
+
HTTP/1.1 server built on [one-ring-loop](https://pypi.org/project/one-ring-loop/) and Linux io_uring.
|
|
27
|
+
|
|
28
|
+
Part of the [one-ring](https://github.com/otto-sellerstam/one-ring) project.
|
|
29
|
+
|
|
30
|
+
## What it provides
|
|
31
|
+
|
|
32
|
+
- **HTTPServer** - TLS-enabled HTTP/1.1 server
|
|
33
|
+
- **Router** - method + path routing with fallback handlers
|
|
34
|
+
- **Request/Response** - parsed HTTP requests, serializable responses
|
|
35
|
+
- **Static file serving** - built-in static file handler
|
|
36
|
+
|
|
37
|
+
## Example
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import ssl
|
|
41
|
+
|
|
42
|
+
from one_ring_http.response import Response
|
|
43
|
+
from one_ring_http.router import Router
|
|
44
|
+
from one_ring_http.server import HTTPServer
|
|
45
|
+
from one_ring_loop import run
|
|
46
|
+
|
|
47
|
+
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
48
|
+
ssl_context.load_cert_chain("cert.pem", "key.pem")
|
|
49
|
+
|
|
50
|
+
def hello(request):
|
|
51
|
+
return Response(status_code=200, body=b"Hello, world!")
|
|
52
|
+
|
|
53
|
+
router = Router()
|
|
54
|
+
router.add("GET", "/", hello)
|
|
55
|
+
|
|
56
|
+
server = HTTPServer(router=router, host="127.0.0.1", port=8000, ssl_context=ssl_context)
|
|
57
|
+
run(server.serve())
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Requirements
|
|
61
|
+
|
|
62
|
+
- **Linux** with io_uring support (kernel 6.7+)
|
|
63
|
+
- **Python 3.14+**
|
|
64
|
+
|
|
65
|
+
## Installation
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
uv add one-ring-http
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# one-ring-http
|
|
2
|
+
|
|
3
|
+
HTTP/1.1 server built on [one-ring-loop](https://pypi.org/project/one-ring-loop/) and Linux io_uring.
|
|
4
|
+
|
|
5
|
+
Part of the [one-ring](https://github.com/otto-sellerstam/one-ring) project.
|
|
6
|
+
|
|
7
|
+
## What it provides
|
|
8
|
+
|
|
9
|
+
- **HTTPServer** - TLS-enabled HTTP/1.1 server
|
|
10
|
+
- **Router** - method + path routing with fallback handlers
|
|
11
|
+
- **Request/Response** - parsed HTTP requests, serializable responses
|
|
12
|
+
- **Static file serving** - built-in static file handler
|
|
13
|
+
|
|
14
|
+
## Example
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import ssl
|
|
18
|
+
|
|
19
|
+
from one_ring_http.response import Response
|
|
20
|
+
from one_ring_http.router import Router
|
|
21
|
+
from one_ring_http.server import HTTPServer
|
|
22
|
+
from one_ring_loop import run
|
|
23
|
+
|
|
24
|
+
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
25
|
+
ssl_context.load_cert_chain("cert.pem", "key.pem")
|
|
26
|
+
|
|
27
|
+
def hello(request):
|
|
28
|
+
return Response(status_code=200, body=b"Hello, world!")
|
|
29
|
+
|
|
30
|
+
router = Router()
|
|
31
|
+
router.add("GET", "/", hello)
|
|
32
|
+
|
|
33
|
+
server = HTTPServer(router=router, host="127.0.0.1", port=8000, ssl_context=ssl_context)
|
|
34
|
+
run(server.serve())
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Requirements
|
|
38
|
+
|
|
39
|
+
- **Linux** with io_uring support (kernel 6.7+)
|
|
40
|
+
- **Python 3.14+**
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv add one-ring-http
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# List available recipes
|
|
2
|
+
default:
|
|
3
|
+
@just --list
|
|
4
|
+
|
|
5
|
+
pkg := "one_ring_http"
|
|
6
|
+
|
|
7
|
+
# Run tests for this package
|
|
8
|
+
test:
|
|
9
|
+
uv run pytest tests/
|
|
10
|
+
|
|
11
|
+
# Run tests with coverage
|
|
12
|
+
test-cov:
|
|
13
|
+
uv run pytest tests/ --cov={{pkg}} --cov-report=term-missing
|
|
14
|
+
|
|
15
|
+
# Run type checker for this package
|
|
16
|
+
typecheck:
|
|
17
|
+
uv run pyrefly check src/ tests/
|
|
18
|
+
|
|
19
|
+
# Run all checks for this package
|
|
20
|
+
check: typecheck test
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "one-ring-http"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "HTTP/1.1 server built on one-ring-loop and io_uring"
|
|
5
|
+
requires-python = ">=3.14"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Otto Sellerstam", email = "ottosellerstam@gmail.com" },
|
|
9
|
+
]
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
keywords = ["io_uring", "http", "server", "async", "linux"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Operating System :: POSIX :: Linux",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"structlog>=24.0",
|
|
24
|
+
"one-ring-loop>=0.1.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/otto-sellerstam/one-ring"
|
|
29
|
+
Repository = "https://github.com/otto-sellerstam/one-ring"
|
|
30
|
+
Issues = "https://github.com/otto-sellerstam/one-ring/issues"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["hatchling"]
|
|
34
|
+
build-backend = "hatchling.build"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/one_ring_http"]
|
|
38
|
+
|
|
39
|
+
# ── Coverage (per-package source) ───────────────────────────────────────
|
|
40
|
+
# Other coverage settings (fail_under, exclude_lines) are in root pyproject.toml.
|
|
41
|
+
|
|
42
|
+
[tool.coverage.run]
|
|
43
|
+
source = ["one_ring_http"]
|
|
44
|
+
branch = true
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Structured logging configuration using structlog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def setup_logging() -> None:
|
|
12
|
+
"""Configure structlog with sensible defaults.
|
|
13
|
+
|
|
14
|
+
Uses JSON output when ``LOG_FORMAT=json`` (e.g. production),
|
|
15
|
+
otherwise uses colored console output for development.
|
|
16
|
+
"""
|
|
17
|
+
shared_processors: list[structlog.types.Processor] = [
|
|
18
|
+
structlog.contextvars.merge_contextvars,
|
|
19
|
+
structlog.stdlib.add_log_level,
|
|
20
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
21
|
+
structlog.processors.StackInfoRenderer(),
|
|
22
|
+
structlog.processors.UnicodeDecoder(),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
if os.environ.get("LOG_FORMAT") == "json":
|
|
26
|
+
renderer: structlog.types.Processor = structlog.processors.JSONRenderer()
|
|
27
|
+
else:
|
|
28
|
+
renderer = structlog.dev.ConsoleRenderer()
|
|
29
|
+
|
|
30
|
+
structlog.configure(
|
|
31
|
+
processors=[
|
|
32
|
+
*shared_processors,
|
|
33
|
+
renderer,
|
|
34
|
+
],
|
|
35
|
+
wrapper_class=structlog.make_filtering_bound_logger(0),
|
|
36
|
+
context_class=dict,
|
|
37
|
+
logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
|
|
38
|
+
cache_logger_on_first_use=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_configured = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_logger(*args: object, **kwargs: object) -> structlog.stdlib.BoundLogger:
|
|
46
|
+
"""Return a structlog logger, configuring on first call.
|
|
47
|
+
|
|
48
|
+
This avoids running ``setup_logging()`` at import time, which would
|
|
49
|
+
interfere with tests and multi-process setups.
|
|
50
|
+
"""
|
|
51
|
+
global _configured # noqa: PLW0603
|
|
52
|
+
if not _configured:
|
|
53
|
+
setup_logging()
|
|
54
|
+
_configured = True
|
|
55
|
+
return structlog.get_logger(*args, **kwargs)
|
|
File without changes
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import TYPE_CHECKING, Self, TypeGuard
|
|
3
|
+
|
|
4
|
+
from one_ring_loop.log import get_logger
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from one_ring_http.typedef import HTTPHeaders, HTTPMethod
|
|
8
|
+
from one_ring_loop.streams.buffered import BufferedByteReceiveStream
|
|
9
|
+
from one_ring_loop.typedefs import Coro
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
logger = get_logger()
|
|
13
|
+
|
|
14
|
+
ALLOWED_HTTP_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
18
|
+
class Request:
|
|
19
|
+
"""Represents a parsed HTTP/1.1 request."""
|
|
20
|
+
|
|
21
|
+
"""The HTTP method"""
|
|
22
|
+
method: HTTPMethod
|
|
23
|
+
|
|
24
|
+
"""The URL path"""
|
|
25
|
+
path: str
|
|
26
|
+
|
|
27
|
+
"""HTTP version"""
|
|
28
|
+
http_version: str
|
|
29
|
+
|
|
30
|
+
"""HTTP headers"""
|
|
31
|
+
headers: HTTPHeaders
|
|
32
|
+
|
|
33
|
+
"""HTTP body"""
|
|
34
|
+
body: bytes
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def parse(cls, buffered_stream: BufferedByteReceiveStream) -> Coro[Self]:
|
|
38
|
+
"""Parses data from a buffered receive stream and provides a Request object."""
|
|
39
|
+
# 1. Get tokens from first line
|
|
40
|
+
first_line = yield from buffered_stream.receive_until(
|
|
41
|
+
delimiter=b"\r\n", max_bytes=65536
|
|
42
|
+
)
|
|
43
|
+
tokens = first_line.split(b" ")
|
|
44
|
+
method = tokens[0].decode()
|
|
45
|
+
target = tokens[1].decode()
|
|
46
|
+
version = tokens[2].decode()
|
|
47
|
+
|
|
48
|
+
if not cls.verify_http_method(method):
|
|
49
|
+
raise RuntimeError
|
|
50
|
+
|
|
51
|
+
# 2. Get headers, until reaching empty line
|
|
52
|
+
headers: HTTPHeaders = {}
|
|
53
|
+
line = yield from buffered_stream.receive_until(
|
|
54
|
+
delimiter=b"\r\n", max_bytes=65536
|
|
55
|
+
)
|
|
56
|
+
while line:
|
|
57
|
+
key_val = line.split(b": ", 1)
|
|
58
|
+
header_name = key_val[0].decode().lower()
|
|
59
|
+
header_val = key_val[1].decode()
|
|
60
|
+
|
|
61
|
+
if header_name in headers:
|
|
62
|
+
header_val = headers[header_name] + ", " + header_val
|
|
63
|
+
|
|
64
|
+
headers[header_name] = header_val
|
|
65
|
+
line = yield from buffered_stream.receive_until(
|
|
66
|
+
delimiter=b"\r\n", max_bytes=65536
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# 3. Get body, if we have "content-length"
|
|
70
|
+
body = b""
|
|
71
|
+
if "content-length" in headers:
|
|
72
|
+
content_length = int(headers["content-length"])
|
|
73
|
+
body = yield from buffered_stream.receive_exactly(content_length)
|
|
74
|
+
|
|
75
|
+
return cls(
|
|
76
|
+
method=method,
|
|
77
|
+
path=target,
|
|
78
|
+
http_version=version,
|
|
79
|
+
headers=headers,
|
|
80
|
+
body=body,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def verify_http_method(method: str) -> TypeGuard[HTTPMethod]:
|
|
85
|
+
"""Verifies that HTTP method is valid."""
|
|
86
|
+
return method in ALLOWED_HTTP_METHODS
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from one_ring_http.typedef import HTTPHeaders
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
9
|
+
class Response:
|
|
10
|
+
"""A HTTP/1.1 response to be serialized."""
|
|
11
|
+
|
|
12
|
+
"""HTTP status code of the response"""
|
|
13
|
+
status_code: int
|
|
14
|
+
|
|
15
|
+
"""HTTP headers for the response"""
|
|
16
|
+
headers: HTTPHeaders = field(default_factory=dict)
|
|
17
|
+
|
|
18
|
+
"""HTTP body for the response"""
|
|
19
|
+
body: bytes = field(default=b"")
|
|
20
|
+
|
|
21
|
+
def serialize(self) -> bytes:
|
|
22
|
+
"""Serializes a response for transfer."""
|
|
23
|
+
# 1. Add first line
|
|
24
|
+
serialized_response = f"HTTP/1.1 {self.status_code}\r\n".encode()
|
|
25
|
+
|
|
26
|
+
# 2. Add headers
|
|
27
|
+
content_length = len(self.body)
|
|
28
|
+
serialized_response += f"content-length: {content_length}\r\n".encode()
|
|
29
|
+
|
|
30
|
+
for header_name, header_val in self.headers.items():
|
|
31
|
+
serialized_response += f"{header_name}: {header_val}\r\n".encode()
|
|
32
|
+
|
|
33
|
+
serialized_response += b"\r\n"
|
|
34
|
+
|
|
35
|
+
# 3. Add body
|
|
36
|
+
serialized_response += self.body
|
|
37
|
+
|
|
38
|
+
return serialized_response
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from one_ring_http.response import Response
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from one_ring_http.request import Request
|
|
8
|
+
from one_ring_http.typedef import HTTPHandler, HTTPMethod
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def page_not_found(_: Request) -> Response:
|
|
12
|
+
"""Default handler for 404."""
|
|
13
|
+
return Response(status_code=404)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True, kw_only=True)
|
|
17
|
+
class Router:
|
|
18
|
+
"""Routes HTTP request to handlers."""
|
|
19
|
+
|
|
20
|
+
_registry: dict[tuple[HTTPMethod, str], HTTPHandler] = field(
|
|
21
|
+
default_factory=dict, init=False
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_fallback: HTTPHandler = field(default=page_not_found, init=False)
|
|
25
|
+
|
|
26
|
+
def add(self, method: HTTPMethod, path: str, handler: HTTPHandler) -> None:
|
|
27
|
+
"""Registers a path."""
|
|
28
|
+
self._registry[(method, path)] = handler
|
|
29
|
+
|
|
30
|
+
def resolve(self, method: HTTPMethod, path: str) -> HTTPHandler:
|
|
31
|
+
"""Returns the handler for a method and path."""
|
|
32
|
+
handler = self._registry.get((method, path))
|
|
33
|
+
if handler is None:
|
|
34
|
+
return self._fallback
|
|
35
|
+
return handler
|
|
36
|
+
|
|
37
|
+
def set_fallback(self, handler: HTTPHandler) -> None:
|
|
38
|
+
"""Sets fallback."""
|
|
39
|
+
self._fallback = handler
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from collections.abc import Generator
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from one_ring_http.log import get_logger
|
|
6
|
+
from one_ring_http.request import Request
|
|
7
|
+
from one_ring_loop import TaskGroup
|
|
8
|
+
from one_ring_loop.cancellation import move_on_after
|
|
9
|
+
from one_ring_loop.socketio import Connection, create_server
|
|
10
|
+
from one_ring_loop.streams.buffered import BufferedByteStream
|
|
11
|
+
from one_ring_loop.streams.tls import TLSStream
|
|
12
|
+
|
|
13
|
+
logger = get_logger()
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
import ssl
|
|
17
|
+
|
|
18
|
+
from one_ring_http.router import Router
|
|
19
|
+
from one_ring_loop.typedefs import Coro
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
23
|
+
class HTTPServer:
|
|
24
|
+
"""A simple HTTP server built on one-ring-loop."""
|
|
25
|
+
|
|
26
|
+
"""Routes method and path to a handler"""
|
|
27
|
+
router: Router
|
|
28
|
+
|
|
29
|
+
"""The host for the server to run on"""
|
|
30
|
+
host: str
|
|
31
|
+
|
|
32
|
+
"""The port for the server to run on"""
|
|
33
|
+
port: int
|
|
34
|
+
|
|
35
|
+
"""The SSL context for TLS wrapping"""
|
|
36
|
+
ssl_context: ssl.SSLContext
|
|
37
|
+
|
|
38
|
+
def serve(self) -> Coro[None]:
|
|
39
|
+
"""Starts the server."""
|
|
40
|
+
server = yield from create_server(self.host.encode(), self.port)
|
|
41
|
+
tg = TaskGroup()
|
|
42
|
+
tg.enter()
|
|
43
|
+
try:
|
|
44
|
+
while True:
|
|
45
|
+
conn = yield from server.accept()
|
|
46
|
+
tg.create_task(self._handle_connection(conn))
|
|
47
|
+
finally:
|
|
48
|
+
yield from tg.exit()
|
|
49
|
+
|
|
50
|
+
def _handle_connection(self, conn: Connection) -> Coro[None]:
|
|
51
|
+
"""Handles an incoming connection."""
|
|
52
|
+
try:
|
|
53
|
+
try:
|
|
54
|
+
tls_con = yield from TLSStream.wrap(
|
|
55
|
+
conn, ssl_context=self.ssl_context, standard_compatible=False
|
|
56
|
+
)
|
|
57
|
+
except Exception:
|
|
58
|
+
with move_on_after(3, shield=True):
|
|
59
|
+
yield from conn.close()
|
|
60
|
+
raise
|
|
61
|
+
|
|
62
|
+
buffered_stream = BufferedByteStream(
|
|
63
|
+
receive_stream=tls_con, send_stream=tls_con
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
request = yield from Request.parse(buffered_stream)
|
|
68
|
+
handler = self.router.resolve(request.method, request.path)
|
|
69
|
+
result = handler(request)
|
|
70
|
+
if isinstance(result, Generator):
|
|
71
|
+
response = yield from result
|
|
72
|
+
else:
|
|
73
|
+
response = result
|
|
74
|
+
yield from buffered_stream.send(response.serialize())
|
|
75
|
+
finally:
|
|
76
|
+
with move_on_after(3, shield=True):
|
|
77
|
+
yield from buffered_stream.close()
|
|
78
|
+
except Exception:
|
|
79
|
+
logger.exception("Connection error")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from one_ring_http.log import get_logger
|
|
8
|
+
from one_ring_http.response import Response
|
|
9
|
+
from one_ring_loop.fileio import open_file # or however your file I/O is exposed
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from one_ring_http.request import Request
|
|
13
|
+
from one_ring_http.typedef import HTTPHandler
|
|
14
|
+
from one_ring_loop.typedefs import Coro
|
|
15
|
+
|
|
16
|
+
logger = get_logger()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def static_handler(root: str | Path) -> HTTPHandler:
|
|
20
|
+
"""Returns a handler that serves files from root directory."""
|
|
21
|
+
root_path = Path(root).resolve()
|
|
22
|
+
|
|
23
|
+
def handler(request: Request) -> Coro[Response]:
|
|
24
|
+
path = request.path
|
|
25
|
+
if path == "/":
|
|
26
|
+
path = "/index.html"
|
|
27
|
+
|
|
28
|
+
candidates = [
|
|
29
|
+
root_path / path.lstrip("/"),
|
|
30
|
+
root_path / (path.lstrip("/") + ".html"),
|
|
31
|
+
root_path / path.lstrip("/") / "index.html",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
for candidate in candidates:
|
|
35
|
+
# Resolve and check it's still under root
|
|
36
|
+
file_path = candidate.resolve()
|
|
37
|
+
if not file_path.is_relative_to(root_path) or not file_path.is_file():
|
|
38
|
+
continue
|
|
39
|
+
try:
|
|
40
|
+
file = yield from open_file(file_path)
|
|
41
|
+
except FileNotFoundError:
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
body = yield from file.read()
|
|
46
|
+
content_type = (
|
|
47
|
+
mimetypes.guess_type(str(file_path))[0]
|
|
48
|
+
or "application/octet-stream"
|
|
49
|
+
)
|
|
50
|
+
finally:
|
|
51
|
+
yield from file.close()
|
|
52
|
+
|
|
53
|
+
return Response(
|
|
54
|
+
status_code=200,
|
|
55
|
+
headers={"content-type": content_type},
|
|
56
|
+
body=body.encode(),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return Response(status_code=404, body=b"Not Found")
|
|
60
|
+
|
|
61
|
+
return handler
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from one_ring_http.request import Request
|
|
5
|
+
from one_ring_http.response import Response
|
|
6
|
+
from one_ring_loop.typedefs import Coro
|
|
7
|
+
|
|
8
|
+
type HTTPHeaders = dict[str, str]
|
|
9
|
+
|
|
10
|
+
type HTTPMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
11
|
+
|
|
12
|
+
type HTTPHandler = Callable[[Request], Coro[Response] | Response]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Shared test fixtures for one-ring-http."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from one_ring_http.typedef import HTTPHeaders
|
|
8
|
+
from one_ring_loop.streams.buffered import BufferedByteReceiveStream
|
|
9
|
+
from one_ring_loop.typedefs import Coro
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
13
|
+
class RawHTTPResponse:
|
|
14
|
+
"""Parsed raw HTTP response from a buffered stream."""
|
|
15
|
+
|
|
16
|
+
status_code: int
|
|
17
|
+
headers: HTTPHeaders
|
|
18
|
+
body: bytes
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_raw_response(stream: BufferedByteReceiveStream) -> Coro[RawHTTPResponse]:
|
|
22
|
+
"""Reads and parses a raw HTTP response from a buffered byte stream."""
|
|
23
|
+
status_line = yield from stream.receive_until(delimiter=b"\r\n", max_bytes=65536)
|
|
24
|
+
status_code = int(status_line.split(b" ", 2)[1])
|
|
25
|
+
|
|
26
|
+
headers: dict[str, str] = {}
|
|
27
|
+
line = yield from stream.receive_until(delimiter=b"\r\n", max_bytes=65536)
|
|
28
|
+
while line:
|
|
29
|
+
key, val = line.split(b": ", 1)
|
|
30
|
+
headers[key.decode().lower()] = val.decode()
|
|
31
|
+
line = yield from stream.receive_until(delimiter=b"\r\n", max_bytes=65536)
|
|
32
|
+
|
|
33
|
+
body = b""
|
|
34
|
+
if "content-length" in headers:
|
|
35
|
+
body = yield from stream.receive_exactly(int(headers["content-length"]))
|
|
36
|
+
|
|
37
|
+
return RawHTTPResponse(status_code=status_code, headers=headers, body=body)
|