From bcd124cb11f0c4973ee6baf53a16c0abe6e053d3 Mon Sep 17 00:00:00 2001 From: Rickard Armiento Date: Thu, 26 Mar 2026 23:32:20 +0000 Subject: [PATCH 1/2] POST requests --- src/httk/web/engine/site_engine.py | 47 +++++++++++++++++++---- src/httk/web/functions/python_module.py | 4 +- src/httk/web/model/__init__.py | 2 + src/httk/web/model/request.py | 9 +++++ src/httk/web/runtime/asgi.py | 51 +++++++++++++++++++++++-- tests/test_functions.py | 33 ++++++++++++++++ tests/test_publish_warnings.py | 2 +- 7 files changed, 134 insertions(+), 14 deletions(-) create mode 100644 src/httk/web/model/request.py diff --git a/src/httk/web/engine/site_engine.py b/src/httk/web/engine/site_engine.py index 99e3ed2..bf4c1a6 100644 --- a/src/httk/web/engine/site_engine.py +++ b/src/httk/web/engine/site_engine.py @@ -7,6 +7,7 @@ from httk.web.model.config import SiteConfig from httk.web.model.errors import FunctionInjectionError, NotFoundError from httk.web.model.page import PageResult, ResolvedRoute +from httk.web.model.request import HttpRequestContext from httk.web.renderers import RENDERERS_BY_SUFFIX from httk.web.templating import JinjaTemplateEngine, TemplateRenderInput @@ -20,7 +21,12 @@ def __init__(self, config: SiteConfig) -> None: def resolve(self, route: str) -> ResolvedRoute: return resolve_route(config=self.config, route=route) - def render(self, route: str, query: dict[str, str] | None = None) -> PageResult: + def render( + self, + route: str, + query: dict[str, str] | None = None, + request: HttpRequestContext | None = None, + ) -> PageResult: resolved = self.resolve(route) if resolved.kind == "missing" or resolved.source_path is None: @@ -30,7 +36,22 @@ def render(self, route: str, query: dict[str, str] | None = None) -> PageResult: content_type = guess_type(str(resolved.source_path))[0] or "application/octet-stream" return PageResult(status_code=200, content_type=content_type, body=resolved.source_path.read_bytes()) - query_params = dict(query or {}) + if request is None: + request_context = HttpRequestContext(query=dict(query or {})) + else: + request_context = request + if query is not None: + request_context = HttpRequestContext( + method=request.method, + query=dict(query), + postvars=request.postvars, + headers=request.headers, + ) + + query_params = dict(request_context.query) + postvars = dict(request_context.postvars) + request_params = dict(query_params) + request_params.update(postvars) rendered_html, metadata = self._render_content_without_templates(resolved) route_key = normalize_route(route) warnings: list[str] = [] @@ -38,11 +59,17 @@ def render(self, route: str, query: dict[str, str] | None = None) -> PageResult: template_name = self._metadata_string(metadata, "template", default="default") base_template_name = self._metadata_string(metadata, "base_template", default="base_default") - context = self._build_template_context(route_key=route_key, metadata=metadata, query=query_params) + context = self._build_template_context( + route_key=route_key, + metadata=metadata, + query=query_params, + postvars=postvars, + request=request_context, + ) self._apply_function_injections( metadata=metadata, context=context, - query=query_params, + params=request_params, route_key=route_key, warnings=warnings, ) @@ -82,6 +109,8 @@ def _build_template_context( route_key: str, metadata: dict[str, object], query: dict[str, str], + postvars: dict[str, str], + request: HttpRequestContext, ) -> dict[str, object]: context: dict[str, object] = dict(metadata) page_cache: dict[str, tuple[str, dict[str, object]]] = {} @@ -144,6 +173,8 @@ def pages(path: str, field: str) -> object: context["listdir"] = listdir context["pages"] = pages context["query"] = dict(query) + context["postvars"] = dict(postvars) + context["request"] = request context["page"] = { "relurl": route_key, "absurl": self._absolute_url(route_key), @@ -156,7 +187,7 @@ def _apply_function_injections( *, metadata: dict[str, object], context: dict[str, object], - query: dict[str, str], + params: dict[str, str], route_key: str, warnings: list[str], ) -> None: @@ -173,16 +204,16 @@ def _apply_function_injections( function_name, arg_specs, function_template = self._parse_function_spec(raw_spec) required = [x.strip() for x in arg_specs.split(",") if x.strip()] - if not self._function_args_satisfied(required, query): + if not self._function_args_satisfied(required, params): context[output_name] = "" metadata[output_name] = "" warnings.append( - f"Function '{function_name}' on route '{route_key}' skipped: query constraints not satisfied." + f"Function '{function_name}' on route '{route_key}' skipped: input parameter constraints not satisfied." ) del metadata[function_key] continue - result = self.function_handler.execute(function_name=function_name, query=query, global_data=context) + result = self.function_handler.execute(function_name=function_name, params=params, global_data=context) joint_context = dict(context) joint_context["result"] = result diff --git a/src/httk/web/functions/python_module.py b/src/httk/web/functions/python_module.py index e4679ee..7cd1b7a 100644 --- a/src/httk/web/functions/python_module.py +++ b/src/httk/web/functions/python_module.py @@ -12,7 +12,7 @@ def __init__(self, functions_dir: Path) -> None: self._module_cache: dict[Path, ModuleType] = {} self._cache_lock = threading.Lock() - def execute(self, *, function_name: str, query: dict[str, str], global_data: dict[str, object]) -> Any: + def execute(self, *, function_name: str, params: dict[str, str], global_data: dict[str, object]) -> Any: module_path = self._resolve_function_path(function_name) module = self._load_module(module_path) @@ -20,7 +20,7 @@ def execute(self, *, function_name: str, query: dict[str, str], global_data: dic if execute_fn is None or not callable(execute_fn): raise ValueError(f"Function module missing callable execute(): {module_path}") - callargs: dict[str, object] = dict(query) + callargs: dict[str, object] = dict(params) callargs["global_data"] = global_data return execute_fn(**callargs) diff --git a/src/httk/web/model/__init__.py b/src/httk/web/model/__init__.py index 75c8fd6..9c92996 100644 --- a/src/httk/web/model/__init__.py +++ b/src/httk/web/model/__init__.py @@ -1,6 +1,7 @@ from .config import SiteConfig from .errors import FunctionInjectionError, NotFoundError, WebError from .page import PageResult, PublishReport, ResolvedRoute +from .request import HttpRequestContext __all__ = [ "SiteConfig", @@ -10,4 +11,5 @@ "ResolvedRoute", "PageResult", "PublishReport", + "HttpRequestContext", ] diff --git a/src/httk/web/model/request.py b/src/httk/web/model/request.py new file mode 100644 index 0000000..17bcad7 --- /dev/null +++ b/src/httk/web/model/request.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class HttpRequestContext: + method: str = "GET" + query: dict[str, str] = field(default_factory=dict) + postvars: dict[str, str] = field(default_factory=dict) + headers: dict[str, str] = field(default_factory=dict) diff --git a/src/httk/web/runtime/asgi.py b/src/httk/web/runtime/asgi.py index 3988b12..96ae10c 100644 --- a/src/httk/web/runtime/asgi.py +++ b/src/httk/web/runtime/asgi.py @@ -1,3 +1,6 @@ +import json +from urllib.parse import parse_qsl + from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import Response @@ -5,14 +8,20 @@ from httk.web.engine.site_engine import SiteEngine from httk.web.model.errors import WebError +from httk.web.model.request import HttpRequestContext async def _handle_request(request: Request) -> Response: engine: SiteEngine = request.app.state.engine route = request.path_params.get("path", "") - query = dict(request.query_params) + request_context = HttpRequestContext( + method=request.method, + query=dict(request.query_params), + postvars=await _extract_postvars(request), + headers={k.lower(): v for k, v in request.headers.items()}, + ) try: - result = engine.render(route, query=query) + result = engine.render(route, request=request_context) except WebError as exc: return Response(content=str(exc), status_code=exc.status_code, media_type="text/plain") @@ -20,6 +29,42 @@ async def _handle_request(request: Request) -> Response: def create_app(*, engine: SiteEngine, debug: bool = False) -> Starlette: - app = Starlette(debug=debug, routes=[Route("/{path:path}", _handle_request, methods=["GET"])]) + app = Starlette(debug=debug, routes=[Route("/{path:path}", _handle_request, methods=["GET", "POST"])]) app.state.engine = engine return app + + +async def _extract_postvars(request: Request) -> dict[str, str]: + if request.method.upper() != "POST": + return {} + + content_type = request.headers.get("content-type", "").lower() + + if "application/x-www-form-urlencoded" in content_type: + body = (await request.body()).decode("utf-8", errors="replace") + return {k: v for k, v in parse_qsl(body, keep_blank_values=True)} + + if "application/json" in content_type: + try: + payload = json.loads((await request.body()).decode("utf-8", errors="replace")) + except json.JSONDecodeError: + return {} + if isinstance(payload, dict): + out_json: dict[str, str] = {} + for key, value in payload.items(): + if isinstance(value, (str, int, float, bool)) or value is None: + out_json[str(key)] = "" if value is None else str(value) + return out_json + return {} + + if "multipart/form-data" in content_type: + try: + form = await request.form() + except Exception: + return {} + out_form: dict[str, str] = {} + for key, value in form.items(): + out_form[str(key)] = str(value) + return out_form + + return {} diff --git a/tests/test_functions.py b/tests/test_functions.py index b4ab560..bd81552 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -85,6 +85,39 @@ def test_function_injection_skips_when_query_constraints_not_met(tmp_path: Path) assert "Hello" not in blocked_present.text +def test_function_injection_accepts_post_form_params(tmp_path: Path) -> None: + src = _make_src(tmp_path) + + (src / "functions" / "hello.py").write_text( + """def execute(name, global_data, **kwargs): + return f"FROM-POST:{name}" +""", + encoding="utf-8", + ) + + (src / "templates" / "default.html.j2").write_text("{{ greeting }}|{{ content }}", encoding="utf-8") + (src / "templates" / "base_default.html.j2").write_text("{{ content }}", encoding="utf-8") + (src / "templates" / "greeting_fragment.html.j2").write_text("Hello {{ result }}!", encoding="utf-8") + + (src / "content" / "index.md").write_text( + """--- +template: default +greeting-function: hello:name:greeting_fragment +--- + +Body text +""", + encoding="utf-8", + ) + + app = create_asgi_app(src) + with TestClient(app) as client: + response = client.post("/", data={"name": "Rick"}) + + assert response.status_code == 200 + assert "Hello FROM-POST:Rick!" in response.text + + def test_invalid_function_spec_returns_controlled_500(tmp_path: Path) -> None: src = _make_src(tmp_path) diff --git a/tests/test_publish_warnings.py b/tests/test_publish_warnings.py index 510ac40..12f9c16 100644 --- a/tests/test_publish_warnings.py +++ b/tests/test_publish_warnings.py @@ -39,7 +39,7 @@ def test_publish_reports_warning_when_function_query_constraints_not_met(tmp_pat ) report = publish(src, out, "http://localhost/") - assert any("query constraints not satisfied" in warning for warning in report.warnings) + assert any("input parameter constraints not satisfied" in warning for warning in report.warnings) def test_publish_reports_warning_when_function_template_missing(tmp_path: Path) -> None: From c63fb97dd1b296977879195c73e7ec79a591a631 Mon Sep 17 00:00:00 2001 From: Rickard Armiento Date: Thu, 26 Mar 2026 23:43:15 +0000 Subject: [PATCH 2/2] Fix POST issues --- pyproject.toml | 3 +- src/httk/web/runtime/asgi.py | 51 +++++++++++++--- tests/test_functions.py | 114 +++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 755eda9..7c13021 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,8 @@ dependencies = [ "PyYAML>=6.0", "docutils>=0.20", "starlette>=0.37", - "uvicorn>=0.30" + "uvicorn>=0.30", + "python-multipart>=0.0.9" ] keywords = [ "Materials Discovery", diff --git a/src/httk/web/runtime/asgi.py b/src/httk/web/runtime/asgi.py index 96ae10c..b5129da 100644 --- a/src/httk/web/runtime/asgi.py +++ b/src/httk/web/runtime/asgi.py @@ -2,6 +2,7 @@ from urllib.parse import parse_qsl from starlette.applications import Starlette +from starlette.datastructures import UploadFile from starlette.requests import Request from starlette.responses import Response from starlette.routing import Route @@ -10,17 +11,19 @@ from httk.web.model.errors import WebError from httk.web.model.request import HttpRequestContext +MAX_POST_BODY_BYTES = 1_000_000 + async def _handle_request(request: Request) -> Response: engine: SiteEngine = request.app.state.engine route = request.path_params.get("path", "") - request_context = HttpRequestContext( - method=request.method, - query=dict(request.query_params), - postvars=await _extract_postvars(request), - headers={k.lower(): v for k, v in request.headers.items()}, - ) try: + request_context = HttpRequestContext( + method=request.method, + query=dict(request.query_params), + postvars=await _extract_postvars(request), + headers={k.lower(): v for k, v in request.headers.items()}, + ) result = engine.render(route, request=request_context) except WebError as exc: return Response(content=str(exc), status_code=exc.status_code, media_type="text/plain") @@ -39,14 +42,37 @@ async def _extract_postvars(request: Request) -> dict[str, str]: return {} content_type = request.headers.get("content-type", "").lower() + content_length = request.headers.get("content-length") + if content_length is not None: + try: + if int(content_length) > MAX_POST_BODY_BYTES: + raise WebError( + f"POST body too large (>{MAX_POST_BODY_BYTES} bytes).", + status_code=413, + ) + except ValueError: + # Ignore invalid content-length values and attempt best-effort parsing. + pass if "application/x-www-form-urlencoded" in content_type: - body = (await request.body()).decode("utf-8", errors="replace") + raw = await request.body() + if len(raw) > MAX_POST_BODY_BYTES: + raise WebError( + f"POST body too large (>{MAX_POST_BODY_BYTES} bytes).", + status_code=413, + ) + body = raw.decode("utf-8", errors="replace") return {k: v for k, v in parse_qsl(body, keep_blank_values=True)} if "application/json" in content_type: + raw = await request.body() + if len(raw) > MAX_POST_BODY_BYTES: + raise WebError( + f"POST body too large (>{MAX_POST_BODY_BYTES} bytes).", + status_code=413, + ) try: - payload = json.loads((await request.body()).decode("utf-8", errors="replace")) + payload = json.loads(raw.decode("utf-8", errors="replace")) except json.JSONDecodeError: return {} if isinstance(payload, dict): @@ -63,8 +89,13 @@ async def _extract_postvars(request: Request) -> dict[str, str]: except Exception: return {} out_form: dict[str, str] = {} - for key, value in form.items(): - out_form[str(key)] = str(value) + for key, value in form.multi_items(): + normalized_key = str(key) + if isinstance(value, UploadFile): + # Keep uploads explicit and compact in postvars. + out_form[normalized_key] = value.filename or "" + else: + out_form[normalized_key] = str(value) return out_form return {} diff --git a/tests/test_functions.py b/tests/test_functions.py index bd81552..c7c344a 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -118,6 +118,120 @@ def test_function_injection_accepts_post_form_params(tmp_path: Path) -> None: assert "Hello FROM-POST:Rick!" in response.text +def test_function_injection_accepts_post_json_params(tmp_path: Path) -> None: + src = _make_src(tmp_path) + + (src / "functions" / "hello.py").write_text( + """def execute(name, global_data, **kwargs): + return f"FROM-JSON:{name}" +""", + encoding="utf-8", + ) + + (src / "templates" / "default.html.j2").write_text("{{ greeting }}|{{ content }}", encoding="utf-8") + (src / "templates" / "base_default.html.j2").write_text("{{ content }}", encoding="utf-8") + (src / "templates" / "greeting_fragment.html.j2").write_text("Hello {{ result }}!", encoding="utf-8") + + (src / "content" / "index.md").write_text( + """--- +template: default +greeting-function: hello:name:greeting_fragment +--- + +Body text +""", + encoding="utf-8", + ) + + app = create_asgi_app(src) + with TestClient(app) as client: + response = client.post("/", json={"name": "Rick"}) + + assert response.status_code == 200 + assert "Hello FROM-JSON:Rick!" in response.text + + +def test_function_injection_accepts_post_multipart_and_maps_upload_filename(tmp_path: Path) -> None: + src = _make_src(tmp_path) + + (src / "functions" / "hello.py").write_text( + """def execute(name, upload, global_data, **kwargs): + return f"{name}:{upload}" +""", + encoding="utf-8", + ) + + (src / "templates" / "default.html.j2").write_text("{{ greeting }}|{{ content }}", encoding="utf-8") + (src / "templates" / "base_default.html.j2").write_text("{{ content }}", encoding="utf-8") + (src / "templates" / "greeting_fragment.html.j2").write_text("Hello {{ result }}!", encoding="utf-8") + + (src / "content" / "index.md").write_text( + """--- +template: default +greeting-function: hello:name,upload:greeting_fragment +--- + +Body text +""", + encoding="utf-8", + ) + + app = create_asgi_app(src) + with TestClient(app) as client: + response = client.post("/", data={"name": "Rick"}, files={"upload": ("example.txt", b"abc", "text/plain")}) + + assert response.status_code == 200 + assert "Hello Rick:example.txt!" in response.text + + +def test_invalid_json_post_does_not_crash_and_skips_required_function(tmp_path: Path) -> None: + src = _make_src(tmp_path) + + (src / "functions" / "hello.py").write_text( + """def execute(name, global_data, **kwargs): + return name.upper() +""", + encoding="utf-8", + ) + + (src / "templates" / "default.html.j2").write_text("{{ greeting }}|{{ content }}", encoding="utf-8") + (src / "templates" / "base_default.html.j2").write_text("{{ content }}", encoding="utf-8") + (src / "templates" / "greeting_fragment.html.j2").write_text("Hello {{ result }}!", encoding="utf-8") + + (src / "content" / "index.md").write_text( + """--- +template: default +greeting-function: hello:name:greeting_fragment +--- + +Body text +""", + encoding="utf-8", + ) + + app = create_asgi_app(src) + with TestClient(app) as client: + response = client.post("/", content="{bad json", headers={"content-type": "application/json"}) + + assert response.status_code == 200 + assert "Hello" not in response.text + + +def test_oversized_json_post_returns_413(tmp_path: Path) -> None: + src = _make_src(tmp_path) + + (src / "templates" / "default.html.j2").write_text("{{ content }}", encoding="utf-8") + (src / "templates" / "base_default.html.j2").write_text("{{ content }}", encoding="utf-8") + (src / "content" / "index.md").write_text("---\ntemplate: default\n---\n\nBody text", encoding="utf-8") + + app = create_asgi_app(src) + payload = "x" * 1_100_000 + with TestClient(app) as client: + response = client.post("/", content=payload, headers={"content-type": "application/json"}) + + assert response.status_code == 413 + + def test_invalid_function_spec_returns_controlled_500(tmp_path: Path) -> None: src = _make_src(tmp_path)