diff --git a/services/api-server/pyproject.toml b/services/api-server/pyproject.toml index cb4d1f2..44bae02 100644 --- a/services/api-server/pyproject.toml +++ b/services/api-server/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "sfera-ui-semantics", "smbprotocol>=1.15", "uvicorn>=0.30", + "python-multipart>=0.0.20", ] [tool.uv] diff --git a/services/api-server/src/api_server/html5_forms.py b/services/api-server/src/api_server/html5_forms.py index 138fe69..e49fc7b 100644 --- a/services/api-server/src/api_server/html5_forms.py +++ b/services/api-server/src/api_server/html5_forms.py @@ -6,6 +6,14 @@ from fastapi import Request async def html5_form_data(request: Request) -> dict[str, list[str]]: + content_type = request.headers.get("content-type", "") + if content_type.startswith("multipart/form-data"): + parsed = await request.form() + form: dict[str, list[str]] = {} + for key, value in parsed.multi_items(): + text = getattr(value, "filename", None) if not isinstance(value, str) else value + form.setdefault(key, []).append(str(text or "")) + return form body = (await request.body()).decode("utf-8") return parse_qs(body, keep_blank_values=True) diff --git a/services/api-server/tests/test_api.py b/services/api-server/tests/test_api.py index ecce6b7..3215bac 100644 --- a/services/api-server/tests/test_api.py +++ b/services/api-server/tests/test_api.py @@ -484,6 +484,26 @@ def test_html5_project_index_creates_project_with_fragment(): assert deleted_setup.json()["status"] == "NOT_CONFIGURED" +def test_html5_project_create_accepts_multipart_browser_form(): + client = TestClient(app) + project_id = f"html5-multipart-{uuid4()}" + + created = client.post( + "/html5/projects", + files={ + "project_id": (None, project_id), + "name": (None, "HTML5 Multipart"), + }, + ) + + assert created.status_code == 200 + assert_html5_response_contract(created, project_id, "HTML5 Multipart") + + setup = client.get(f"/html5/projects/{project_id}/setup") + assert setup.status_code == 200 + assert_html5_response_contract(setup, "data-html5-page=\"setup\"", "HTML5 Multipart", full_page=True) + + def test_html5_object_context_fragment(tmp_path: Path): project_id = f"html5-object-context-{uuid4()}" (tmp_path / "metadata.xml").write_text( diff --git a/uv.lock b/uv.lock index 4d4a075..709b949 100644 --- a/uv.lock +++ b/uv.lock @@ -505,6 +505,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.28" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/54/a85eb421fbdd5007bc5af39d0f4ed9fa609e0fedbfdc2adcf0b34526870e/python_multipart-0.0.28.tar.gz", hash = "sha256:8550da197eac0f7ab748961fc9509b999fa2662ea25cef857f05249f6893c0f8", size = 45314, upload-time = "2026-05-10T11:05:16.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/a2/43bbc5860b5034e2af4ef99a0e04d726ff329c43e192ef3abaa8d7ecfce5/python_multipart-0.0.28-py3-none-any.whl", hash = "sha256:10faac07eb966c3f48dc415f9dee46c04cb10d58d30a35677db8027c825ed9b6", size = 29438, upload-time = "2026-05-10T11:05:15.052Z" }, +] + [[package]] name = "pytz" version = "2026.2" @@ -521,6 +530,7 @@ source = { editable = "services/api-server" } dependencies = [ { name = "fastapi" }, { name = "neo4j" }, + { name = "python-multipart" }, { name = "sfera-collaboration" }, { name = "sfera-impact-engine" }, { name = "sfera-incremental-indexer" }, @@ -550,6 +560,7 @@ dependencies = [ requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, { name = "neo4j", specifier = ">=5.0" }, + { name = "python-multipart", specifier = ">=0.0.20" }, { name = "sfera-collaboration", editable = "packages/collaboration" }, { name = "sfera-impact-engine", editable = "packages/impact-engine" }, { name = "sfera-incremental-indexer", editable = "packages/incremental-indexer" },