From 32290b2fb67419cc6ee394bd5632bac0a4ac38a4 Mon Sep 17 00:00:00 2001
From: Quang <20378quang@gmail.com>
Date: Mon, 24 Aug 2026 10:11:59 -0400
Subject: [PATCH 1/6] feat: add form ingestion core with health and submission
endpoints
---
.env.example | 19 +++
.github/workflows/ci.yml | 32 +++++
README.md | 198 +++++++++++++++++++++++++-
pyproject.toml | 72 ++++++++++
src/hymical_forms/__init__.py | 5 +
src/hymical_forms/api/__init__.py | 1 +
src/hymical_forms/api/health.py | 31 +++++
src/hymical_forms/api/submissions.py | 201 +++++++++++++++++++++++++++
src/hymical_forms/app.py | 46 ++++++
src/hymical_forms/config.py | 44 ++++++
src/hymical_forms/errors.py | 149 ++++++++++++++++++++
src/hymical_forms/ingestion.py | 153 ++++++++++++++++++++
src/hymical_forms/main.py | 12 ++
src/hymical_forms/middleware.py | 72 ++++++++++
tests/conftest.py | 59 ++++++++
tests/test_endpoint_ids.py | 65 +++++++++
tests/test_errors.py | 117 ++++++++++++++++
tests/test_health.py | 18 +++
tests/test_ingestion.py | 62 +++++++++
tests/test_limits.py | 133 ++++++++++++++++++
tests/test_submissions.py | 107 ++++++++++++++
21 files changed, 1594 insertions(+), 2 deletions(-)
create mode 100644 .env.example
create mode 100644 .github/workflows/ci.yml
create mode 100644 pyproject.toml
create mode 100644 src/hymical_forms/__init__.py
create mode 100644 src/hymical_forms/api/__init__.py
create mode 100644 src/hymical_forms/api/health.py
create mode 100644 src/hymical_forms/api/submissions.py
create mode 100644 src/hymical_forms/app.py
create mode 100644 src/hymical_forms/config.py
create mode 100644 src/hymical_forms/errors.py
create mode 100644 src/hymical_forms/ingestion.py
create mode 100644 src/hymical_forms/main.py
create mode 100644 src/hymical_forms/middleware.py
create mode 100644 tests/conftest.py
create mode 100644 tests/test_endpoint_ids.py
create mode 100644 tests/test_errors.py
create mode 100644 tests/test_health.py
create mode 100644 tests/test_ingestion.py
create mode 100644 tests/test_limits.py
create mode 100644 tests/test_submissions.py
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..2211e98
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,19 @@
+# Hymical Forms configuration.
+#
+# Every setting is optional and shown below with its built-in default. Copy this
+# file to `.env` and uncomment the lines you want to change, or set the same
+# variables in your process environment.
+
+# Largest request body accepted, in bytes. File uploads are not supported, so
+# this only needs to accommodate text form fields.
+# FORMS_MAX_BODY_BYTES=262144
+
+# Largest number of name/value pairs accepted in one submission. A repeated
+# field name (a checkbox group) counts once per submitted value.
+# FORMS_MAX_FIELDS=100
+
+# Largest field name accepted, in characters.
+# FORMS_MAX_FIELD_NAME_LENGTH=128
+
+# Largest field value accepted, in characters.
+# FORMS_MAX_FIELD_VALUE_LENGTH=16384
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..63161a0
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,32 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.11", "3.12", "3.13"]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: pip
+ - run: pip install -e ".[dev]"
+ - run: ruff check .
+ - run: ruff format --check .
+ - run: mypy
+ - run: pytest
diff --git a/README.md b/README.md
index f9cc2d9..acdb69d 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,196 @@
-# forms
-Reliable form submission infrastructure with validation, storage, webhooks, retries, and delivery tracking
+# Hymical Forms
+
+Reliable form ingestion and webhook delivery for developers.
+
+## The problem
+
+Every project with a contact form, a waitlist, or a feedback box ends up needing
+the same small backend: something that accepts an HTML form POST, validates it,
+stores it, and forwards it somewhere useful. Writing that once is easy; running
+it reliably — with retries, delivery logs, spam handling and retention rules —
+is not. Hymical Forms is intended to be that backend, self-hostable and
+open-source.
+
+## Project status
+
+**Early development.** This build implements the ingestion boundary only.
+
+A submission is parsed, validated and acknowledged — and then discarded.
+Nothing is persisted and nothing is delivered anywhere. There is no
+authentication, no rate limiting, and no spam protection, so do not expose this
+to the public internet.
+
+| Capability | Status |
+| ----------------------------- | ------------------------- |
+| Health endpoint | Implemented |
+| Form ingestion + validation | Implemented |
+| Request limits + error model | Implemented |
+| Persistence | **Not implemented** |
+| API keys / authentication | **Not implemented** |
+| Webhook delivery and retries | **Not implemented** |
+| Rate limiting, spam handling | **Not implemented** |
+| Export, retention, dashboards | **Not implemented** |
+
+## Requirements
+
+Python 3.11 or newer.
+
+## Install
+
+```bash
+python -m venv .venv && . .venv/bin/activate && pip install -e ".[dev]"
+```
+
+On Windows, activate with `.venv\Scripts\activate` instead.
+
+## Run
+
+```bash
+uvicorn hymical_forms.main:app --reload
+```
+
+Interactive API documentation is served at `http://127.0.0.1:8000/docs`.
+
+## API
+
+### `GET /health`
+
+Reports that the API process is running.
+
+```json
+{ "status": "ok", "service": "hymical-forms", "version": "0.1.0" }
+```
+
+This is a liveness signal only. Hymical Forms has no external dependencies yet,
+so there is nothing that readiness could report separately.
+
+### `POST /f/{endpoint_id}`
+
+Accepts a form submission.
+
+**Endpoint IDs** are 3–64 characters of lowercase ASCII letters, digits, `-` and
+`_`, and must start and end with a letter or digit. There is no endpoint
+registry yet, so any syntactically valid ID is addressable; a malformed one is
+rejected with `404 invalid_endpoint_id`.
+
+**Content types.** `application/x-www-form-urlencoded` and
+`multipart/form-data` are both accepted, so a plain HTML `
+```
+
+### Errors
+
+Every non-2xx response uses one envelope. `code` is stable and
+machine-readable; `details` appears only when there is something concrete to
+add.
+
+```json
+{
+ "error": {
+ "code": "too_many_fields",
+ "message": "Submission carries 120 fields, which exceeds the limit of 100.",
+ "details": { "limit": 100, "received": 120 }
+ }
+}
+```
+
+| Status | `code` | Cause |
+| ------ | ----------------------------------------------------------------------------------------------- | ------------------------------------------------ |
+| 400 | `malformed_form_body` | Body does not parse as the declared content type |
+| 404 | `invalid_endpoint_id` | Path is not a well-formed endpoint ID |
+| 404 | `not_found` | Unknown path |
+| 405 | `method_not_allowed` | Wrong method for a known path |
+| 413 | `request_body_too_large` | Body exceeded `FORMS_MAX_BODY_BYTES` |
+| 415 | `unsupported_media_type` | Content type is not a supported form encoding |
+| 422 | `empty_submission` | No fields were submitted |
+| 422 | `too_many_fields`, `field_name_too_long`, `field_value_too_long`, `invalid_field_name`, `invalid_field_value` | A field breached an ingestion rule |
+| 422 | `file_upload_not_supported` | A multipart part carried a file |
+| 500 | `internal_error` | Unexpected failure; no internals are exposed |
+
+## Configuration
+
+All settings are read from `FORMS_`-prefixed environment variables, or from a
+`.env` file in the working directory. See [`.env.example`](.env.example) for the
+full list and defaults.
+
+| Variable | Default | Meaning |
+| ------------------------------- | -------- | ------------------------------------------ |
+| `FORMS_MAX_BODY_BYTES` | `262144` | Largest accepted request body, in bytes |
+| `FORMS_MAX_FIELDS` | `100` | Largest number of name/value pairs |
+| `FORMS_MAX_FIELD_NAME_LENGTH` | `128` | Largest field name, in characters |
+| `FORMS_MAX_FIELD_VALUE_LENGTH` | `16384` | Largest field value, in characters |
+
+## Development
+
+```bash
+pytest # run the test suite
+ruff check . # lint
+ruff format --check . # formatting check
+mypy # type check
+```
+
+### Layout
+
+```
+src/hymical_forms/
+ app.py application assembly
+ config.py typed settings
+ errors.py the shared JSON error envelope
+ ingestion.py domain rules: endpoint IDs, submission validation
+ middleware.py request body size limit
+ main.py ASGI entrypoint
+ api/ HTTP routes and response models
+```
+
+`ingestion.py` holds the domain rules and knows nothing about HTTP; `api/`
+translates requests into those rules and their outcomes into responses.
+
+## Limitations
+
+- **Nothing is stored.** A submission is validated, acknowledged, and dropped.
+- **Nothing is delivered.** There are no webhooks, retries or delivery logs.
+- **No authentication.** Any client can post to any syntactically valid endpoint
+ ID, and there is no rate limiting or spam protection.
+- **No file uploads.** Multipart text fields are accepted; file parts are
+ rejected.
+- **`multipart/form-data` bodies are buffered in memory,** bounded by
+ `FORMS_MAX_BODY_BYTES`.
+- Submission IDs are opaque and not yet guaranteed stable in format.
+
+## License
+
+[Apache License 2.0](LICENSE).
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..580ef20
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,72 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "hymical-forms"
+dynamic = ["version"]
+description = "Reliable form ingestion and webhook delivery for developers."
+readme = "README.md"
+license = "Apache-2.0"
+license-files = ["LICENSE"]
+requires-python = ">=3.11"
+authors = [{ name = "Hymical" }]
+keywords = ["forms", "webhooks", "ingestion", "fastapi"]
+classifiers = [
+ "Development Status :: 3 - Alpha",
+ "Framework :: FastAPI",
+ "Intended Audience :: Developers",
+ "Programming Language :: Python :: 3",
+ "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
+]
+dependencies = [
+ "fastapi>=0.115",
+ "pydantic>=2.7",
+ "pydantic-settings>=2.3",
+ "python-multipart>=0.0.9",
+ "uvicorn>=0.30",
+]
+
+[project.optional-dependencies]
+dev = [
+ "httpx2>=2.0", # transport used by starlette.testclient
+ "mypy>=1.11",
+ "pytest>=8.3",
+ "ruff>=0.6",
+]
+
+[project.urls]
+Homepage = "https://github.com/hymical/forms"
+Source = "https://github.com/hymical/forms"
+Issues = "https://github.com/hymical/forms/issues"
+
+[tool.hatch.version]
+path = "src/hymical_forms/__init__.py"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/hymical_forms"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+addopts = "-q --strict-markers --strict-config"
+
+[tool.ruff]
+target-version = "py311"
+line-length = 100
+src = ["src", "tests"]
+
+[tool.ruff.lint]
+select = [
+ "E", # pycodestyle errors
+ "F", # pyflakes
+ "I", # import sorting
+ "UP", # pyupgrade
+ "B", # bugbear
+ "SIM", # simplify
+ "RUF", # ruff-specific
+]
+
+[tool.mypy]
+python_version = "3.11"
+files = ["src", "tests"]
+strict = true
diff --git a/src/hymical_forms/__init__.py b/src/hymical_forms/__init__.py
new file mode 100644
index 0000000..523a73e
--- /dev/null
+++ b/src/hymical_forms/__init__.py
@@ -0,0 +1,5 @@
+"""Hymical Forms — reliable form ingestion and webhook delivery for developers."""
+
+__version__ = "0.1.0"
+
+__all__ = ["__version__"]
diff --git a/src/hymical_forms/api/__init__.py b/src/hymical_forms/api/__init__.py
new file mode 100644
index 0000000..58379db
--- /dev/null
+++ b/src/hymical_forms/api/__init__.py
@@ -0,0 +1 @@
+"""HTTP layer: routing, request parsing, and response shapes."""
diff --git a/src/hymical_forms/api/health.py b/src/hymical_forms/api/health.py
new file mode 100644
index 0000000..5486cd2
--- /dev/null
+++ b/src/hymical_forms/api/health.py
@@ -0,0 +1,31 @@
+"""Health endpoint."""
+
+from __future__ import annotations
+
+from typing import Literal
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from hymical_forms import __version__
+
+router = APIRouter(tags=["health"])
+
+
+class HealthResponse(BaseModel):
+ """Liveness report for a Hymical Forms process."""
+
+ status: Literal["ok"]
+ service: str
+ version: str
+
+
+@router.get("/health", summary="Report process health")
+async def health() -> HealthResponse:
+ """Report that the API process is running and able to serve requests.
+
+ This is a liveness signal only. Hymical Forms has no external dependencies
+ yet, so there is nothing to distinguish readiness from liveness; a separate
+ readiness endpoint will arrive with persistence.
+ """
+ return HealthResponse(status="ok", service="hymical-forms", version=__version__)
diff --git a/src/hymical_forms/api/submissions.py b/src/hymical_forms/api/submissions.py
new file mode 100644
index 0000000..73dc3e6
--- /dev/null
+++ b/src/hymical_forms/api/submissions.py
@@ -0,0 +1,201 @@
+"""Form ingestion endpoint: ``POST /f/{endpoint_id}``."""
+
+from __future__ import annotations
+
+import math
+from datetime import datetime
+from http import HTTPStatus
+
+from fastapi import APIRouter, Request
+from pydantic import BaseModel, Field
+from python_multipart.exceptions import ParseError
+from starlette.datastructures import UploadFile
+from starlette.formparsers import FormParser, MultiPartException, MultiPartParser
+
+from hymical_forms.config import Settings
+from hymical_forms.errors import ApiError, ErrorResponse
+from hymical_forms.ingestion import (
+ ENDPOINT_ID_MAX_LENGTH,
+ ENDPOINT_ID_MIN_LENGTH,
+ build_submission,
+ is_valid_endpoint_id,
+)
+
+URLENCODED = "application/x-www-form-urlencoded"
+MULTIPART = "multipart/form-data"
+SUPPORTED_MEDIA_TYPES = (URLENCODED, MULTIPART)
+
+# Content-Type values are echoed back to help developers debug their form tags,
+# but only ever a bounded prefix of what the client sent.
+_MEDIA_TYPE_ECHO_LIMIT = 128
+
+router = APIRouter(tags=["submissions"])
+
+
+class InvalidEndpointId(ApiError):
+ """The path segment is not a well-formed endpoint identifier."""
+
+ status_code = HTTPStatus.NOT_FOUND
+ code = "invalid_endpoint_id"
+
+ def __init__(self) -> None:
+ super().__init__(
+ "The path does not address a form endpoint. Endpoint IDs are "
+ f"{ENDPOINT_ID_MIN_LENGTH}-{ENDPOINT_ID_MAX_LENGTH} characters using lowercase "
+ "letters, digits, '-' and '_', and must start and end with a letter or digit.",
+ )
+
+
+class UnsupportedMediaType(ApiError):
+ """The request used a content type the ingestion endpoint cannot parse."""
+
+ status_code = HTTPStatus.UNSUPPORTED_MEDIA_TYPE
+ code = "unsupported_media_type"
+
+ def __init__(self, received: str) -> None:
+ super().__init__(
+ f"Form submissions must be sent as {URLENCODED} or {MULTIPART}.",
+ details={
+ "received": received[:_MEDIA_TYPE_ECHO_LIMIT] or None,
+ "supported": list(SUPPORTED_MEDIA_TYPES),
+ },
+ )
+
+
+class MalformedFormBody(ApiError):
+ """The body did not parse as the declared form content type."""
+
+ status_code = HTTPStatus.BAD_REQUEST
+ code = "malformed_form_body"
+
+ def __init__(self, reason: str) -> None:
+ super().__init__(
+ "The request body could not be parsed as form data.",
+ details={"reason": reason},
+ )
+
+
+class FileUploadNotSupported(ApiError):
+ """A multipart part carried a file, which this service does not accept."""
+
+ status_code = HTTPStatus.UNPROCESSABLE_ENTITY
+ code = "file_upload_not_supported"
+
+ def __init__(self, field_name: str) -> None:
+ super().__init__(
+ f"Field {field_name!r} carries a file upload, which is not supported.",
+ details={"field": field_name},
+ )
+
+
+class SubmissionAccepted(BaseModel):
+ """Acknowledgement returned for an accepted submission.
+
+ The submitted values are not echoed back: the client already has them, and
+ reflecting user input adds nothing but risk.
+ """
+
+ submission_id: str = Field(description="Opaque identifier generated for this submission.")
+ endpoint_id: str = Field(description="The endpoint the submission was addressed to.")
+ received_at: datetime = Field(description="UTC timestamp of when the API accepted the body.")
+ field_count: int = Field(description="Number of name/value pairs the submission carried.")
+
+
+@router.post(
+ "/f/{endpoint_id}",
+ status_code=HTTPStatus.ACCEPTED,
+ summary="Submit a form",
+ responses={
+ 400: {"model": ErrorResponse, "description": "Malformed form body"},
+ 404: {"model": ErrorResponse, "description": "Invalid endpoint ID"},
+ 413: {"model": ErrorResponse, "description": "Request body too large"},
+ 415: {"model": ErrorResponse, "description": "Unsupported content type"},
+ 422: {"model": ErrorResponse, "description": "Submission rejected by an ingestion rule"},
+ },
+)
+async def submit(endpoint_id: str, request: Request) -> SubmissionAccepted:
+ """Accept an HTML form submission.
+
+ The response is ``202 Accepted`` rather than ``201 Created``: the submission
+ is acknowledged as received and well-formed, but Hymical Forms does not yet
+ persist it or deliver it anywhere.
+ """
+ if not is_valid_endpoint_id(endpoint_id):
+ raise InvalidEndpointId()
+
+ media_type = _media_type(request.headers.get("content-type"))
+ if media_type not in SUPPORTED_MEDIA_TYPES:
+ raise UnsupportedMediaType(media_type)
+
+ settings: Settings = request.app.state.settings
+ submission = build_submission(
+ endpoint_id,
+ await _parse_form(request, media_type, settings),
+ max_fields=settings.max_fields,
+ max_field_name_length=settings.max_field_name_length,
+ max_field_value_length=settings.max_field_value_length,
+ )
+
+ return SubmissionAccepted(
+ submission_id=submission.id,
+ endpoint_id=submission.endpoint_id,
+ received_at=submission.received_at,
+ field_count=submission.field_count,
+ )
+
+
+async def _parse_form(
+ request: Request, media_type: str, settings: Settings
+) -> list[tuple[str, str]]:
+ """Parse the body into ordered name/value pairs, preserving repeated names.
+
+ The parser is selected from the media type we normalized ourselves rather
+ than through ``Request.form()``, whose dispatch compares the header verbatim
+ even though media types are case-insensitive (RFC 9110 §8.3).
+
+ Starlette's own field and part limits are disabled: the request body size cap
+ already bounds memory use, and leaving them on would let a library-defined
+ threshold shadow the limits configured for this service.
+ """
+ parser: FormParser | MultiPartParser
+ if media_type == MULTIPART:
+ parser = MultiPartParser(
+ request.headers,
+ request.stream(),
+ max_files=math.inf,
+ max_fields=math.inf,
+ max_part_size=settings.max_body_bytes,
+ )
+ else:
+ parser = FormParser(
+ request.headers,
+ request.stream(),
+ max_fields=math.inf,
+ max_part_size=settings.max_body_bytes,
+ )
+
+ try:
+ form = await parser.parse()
+ except (MultiPartException, ParseError) as exc:
+ raise MalformedFormBody(_failure_reason(exc)) from exc
+
+ try:
+ items: list[tuple[str, str]] = []
+ for name, value in form.multi_items():
+ if isinstance(value, UploadFile):
+ raise FileUploadNotSupported(name)
+ items.append((name, value))
+ return items
+ finally:
+ await form.close()
+
+
+def _failure_reason(exc: MultiPartException | ParseError) -> str:
+ return exc.message if isinstance(exc, MultiPartException) else str(exc)
+
+
+def _media_type(content_type: str | None) -> str:
+ """Strip parameters such as ``charset`` and ``boundary`` from a Content-Type."""
+ if not content_type:
+ return ""
+ return content_type.split(";", 1)[0].strip().lower()
diff --git a/src/hymical_forms/app.py b/src/hymical_forms/app.py
new file mode 100644
index 0000000..281c77f
--- /dev/null
+++ b/src/hymical_forms/app.py
@@ -0,0 +1,46 @@
+"""Application assembly."""
+
+from __future__ import annotations
+
+from fastapi import FastAPI
+
+from hymical_forms import __version__
+from hymical_forms.api import health, submissions
+from hymical_forms.config import Settings
+from hymical_forms.errors import register_exception_handlers
+from hymical_forms.middleware import BodySizeLimitMiddleware
+
+DESCRIPTION = """\
+Hymical Forms accepts HTML form submissions over HTTP so that developers do not
+have to run a form backend of their own.
+
+This build implements the ingestion boundary only: submissions are parsed,
+validated and acknowledged, but not stored or delivered anywhere.
+"""
+
+
+def create_app(settings: Settings | None = None) -> FastAPI:
+ """Build a Hymical Forms application.
+
+ Settings are attached to ``app.state`` rather than read from a module-level
+ singleton, so a test (or a future multi-tenant host) can run several
+ differently configured applications in one process.
+ """
+ settings = settings or Settings()
+
+ app = FastAPI(
+ title="Hymical Forms",
+ summary="Reliable form ingestion and webhook delivery for developers.",
+ description=DESCRIPTION,
+ version=__version__,
+ license_info={"name": "Apache-2.0", "identifier": "Apache-2.0"},
+ )
+ app.state.settings = settings
+
+ app.add_middleware(BodySizeLimitMiddleware, max_bytes=settings.max_body_bytes)
+ register_exception_handlers(app)
+
+ app.include_router(health.router)
+ app.include_router(submissions.router)
+
+ return app
diff --git a/src/hymical_forms/config.py b/src/hymical_forms/config.py
new file mode 100644
index 0000000..bfae03f
--- /dev/null
+++ b/src/hymical_forms/config.py
@@ -0,0 +1,44 @@
+"""Application settings.
+
+Every setting is read from a ``FORMS_``-prefixed environment variable (or a local
+``.env`` file). Settings are added only when the code actually uses them, so this
+model is currently limited to the ingestion boundary's protective limits.
+"""
+
+from __future__ import annotations
+
+from pydantic import Field
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ """Runtime configuration for a Hymical Forms process."""
+
+ model_config = SettingsConfigDict(
+ env_prefix="FORMS_",
+ env_file=".env",
+ env_file_encoding="utf-8",
+ extra="ignore",
+ frozen=True,
+ )
+
+ max_body_bytes: int = Field(
+ default=256 * 1024,
+ ge=1,
+ description="Largest request body accepted, in bytes. File uploads are not supported.",
+ )
+ max_fields: int = Field(
+ default=100,
+ ge=1,
+ description="Largest number of name/value pairs accepted in one submission.",
+ )
+ max_field_name_length: int = Field(
+ default=128,
+ ge=1,
+ description="Largest field name accepted, in characters.",
+ )
+ max_field_value_length: int = Field(
+ default=16 * 1024,
+ ge=1,
+ description="Largest field value accepted, in characters.",
+ )
diff --git a/src/hymical_forms/errors.py b/src/hymical_forms/errors.py
new file mode 100644
index 0000000..e2d6186
--- /dev/null
+++ b/src/hymical_forms/errors.py
@@ -0,0 +1,149 @@
+"""The single JSON error envelope used by every non-2xx response.
+
+Every error the API can produce — raised by our own code, by FastAPI's request
+validation, or by Starlette's routing — is rendered as::
+
+ {"error": {"code": "...", "message": "...", "details": {...}}}
+
+``code`` is a stable, machine-readable string; ``message`` is a human-readable
+sentence; ``details`` is present only when there is something concrete to add
+(the limit that was exceeded, the field at fault). Nothing in the envelope
+exposes internal types, stack frames, or file paths.
+"""
+
+from __future__ import annotations
+
+from http import HTTPStatus
+from typing import Any, ClassVar
+
+from fastapi import FastAPI
+from fastapi.exceptions import RequestValidationError
+from pydantic import BaseModel, Field
+from starlette.exceptions import HTTPException as StarletteHTTPException
+from starlette.requests import Request
+from starlette.responses import JSONResponse, Response
+
+from hymical_forms.ingestion import SubmissionRejected
+
+
+class ErrorDetail(BaseModel):
+ """The body of an error response."""
+
+ code: str = Field(description="Stable, machine-readable error identifier.")
+ message: str = Field(description="Human-readable explanation of the failure.")
+ details: dict[str, Any] | None = Field(
+ default=None,
+ description="Optional structured context, such as the limit that was exceeded.",
+ )
+
+
+class ErrorResponse(BaseModel):
+ """The envelope returned for every error."""
+
+ error: ErrorDetail
+
+
+class ApiError(Exception):
+ """An error that maps directly onto the public error envelope.
+
+ Subclasses fix ``status_code`` and ``code``; instances supply the message and
+ any structured details.
+ """
+
+ status_code: ClassVar[int] = 500
+ code: ClassVar[str] = "internal_error"
+
+ def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
+ super().__init__(message)
+ self.message = message
+ self.details = details
+
+ def as_response(self) -> JSONResponse:
+ return error_response(
+ status_code=self.status_code,
+ code=self.code,
+ message=self.message,
+ details=self.details,
+ )
+
+
+def error_response(
+ *,
+ status_code: int,
+ code: str,
+ message: str,
+ details: dict[str, Any] | None = None,
+) -> JSONResponse:
+ """Build a JSON response in the standard error envelope."""
+ payload = ErrorResponse(error=ErrorDetail(code=code, message=message, details=details))
+ return JSONResponse(status_code=status_code, content=payload.model_dump(exclude_none=True))
+
+
+def register_exception_handlers(app: FastAPI) -> None:
+ """Route every error class the app can raise through the shared envelope."""
+ app.add_exception_handler(ApiError, _handle_api_error)
+ app.add_exception_handler(SubmissionRejected, _handle_submission_rejected)
+ app.add_exception_handler(StarletteHTTPException, _handle_http_exception)
+ app.add_exception_handler(RequestValidationError, _handle_request_validation_error)
+ app.add_exception_handler(Exception, _handle_unexpected_error)
+
+
+# Starlette types every handler as ``(Request, Exception) -> Response``, so each
+# handler re-narrows the exception it was registered for.
+
+
+async def _handle_api_error(request: Request, exc: Exception) -> Response:
+ assert isinstance(exc, ApiError)
+ return exc.as_response()
+
+
+async def _handle_submission_rejected(request: Request, exc: Exception) -> Response:
+ """Render a domain rejection.
+
+ Every ingestion rule failure is a well-formed request carrying an
+ unacceptable submission, which is exactly what 422 describes.
+ """
+ assert isinstance(exc, SubmissionRejected)
+ return error_response(
+ status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
+ code=exc.code,
+ message=exc.message,
+ details=exc.details,
+ )
+
+
+async def _handle_http_exception(request: Request, exc: Exception) -> Response:
+ """Render routing-level errors (unknown paths, wrong methods) in the envelope."""
+ assert isinstance(exc, StarletteHTTPException)
+ return error_response(
+ status_code=exc.status_code,
+ code=_code_for_status(exc.status_code),
+ message=str(exc.detail),
+ )
+
+
+async def _handle_request_validation_error(request: Request, exc: Exception) -> Response:
+ assert isinstance(exc, RequestValidationError)
+ return error_response(
+ status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
+ code="invalid_request",
+ message="The request could not be validated.",
+ )
+
+
+async def _handle_unexpected_error(request: Request, exc: Exception) -> Response:
+ """Return an opaque 500 rather than letting an internal error reach the client."""
+ return error_response(
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
+ code="internal_error",
+ message="The request could not be processed.",
+ )
+
+
+def _code_for_status(status_code: int) -> str:
+ """Derive an error code from a status code, e.g. 405 -> ``method_not_allowed``."""
+ try:
+ phrase = HTTPStatus(status_code).phrase
+ except ValueError:
+ return "http_error"
+ return phrase.lower().replace("-", " ").replace(" ", "_")
diff --git a/src/hymical_forms/ingestion.py b/src/hymical_forms/ingestion.py
new file mode 100644
index 0000000..fb9fdb7
--- /dev/null
+++ b/src/hymical_forms/ingestion.py
@@ -0,0 +1,153 @@
+"""Ingestion domain rules: endpoint identifiers and submission normalization.
+
+This module is deliberately free of HTTP concepts. It answers two questions —
+"is this a well-formed endpoint identifier?" and "is this set of name/value pairs
+an acceptable submission?" — and leaves status codes and wire formats to the API
+layer.
+"""
+
+from __future__ import annotations
+
+import re
+import uuid
+from collections.abc import Sequence
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from typing import Any
+
+ENDPOINT_ID_MIN_LENGTH = 3
+ENDPOINT_ID_MAX_LENGTH = 64
+
+# Lowercase only, so that an endpoint ID has exactly one spelling. Hyphen and
+# underscore are allowed inside, but an ID may not start or end with them.
+_ENDPOINT_ID_PATTERN = re.compile(r"[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?")
+
+# C0/C1 controls and DEL. HTML permits almost anything else in a field name
+# (``user[email]``, ``entry.42``, non-ASCII labels), so nothing else is rejected.
+_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f-\x9f]")
+
+SUBMISSION_ID_PREFIX = "sub_"
+
+
+def is_valid_endpoint_id(value: str) -> bool:
+ """Report whether ``value`` is a syntactically valid endpoint identifier.
+
+ Interval 1 has no endpoint registry, so any syntactically valid identifier is
+ treated as addressable.
+ """
+ return (
+ ENDPOINT_ID_MIN_LENGTH <= len(value) <= ENDPOINT_ID_MAX_LENGTH
+ and _ENDPOINT_ID_PATTERN.fullmatch(value) is not None
+ )
+
+
+class SubmissionRejected(Exception):
+ """A submission violated an ingestion rule and must not be accepted."""
+
+ def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
+ super().__init__(message)
+ self.code = code
+ self.message = message
+ self.details = details
+
+
+@dataclass(frozen=True, slots=True)
+class Submission:
+ """A validated form submission in its internal representation.
+
+ Repeated field names are preserved as ordered tuples because HTML forms use
+ them for checkbox groups and multi-selects; collapsing them would silently
+ discard user input.
+ """
+
+ id: str
+ endpoint_id: str
+ received_at: datetime
+ fields: dict[str, tuple[str, ...]]
+
+ @property
+ def field_count(self) -> int:
+ """The number of name/value pairs the submission carries."""
+ return sum(len(values) for values in self.fields.values())
+
+
+def new_submission_id() -> str:
+ """Generate an opaque, prefixed submission identifier."""
+ return f"{SUBMISSION_ID_PREFIX}{uuid.uuid4().hex}"
+
+
+def build_submission(
+ endpoint_id: str,
+ items: Sequence[tuple[str, str]],
+ *,
+ max_fields: int,
+ max_field_name_length: int,
+ max_field_value_length: int,
+) -> Submission:
+ """Validate parsed form pairs and normalize them into a :class:`Submission`.
+
+ ``items`` is the ordered sequence of name/value pairs exactly as parsed from
+ the request body, including repeats.
+
+ Raises:
+ SubmissionRejected: if the submission is empty or breaches a limit.
+ """
+ if len(items) > max_fields:
+ raise SubmissionRejected(
+ "too_many_fields",
+ f"Submission carries {len(items)} fields, which exceeds the limit of {max_fields}.",
+ {"limit": max_fields, "received": len(items)},
+ )
+
+ fields: dict[str, tuple[str, ...]] = {}
+ for name, value in items:
+ _validate_field_name(name, max_field_name_length)
+ _validate_field_value(name, value, max_field_value_length)
+ fields[name] = (*fields.get(name, ()), value)
+
+ if not fields:
+ raise SubmissionRejected(
+ "empty_submission",
+ "Submission contains no fields.",
+ )
+
+ return Submission(
+ id=new_submission_id(),
+ endpoint_id=endpoint_id,
+ received_at=datetime.now(UTC),
+ fields=fields,
+ )
+
+
+def _validate_field_name(name: str, max_length: int) -> None:
+ if not name:
+ raise SubmissionRejected(
+ "invalid_field_name",
+ "Submission contains a field with an empty name.",
+ )
+ if len(name) > max_length:
+ raise SubmissionRejected(
+ "field_name_too_long",
+ f"A field name exceeds the limit of {max_length} characters.",
+ {"limit": max_length, "received": len(name)},
+ )
+ if _CONTROL_CHARS.search(name):
+ raise SubmissionRejected(
+ "invalid_field_name",
+ "Submission contains a field name with control characters.",
+ )
+
+
+def _validate_field_value(name: str, value: str, max_length: int) -> None:
+ if len(value) > max_length:
+ raise SubmissionRejected(
+ "field_value_too_long",
+ f"The value of field {name!r} exceeds the limit of {max_length} characters.",
+ {"field": name, "limit": max_length, "received": len(value)},
+ )
+ if "\x00" in value:
+ raise SubmissionRejected(
+ "invalid_field_value",
+ f"The value of field {name!r} contains a null byte.",
+ {"field": name},
+ )
diff --git a/src/hymical_forms/main.py b/src/hymical_forms/main.py
new file mode 100644
index 0000000..0b5d40d
--- /dev/null
+++ b/src/hymical_forms/main.py
@@ -0,0 +1,12 @@
+"""ASGI entrypoint.
+
+Run with::
+
+ uvicorn hymical_forms.main:app
+"""
+
+from __future__ import annotations
+
+from hymical_forms.app import create_app
+
+app = create_app()
diff --git a/src/hymical_forms/middleware.py b/src/hymical_forms/middleware.py
new file mode 100644
index 0000000..bccc048
--- /dev/null
+++ b/src/hymical_forms/middleware.py
@@ -0,0 +1,72 @@
+"""ASGI middleware protecting the ingestion boundary."""
+
+from __future__ import annotations
+
+from http import HTTPStatus
+
+from starlette.types import ASGIApp, Message, Receive, Scope, Send
+
+from hymical_forms.errors import ApiError
+
+
+class RequestBodyTooLarge(ApiError):
+ """The request body exceeded the configured maximum."""
+
+ status_code = HTTPStatus.REQUEST_ENTITY_TOO_LARGE
+ code = "request_body_too_large"
+
+ def __init__(self, limit: int) -> None:
+ super().__init__(
+ f"Request body exceeds the limit of {limit} bytes.",
+ details={"limit_bytes": limit},
+ )
+
+
+class BodySizeLimitMiddleware:
+ """Reject requests whose body exceeds ``max_bytes``.
+
+ Starlette buffers request bodies without an upper bound, so the cap has to sit
+ in front of the form parsers rather than inside a route handler. Requests that
+ declare an oversized ``Content-Length`` are refused before a single body byte
+ is read; the rest are cut off as soon as the running total crosses the limit.
+ """
+
+ def __init__(self, app: ASGIApp, *, max_bytes: int) -> None:
+ self.app = app
+ self.max_bytes = max_bytes
+
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+ if scope["type"] != "http":
+ await self.app(scope, receive, send)
+ return
+
+ declared = _declared_content_length(scope)
+ if declared is not None and declared > self.max_bytes:
+ await RequestBodyTooLarge(self.max_bytes).as_response()(scope, receive, send)
+ return
+
+ received = 0
+
+ async def limited_receive() -> Message:
+ nonlocal received
+ message = await receive()
+ if message["type"] == "http.request":
+ received += len(message.get("body", b""))
+ if received > self.max_bytes:
+ # Raised inside the application, so the registered ApiError
+ # handler renders it in the standard envelope.
+ raise RequestBodyTooLarge(self.max_bytes)
+ return message
+
+ await self.app(scope, limited_receive, send)
+
+
+def _declared_content_length(scope: Scope) -> int | None:
+ """Read ``Content-Length`` from the raw ASGI scope, ignoring unparseable values."""
+ for name, value in scope["headers"]:
+ if name == b"content-length":
+ try:
+ return int(value)
+ except ValueError:
+ return None
+ return None
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..b9bdb7b
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,59 @@
+"""Shared test fixtures.
+
+Tests build their own application instances so that limits can be lowered to
+values that are cheap to exercise, and so that a developer's local environment
+can never change a test's outcome.
+"""
+
+from __future__ import annotations
+
+import os
+from collections.abc import Callable, Iterator
+from contextlib import ExitStack
+
+import pytest
+from fastapi.testclient import TestClient
+from pydantic_settings import SettingsConfigDict
+
+from hymical_forms.app import create_app
+from hymical_forms.config import Settings
+
+URLENCODED_HEADERS = {"content-type": "application/x-www-form-urlencoded"}
+
+ClientFactory = Callable[..., TestClient]
+
+
+class IsolatedSettings(Settings):
+ """Settings that ignore a local ``.env``, so a developer's file cannot skew a run."""
+
+ model_config = SettingsConfigDict(env_file=None)
+
+
+@pytest.fixture(autouse=True)
+def _ignore_ambient_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Hide any ``FORMS_*`` variables the developer happens to have exported."""
+ for name in list(os.environ):
+ if name.startswith("FORMS_"):
+ monkeypatch.delenv(name)
+
+
+def build_settings(**overrides: int) -> Settings:
+ """Build settings from defaults and explicit overrides only."""
+ return IsolatedSettings(**overrides)
+
+
+@pytest.fixture
+def make_client() -> Iterator[ClientFactory]:
+ """Return a factory for clients bound to an app with the given setting overrides."""
+ with ExitStack() as stack:
+
+ def factory(**overrides: int) -> TestClient:
+ return stack.enter_context(TestClient(create_app(build_settings(**overrides))))
+
+ yield factory
+
+
+@pytest.fixture
+def client(make_client: ClientFactory) -> TestClient:
+ """A client for an application running on default settings."""
+ return make_client()
diff --git a/tests/test_endpoint_ids.py b/tests/test_endpoint_ids.py
new file mode 100644
index 0000000..abaab37
--- /dev/null
+++ b/tests/test_endpoint_ids.py
@@ -0,0 +1,65 @@
+"""Endpoint identifier rules.
+
+An endpoint ID is 3-64 characters of lowercase ASCII letters, digits, ``-`` and
+``_``, and must start and end with a letter or digit.
+"""
+
+from __future__ import annotations
+
+import pytest
+from fastapi.testclient import TestClient
+
+from hymical_forms.ingestion import is_valid_endpoint_id
+
+VALID_IDS = [
+ "abc",
+ "a1b",
+ "contact-form",
+ "signup_2026",
+ "x" * 64,
+]
+
+INVALID_IDS = [
+ "",
+ "ab", # shorter than the minimum
+ "x" * 65, # longer than the maximum
+ "Contact", # uppercase
+ "-contact", # leading separator
+ "contact-", # trailing separator
+ "_contact",
+ "contact_",
+ "con tact", # whitespace
+ "con.tact", # disallowed punctuation
+ "contact\n", # trailing newline must not slip past the pattern
+ "café", # non-ASCII
+ "../etc", # path traversal shapes
+]
+
+
+@pytest.mark.parametrize("endpoint_id", VALID_IDS)
+def test_accepts_well_formed_identifiers(endpoint_id: str) -> None:
+ assert is_valid_endpoint_id(endpoint_id)
+
+
+@pytest.mark.parametrize("endpoint_id", INVALID_IDS)
+def test_rejects_malformed_identifiers(endpoint_id: str) -> None:
+ assert not is_valid_endpoint_id(endpoint_id)
+
+
+@pytest.mark.parametrize("endpoint_id", VALID_IDS)
+def test_valid_identifiers_are_addressable_over_http(client: TestClient, endpoint_id: str) -> None:
+ response = client.post(f"/f/{endpoint_id}", data={"email": "dev@example.com"})
+
+ assert response.status_code == 202
+ assert response.json()["endpoint_id"] == endpoint_id
+
+
+@pytest.mark.parametrize(
+ "path_segment",
+ ["Contact", "ab", "contact-", "con%20tact", "contact%0A", "con.tact", "caf%C3%A9"],
+)
+def test_invalid_identifiers_are_not_addressable(client: TestClient, path_segment: str) -> None:
+ response = client.post(f"/f/{path_segment}", data={"email": "dev@example.com"})
+
+ assert response.status_code == 404
+ assert response.json()["error"]["code"] == "invalid_endpoint_id"
diff --git a/tests/test_errors.py b/tests/test_errors.py
new file mode 100644
index 0000000..3c138ef
--- /dev/null
+++ b/tests/test_errors.py
@@ -0,0 +1,117 @@
+"""The shared error envelope."""
+
+from __future__ import annotations
+
+import pytest
+from fastapi.testclient import TestClient
+
+from conftest import URLENCODED_HEADERS, build_settings
+from hymical_forms.app import create_app
+
+ENDPOINT = "/f/contact-form"
+
+
+def test_errors_share_one_envelope(client: TestClient) -> None:
+ response = client.post(ENDPOINT, json={"email": "dev@example.com"})
+
+ assert response.headers["content-type"].startswith("application/json")
+ error = response.json()["error"]
+ assert set(error) <= {"code", "message", "details"}
+ assert isinstance(error["code"], str)
+ assert isinstance(error["message"], str)
+
+
+def test_details_are_omitted_when_there_is_nothing_to_add(client: TestClient) -> None:
+ response = client.post(ENDPOINT, content=b"", headers=URLENCODED_HEADERS)
+
+ assert response.status_code == 422
+ assert response.json() == {
+ "error": {"code": "empty_submission", "message": "Submission contains no fields."}
+ }
+
+
+@pytest.mark.parametrize(
+ "content_type",
+ ["application/json", "text/plain", "application/octet-stream"],
+)
+def test_rejects_unsupported_content_types(client: TestClient, content_type: str) -> None:
+ response = client.post(
+ ENDPOINT, content=b"email=a%40b.co", headers={"content-type": content_type}
+ )
+
+ assert response.status_code == 415
+ body = response.json()
+ assert body["error"]["code"] == "unsupported_media_type"
+ assert body["error"]["details"]["received"] == content_type
+
+
+def test_rejects_a_request_without_a_content_type(client: TestClient) -> None:
+ response = client.post(ENDPOINT, content=b"email=a%40b.co", headers={"content-type": ""})
+
+ assert response.status_code == 415
+ assert response.json()["error"]["code"] == "unsupported_media_type"
+
+
+def test_rejects_multipart_without_a_boundary(client: TestClient) -> None:
+ response = client.post(
+ ENDPOINT, content=b"--x\r\nnope", headers={"content-type": "multipart/form-data"}
+ )
+
+ assert response.status_code == 400
+ assert response.json()["error"]["code"] == "malformed_form_body"
+
+
+def test_rejects_a_multipart_body_that_does_not_match_its_boundary(client: TestClient) -> None:
+ response = client.post(
+ ENDPOINT,
+ content=b"email=a%40b.co",
+ headers={"content-type": "multipart/form-data; boundary=hymical"},
+ )
+
+ assert response.status_code == 400
+ assert response.json()["error"]["code"] == "malformed_form_body"
+
+
+def test_rejects_file_uploads(client: TestClient) -> None:
+ """File handling is out of scope, so a file part is refused rather than ignored."""
+ response = client.post(
+ ENDPOINT,
+ data={"email": "dev@example.com"},
+ files={"resume": ("cv.pdf", b"%PDF-1.4", "application/pdf")},
+ )
+
+ assert response.status_code == 422
+ body = response.json()
+ assert body["error"]["code"] == "file_upload_not_supported"
+ assert body["error"]["details"]["field"] == "resume"
+
+
+def test_unknown_paths_use_the_envelope(client: TestClient) -> None:
+ response = client.post("/does-not-exist")
+
+ assert response.status_code == 404
+ assert response.json() == {"error": {"code": "not_found", "message": "Not Found"}}
+
+
+def test_wrong_methods_use_the_envelope(client: TestClient) -> None:
+ response = client.get(ENDPOINT)
+
+ assert response.status_code == 405
+ assert response.json()["error"]["code"] == "method_not_allowed"
+
+
+def test_unexpected_errors_do_not_leak_internals() -> None:
+ app = create_app(build_settings())
+
+ @app.get("/boom")
+ async def boom() -> None:
+ raise RuntimeError("connection string: postgres://user:pa55w0rd@db/forms")
+
+ with TestClient(app, raise_server_exceptions=False) as client:
+ response = client.get("/boom")
+
+ assert response.status_code == 500
+ assert response.json() == {
+ "error": {"code": "internal_error", "message": "The request could not be processed."}
+ }
+ assert "pa55w0rd" not in response.text
diff --git a/tests/test_health.py b/tests/test_health.py
new file mode 100644
index 0000000..8f0a62c
--- /dev/null
+++ b/tests/test_health.py
@@ -0,0 +1,18 @@
+"""Health endpoint behaviour."""
+
+from __future__ import annotations
+
+from fastapi.testclient import TestClient
+
+from hymical_forms import __version__
+
+
+def test_health_reports_a_running_process(client: TestClient) -> None:
+ response = client.get("/health")
+
+ assert response.status_code == 200
+ assert response.json() == {
+ "status": "ok",
+ "service": "hymical-forms",
+ "version": __version__,
+ }
diff --git a/tests/test_ingestion.py b/tests/test_ingestion.py
new file mode 100644
index 0000000..808bb76
--- /dev/null
+++ b/tests/test_ingestion.py
@@ -0,0 +1,62 @@
+"""The internal submission representation.
+
+These tests pin the shape the rest of the system will eventually persist and
+deliver, which the HTTP acknowledgement only summarises.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+import pytest
+
+from hymical_forms.ingestion import SubmissionRejected, build_submission
+
+LIMITS = {
+ "max_fields": 100,
+ "max_field_name_length": 128,
+ "max_field_value_length": 16384,
+}
+
+
+def test_normalizes_pairs_into_a_field_mapping() -> None:
+ submission = build_submission(
+ "contact-form",
+ [("email", "dev@example.com"), ("message", "hello")],
+ **LIMITS,
+ )
+
+ assert submission.endpoint_id == "contact-form"
+ assert submission.fields == {"email": ("dev@example.com",), "message": ("hello",)}
+ assert submission.field_count == 2
+
+
+def test_preserves_repeated_names_in_submission_order() -> None:
+ submission = build_submission(
+ "contact-form",
+ [("topic", "billing"), ("email", "dev@example.com"), ("topic", "api")],
+ **LIMITS,
+ )
+
+ assert submission.fields == {
+ "topic": ("billing", "api"),
+ "email": ("dev@example.com",),
+ }
+ assert submission.field_count == 3
+
+
+def test_stamps_each_submission_with_an_id_and_utc_timestamp() -> None:
+ before = datetime.now(UTC)
+ submission = build_submission("contact-form", [("email", "a@b.co")], **LIMITS)
+ after = datetime.now(UTC)
+
+ assert submission.id.startswith("sub_")
+ assert submission.received_at.tzinfo is not None
+ assert before <= submission.received_at <= after
+
+
+def test_rejects_a_submission_with_no_fields() -> None:
+ with pytest.raises(SubmissionRejected) as raised:
+ build_submission("contact-form", [], **LIMITS)
+
+ assert raised.value.code == "empty_submission"
diff --git a/tests/test_limits.py b/tests/test_limits.py
new file mode 100644
index 0000000..6f2c1dd
--- /dev/null
+++ b/tests/test_limits.py
@@ -0,0 +1,133 @@
+"""Limits that protect the ingestion boundary."""
+
+from __future__ import annotations
+
+from collections.abc import Iterator
+
+from conftest import URLENCODED_HEADERS, ClientFactory
+
+ENDPOINT = "/f/contact-form"
+
+
+def _chunks(*parts: bytes) -> Iterator[bytes]:
+ """Yield a body in pieces so the client streams it without a Content-Length."""
+ yield from parts
+
+
+def test_rejects_a_body_larger_than_the_declared_limit(make_client: ClientFactory) -> None:
+ client = make_client(max_body_bytes=64)
+
+ response = client.post(ENDPOINT, content=b"note=" + b"x" * 200, headers=URLENCODED_HEADERS)
+
+ assert response.status_code == 413
+ body = response.json()
+ assert body["error"]["code"] == "request_body_too_large"
+ assert body["error"]["details"]["limit_bytes"] == 64
+
+
+def test_rejects_an_oversized_streamed_body(make_client: ClientFactory) -> None:
+ """A chunked request cannot escape the limit by omitting Content-Length."""
+ client = make_client(max_body_bytes=64)
+
+ response = client.post(
+ ENDPOINT,
+ content=_chunks(b"note=", b"x" * 200),
+ headers=URLENCODED_HEADERS,
+ )
+
+ assert response.status_code == 413
+ assert response.json()["error"]["code"] == "request_body_too_large"
+
+
+def test_accepts_a_body_at_the_limit(make_client: ClientFactory) -> None:
+ client = make_client(max_body_bytes=64)
+ body = b"note=" + b"x" * 59
+
+ response = client.post(ENDPOINT, content=body, headers=URLENCODED_HEADERS)
+
+ assert len(body) == 64
+ assert response.status_code == 202
+
+
+def test_rejects_too_many_fields(make_client: ClientFactory) -> None:
+ client = make_client(max_fields=3)
+
+ response = client.post(ENDPOINT, data={f"field{i}": "v" for i in range(4)})
+
+ assert response.status_code == 422
+ body = response.json()
+ assert body["error"]["code"] == "too_many_fields"
+ assert body["error"]["details"] == {"limit": 3, "received": 4}
+
+
+def test_accepts_the_maximum_number_of_fields(make_client: ClientFactory) -> None:
+ client = make_client(max_fields=3)
+
+ response = client.post(ENDPOINT, data={f"field{i}": "v" for i in range(3)})
+
+ assert response.status_code == 202
+ assert response.json()["field_count"] == 3
+
+
+def test_repeated_names_count_towards_the_field_limit(make_client: ClientFactory) -> None:
+ client = make_client(max_fields=2)
+
+ response = client.post(ENDPOINT, data={"topic": ["a", "b", "c"]})
+
+ assert response.status_code == 422
+ assert response.json()["error"]["code"] == "too_many_fields"
+
+
+def test_rejects_an_overlong_field_name(make_client: ClientFactory) -> None:
+ client = make_client(max_field_name_length=8)
+
+ response = client.post(ENDPOINT, data={"a" * 9: "value"})
+
+ assert response.status_code == 422
+ assert response.json()["error"]["code"] == "field_name_too_long"
+
+
+def test_rejects_an_overlong_field_value(make_client: ClientFactory) -> None:
+ client = make_client(max_field_value_length=8)
+
+ response = client.post(ENDPOINT, data={"note": "x" * 9})
+
+ assert response.status_code == 422
+ body = response.json()
+ assert body["error"]["code"] == "field_value_too_long"
+ assert body["error"]["details"]["field"] == "note"
+
+
+def test_accepts_values_at_the_length_limit(make_client: ClientFactory) -> None:
+ client = make_client(max_field_value_length=8)
+
+ response = client.post(ENDPOINT, data={"note": "x" * 8})
+
+ assert response.status_code == 202
+
+
+def test_rejects_control_characters_in_a_field_name(make_client: ClientFactory) -> None:
+ client = make_client()
+
+ response = client.post(ENDPOINT, content=b"na%0Ame=value", headers=URLENCODED_HEADERS)
+
+ assert response.status_code == 422
+ assert response.json()["error"]["code"] == "invalid_field_name"
+
+
+def test_rejects_a_null_byte_in_a_field_value(make_client: ClientFactory) -> None:
+ client = make_client()
+
+ response = client.post(ENDPOINT, content=b"note=%00", headers=URLENCODED_HEADERS)
+
+ assert response.status_code == 422
+ assert response.json()["error"]["code"] == "invalid_field_value"
+
+
+def test_allows_newlines_inside_a_textarea_value(make_client: ClientFactory) -> None:
+ """Multi-line textarea input is legitimate and must not trip the name rules."""
+ client = make_client()
+
+ response = client.post(ENDPOINT, data={"message": "line one\r\nline two"})
+
+ assert response.status_code == 202
diff --git a/tests/test_submissions.py b/tests/test_submissions.py
new file mode 100644
index 0000000..7456ef6
--- /dev/null
+++ b/tests/test_submissions.py
@@ -0,0 +1,107 @@
+"""Accepting form submissions."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from fastapi.testclient import TestClient
+
+from conftest import URLENCODED_HEADERS
+
+ENDPOINT = "/f/contact-form"
+
+
+def test_accepts_a_urlencoded_submission(client: TestClient) -> None:
+ response = client.post(ENDPOINT, data={"email": "dev@example.com", "message": "hello"})
+
+ assert response.status_code == 202
+ body = response.json()
+ assert body["endpoint_id"] == "contact-form"
+ assert body["field_count"] == 2
+
+
+def test_accepts_a_multipart_submission(client: TestClient) -> None:
+ """Browsers send ``enctype="multipart/form-data"`` forms; text parts are accepted."""
+ boundary = "hymicalboundary"
+ body = (
+ f"--{boundary}\r\n"
+ 'Content-Disposition: form-data; name="email"\r\n\r\n'
+ "dev@example.com\r\n"
+ f"--{boundary}--\r\n"
+ ).encode()
+
+ response = client.post(
+ ENDPOINT,
+ content=body,
+ headers={"content-type": f"multipart/form-data; boundary={boundary}"},
+ )
+
+ assert response.status_code == 202
+ assert response.json()["field_count"] == 1
+
+
+def test_generates_submission_metadata(client: TestClient) -> None:
+ before = datetime.now(UTC)
+ response = client.post(ENDPOINT, data={"email": "dev@example.com"})
+ after = datetime.now(UTC)
+
+ body = response.json()
+ assert body["submission_id"].startswith("sub_")
+ received_at = datetime.fromisoformat(body["received_at"])
+ assert received_at.tzinfo is not None
+ assert before <= received_at <= after
+
+
+def test_each_submission_gets_a_distinct_id(client: TestClient) -> None:
+ ids = {
+ client.post(ENDPOINT, data={"email": "dev@example.com"}).json()["submission_id"]
+ for _ in range(5)
+ }
+
+ assert len(ids) == 5
+
+
+def test_repeated_field_names_are_all_counted(client: TestClient) -> None:
+ """Checkbox groups submit one name several times; no value may be dropped."""
+ response = client.post(ENDPOINT, data={"topic": ["billing", "api", "docs"], "email": "a@b.co"})
+
+ assert response.status_code == 202
+ assert response.json()["field_count"] == 4
+
+
+def test_accepts_a_field_with_an_empty_value(client: TestClient) -> None:
+ """An optional text input that the user left blank is still a submitted field."""
+ response = client.post(ENDPOINT, content=b"nickname=", headers=URLENCODED_HEADERS)
+
+ assert response.status_code == 202
+ assert response.json()["field_count"] == 1
+
+
+def test_accepts_non_ascii_values(client: TestClient) -> None:
+ response = client.post(ENDPOINT, data={"name": "Zoë", "note": "naïve café"})
+
+ assert response.status_code == 202
+ assert response.json()["field_count"] == 2
+
+
+def test_content_type_parameters_and_casing_are_ignored(client: TestClient) -> None:
+ response = client.post(
+ ENDPOINT,
+ content=b"email=dev%40example.com",
+ headers={"content-type": "APPLICATION/X-WWW-Form-Urlencoded; charset=UTF-8"},
+ )
+
+ assert response.status_code == 202
+
+
+def test_does_not_echo_submitted_values(client: TestClient) -> None:
+ """The acknowledgement is metadata only; user input is not reflected back."""
+ response = client.post(ENDPOINT, data={"secret": "hunter2"})
+
+ assert "hunter2" not in response.text
+ assert set(response.json()) == {
+ "submission_id",
+ "endpoint_id",
+ "received_at",
+ "field_count",
+ }
From 8c8b03da6637e873cea3ade9d532b257c7b1dfe5 Mon Sep 17 00:00:00 2001
From: Quang <20378quang@gmail.com>
Date: Mon, 24 Aug 2026 10:19:29 -0400
Subject: [PATCH 2/6] style: adopt reST docstring format across the codebase
---
README.md | 14 ++---
src/hymical_forms/__init__.py | 4 +-
src/hymical_forms/api/__init__.py | 4 +-
src/hymical_forms/api/health.py | 19 +++---
src/hymical_forms/api/submissions.py | 91 ++++++++++++++++++++--------
src/hymical_forms/app.py | 16 +++--
src/hymical_forms/config.py | 12 ++--
src/hymical_forms/errors.py | 89 +++++++++++++++++++++------
src/hymical_forms/ingestion.py | 80 ++++++++++++++++--------
src/hymical_forms/main.py | 3 +-
src/hymical_forms/middleware.py | 47 +++++++++++---
tests/conftest.py | 34 +++++++++--
tests/test_endpoint_ids.py | 7 ++-
tests/test_errors.py | 12 +++-
tests/test_health.py | 4 +-
tests/test_ingestion.py | 3 +-
tests/test_limits.py | 20 ++++--
tests/test_submissions.py | 24 ++++++--
18 files changed, 358 insertions(+), 125 deletions(-)
diff --git a/README.md b/README.md
index acdb69d..98953bf 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ Reliable form ingestion and webhook delivery for developers.
Every project with a contact form, a waitlist, or a feedback box ends up needing
the same small backend: something that accepts an HTML form POST, validates it,
stores it, and forwards it somewhere useful. Writing that once is easy; running
-it reliably — with retries, delivery logs, spam handling and retention rules —
+it reliably, with retries, delivery logs, spam handling and retention rules,
is not. Hymical Forms is intended to be that backend, self-hostable and
open-source.
@@ -15,7 +15,7 @@ open-source.
**Early development.** This build implements the ingestion boundary only.
-A submission is parsed, validated and acknowledged — and then discarded.
+A submission is parsed, validated and acknowledged, and then discarded.
Nothing is persisted and nothing is delivered anywhere. There is no
authentication, no rate limiting, and no spam protection, so do not expose this
to the public internet.
@@ -68,8 +68,8 @@ so there is nothing that readiness could report separately.
Accepts a form submission.
-**Endpoint IDs** are 3–64 characters of lowercase ASCII letters, digits, `-` and
-`_`, and must start and end with a letter or digit. There is no endpoint
+**Endpoint IDs** are 3 to 64 characters of lowercase ASCII letters, digits, `-`
+and `_`, and must start and end with a letter or digit. There is no endpoint
registry yet, so any syntactically valid ID is addressable; a malformed one is
rejected with `404 invalid_endpoint_id`.
@@ -78,8 +78,8 @@ rejected with `404 invalid_endpoint_id`.
unchanged. File uploads are not: a multipart part carrying a file is rejected
rather than silently dropped. Anything else is rejected with `415`.
-**Repeated field names** — checkbox groups, multi-selects — are preserved in
-order. No submitted value is discarded.
+**Repeated field names**, such as checkbox groups and multi-selects, are
+preserved in order. No submitted value is discarded.
A successful request returns `202 Accepted`. The status is deliberately not
`201`: the submission is acknowledged as received and well-formed, but nothing
@@ -94,7 +94,7 @@ was created, stored or delivered.
}
```
-Submitted values are not echoed back — the client already has them.
+Submitted values are not echoed back, because the client already has them.
### Try it
diff --git a/src/hymical_forms/__init__.py b/src/hymical_forms/__init__.py
index 523a73e..1a58e53 100644
--- a/src/hymical_forms/__init__.py
+++ b/src/hymical_forms/__init__.py
@@ -1,4 +1,6 @@
-"""Hymical Forms — reliable form ingestion and webhook delivery for developers."""
+"""
+hymical forms: reliable form ingestion and webhook delivery for developers
+"""
__version__ = "0.1.0"
diff --git a/src/hymical_forms/api/__init__.py b/src/hymical_forms/api/__init__.py
index 58379db..f0e63dd 100644
--- a/src/hymical_forms/api/__init__.py
+++ b/src/hymical_forms/api/__init__.py
@@ -1 +1,3 @@
-"""HTTP layer: routing, request parsing, and response shapes."""
+"""
+the HTTP layer: routing, request parsing, and response shapes
+"""
diff --git a/src/hymical_forms/api/health.py b/src/hymical_forms/api/health.py
index 5486cd2..d3836ed 100644
--- a/src/hymical_forms/api/health.py
+++ b/src/hymical_forms/api/health.py
@@ -1,4 +1,6 @@
-"""Health endpoint."""
+"""
+health endpoint
+"""
from __future__ import annotations
@@ -13,7 +15,9 @@
class HealthResponse(BaseModel):
- """Liveness report for a Hymical Forms process."""
+ """
+ liveness report for a hymical forms process
+ """
status: Literal["ok"]
service: str
@@ -22,10 +26,11 @@ class HealthResponse(BaseModel):
@router.get("/health", summary="Report process health")
async def health() -> HealthResponse:
- """Report that the API process is running and able to serve requests.
-
- This is a liveness signal only. Hymical Forms has no external dependencies
- yet, so there is nothing to distinguish readiness from liveness; a separate
- readiness endpoint will arrive with persistence.
"""
+ report that the api process is running and able to serve requests
+ :returns: a liveness payload naming the service and its version
+ """
+ # This is a liveness signal only. Hymical Forms has no external dependencies
+ # yet, so there is nothing to distinguish readiness from liveness; a separate
+ # readiness endpoint will arrive with persistence.
return HealthResponse(status="ok", service="hymical-forms", version=__version__)
diff --git a/src/hymical_forms/api/submissions.py b/src/hymical_forms/api/submissions.py
index 73dc3e6..61e6016 100644
--- a/src/hymical_forms/api/submissions.py
+++ b/src/hymical_forms/api/submissions.py
@@ -1,4 +1,6 @@
-"""Form ingestion endpoint: ``POST /f/{endpoint_id}``."""
+"""
+form ingestion endpoint: ``POST /f/{endpoint_id}``
+"""
from __future__ import annotations
@@ -33,12 +35,17 @@
class InvalidEndpointId(ApiError):
- """The path segment is not a well-formed endpoint identifier."""
+ """
+ raised when the path segment is not a well-formed endpoint identifier
+ """
status_code = HTTPStatus.NOT_FOUND
code = "invalid_endpoint_id"
def __init__(self) -> None:
+ """
+ state the endpoint identifier rules the request failed
+ """
super().__init__(
"The path does not address a form endpoint. Endpoint IDs are "
f"{ENDPOINT_ID_MIN_LENGTH}-{ENDPOINT_ID_MAX_LENGTH} characters using lowercase "
@@ -47,12 +54,18 @@ def __init__(self) -> None:
class UnsupportedMediaType(ApiError):
- """The request used a content type the ingestion endpoint cannot parse."""
+ """
+ raised when the request used a content type the endpoint cannot parse
+ """
status_code = HTTPStatus.UNSUPPORTED_MEDIA_TYPE
code = "unsupported_media_type"
def __init__(self, received: str) -> None:
+ """
+ report the rejected content type alongside the supported ones
+ :param received: the normalized media type taken from the request
+ """
super().__init__(
f"Form submissions must be sent as {URLENCODED} or {MULTIPART}.",
details={
@@ -63,12 +76,18 @@ def __init__(self, received: str) -> None:
class MalformedFormBody(ApiError):
- """The body did not parse as the declared form content type."""
+ """
+ raised when the body did not parse as the declared form content type
+ """
status_code = HTTPStatus.BAD_REQUEST
code = "malformed_form_body"
def __init__(self, reason: str) -> None:
+ """
+ report why the body could not be parsed
+ :param reason: the form parser's description of what went wrong
+ """
super().__init__(
"The request body could not be parsed as form data.",
details={"reason": reason},
@@ -76,12 +95,18 @@ def __init__(self, reason: str) -> None:
class FileUploadNotSupported(ApiError):
- """A multipart part carried a file, which this service does not accept."""
+ """
+ raised when a multipart part carries a file, which this service does not accept
+ """
status_code = HTTPStatus.UNPROCESSABLE_ENTITY
code = "file_upload_not_supported"
def __init__(self, field_name: str) -> None:
+ """
+ name the field that carried a file part
+ :param field_name: name of the offending multipart field
+ """
super().__init__(
f"Field {field_name!r} carries a file upload, which is not supported.",
details={"field": field_name},
@@ -89,12 +114,12 @@ def __init__(self, field_name: str) -> None:
class SubmissionAccepted(BaseModel):
- """Acknowledgement returned for an accepted submission.
-
- The submitted values are not echoed back: the client already has them, and
- reflecting user input adds nothing but risk.
+ """
+ acknowledgement returned for an accepted submission
"""
+ # The submitted values are not echoed back: the client already has them, and
+ # reflecting user input adds nothing but risk.
submission_id: str = Field(description="Opaque identifier generated for this submission.")
endpoint_id: str = Field(description="The endpoint the submission was addressed to.")
received_at: datetime = Field(description="UTC timestamp of when the API accepted the body.")
@@ -114,12 +139,15 @@ class SubmissionAccepted(BaseModel):
},
)
async def submit(endpoint_id: str, request: Request) -> SubmissionAccepted:
- """Accept an HTML form submission.
-
- The response is ``202 Accepted`` rather than ``201 Created``: the submission
- is acknowledged as received and well-formed, but Hymical Forms does not yet
- persist it or deliver it anywhere.
"""
+ accept an html form submission
+ :param endpoint_id: endpoint identifier taken from the request path
+ :param request: the incoming request, read for its content type and body
+ :returns: an acknowledgement carrying the generated submission metadata
+ """
+ # The response is 202 Accepted rather than 201 Created: the submission is
+ # acknowledged as received and well-formed, but Hymical Forms does not yet
+ # persist it or deliver it anywhere.
if not is_valid_endpoint_id(endpoint_id):
raise InvalidEndpointId()
@@ -147,16 +175,22 @@ async def submit(endpoint_id: str, request: Request) -> SubmissionAccepted:
async def _parse_form(
request: Request, media_type: str, settings: Settings
) -> list[tuple[str, str]]:
- """Parse the body into ordered name/value pairs, preserving repeated names.
-
- The parser is selected from the media type we normalized ourselves rather
- than through ``Request.form()``, whose dispatch compares the header verbatim
- even though media types are case-insensitive (RFC 9110 §8.3).
-
- Starlette's own field and part limits are disabled: the request body size cap
- already bounds memory use, and leaving them on would let a library-defined
- threshold shadow the limits configured for this service.
"""
+ parse the body into ordered name/value pairs, preserving repeated names
+ :param request: the incoming request, streamed into the form parser
+ :param media_type: normalized media type taken from the Content-Type header
+ :param settings: active configuration, used to size the parser buffers
+ :returns: ordered name/value pairs exactly as submitted
+ :raises MalformedFormBody: if the body does not parse as the declared media type
+ :raises FileUploadNotSupported: if a multipart part carries a file
+ """
+ # The parser is selected from the media type we normalized ourselves rather
+ # than through ``Request.form()``, whose dispatch compares the header verbatim
+ # even though media types are case-insensitive (RFC 9110 section 8.3).
+ #
+ # Starlette's own field and part limits are disabled: the request body size cap
+ # already bounds memory use, and leaving them on would let a library-defined
+ # threshold shadow the limits configured for this service.
parser: FormParser | MultiPartParser
if media_type == MULTIPART:
parser = MultiPartParser(
@@ -191,11 +225,20 @@ async def _parse_form(
def _failure_reason(exc: MultiPartException | ParseError) -> str:
+ """
+ extract a human-readable reason from a form parser failure
+ :param exc: the exception raised while parsing the body
+ :returns: the parser's description of what went wrong
+ """
return exc.message if isinstance(exc, MultiPartException) else str(exc)
def _media_type(content_type: str | None) -> str:
- """Strip parameters such as ``charset`` and ``boundary`` from a Content-Type."""
+ """
+ strip parameters such as charset and boundary from a Content-Type header
+ :param content_type: raw header value, or None when the header is absent
+ :returns: the lowercased media type, or an empty string when there is none
+ """
if not content_type:
return ""
return content_type.split(";", 1)[0].strip().lower()
diff --git a/src/hymical_forms/app.py b/src/hymical_forms/app.py
index 281c77f..90ea1ac 100644
--- a/src/hymical_forms/app.py
+++ b/src/hymical_forms/app.py
@@ -1,4 +1,6 @@
-"""Application assembly."""
+"""
+application assembly
+"""
from __future__ import annotations
@@ -20,12 +22,14 @@
def create_app(settings: Settings | None = None) -> FastAPI:
- """Build a Hymical Forms application.
-
- Settings are attached to ``app.state`` rather than read from a module-level
- singleton, so a test (or a future multi-tenant host) can run several
- differently configured applications in one process.
"""
+ build a hymical forms application
+ :param settings: configuration to use, or None to load it from the environment
+ :returns: the configured FastAPI application
+ """
+ # Settings are attached to ``app.state`` rather than read from a module-level
+ # singleton, so a test (or a future multi-tenant host) can run several
+ # differently configured applications in one process.
settings = settings or Settings()
app = FastAPI(
diff --git a/src/hymical_forms/config.py b/src/hymical_forms/config.py
index bfae03f..1d505e0 100644
--- a/src/hymical_forms/config.py
+++ b/src/hymical_forms/config.py
@@ -1,8 +1,8 @@
-"""Application settings.
+"""
+application settings, read from ``FORMS_``-prefixed environment variables
-Every setting is read from a ``FORMS_``-prefixed environment variable (or a local
-``.env`` file). Settings are added only when the code actually uses them, so this
-model is currently limited to the ingestion boundary's protective limits.
+Settings are added only when the code actually uses them, so this model is
+currently limited to the ingestion boundary's protective limits.
"""
from __future__ import annotations
@@ -12,7 +12,9 @@
class Settings(BaseSettings):
- """Runtime configuration for a Hymical Forms process."""
+ """
+ runtime configuration for a hymical forms process
+ """
model_config = SettingsConfigDict(
env_prefix="FORMS_",
diff --git a/src/hymical_forms/errors.py b/src/hymical_forms/errors.py
index e2d6186..1e3853f 100644
--- a/src/hymical_forms/errors.py
+++ b/src/hymical_forms/errors.py
@@ -1,7 +1,8 @@
-"""The single JSON error envelope used by every non-2xx response.
+"""
+the single JSON error envelope used by every non-2xx response
-Every error the API can produce — raised by our own code, by FastAPI's request
-validation, or by Starlette's routing — is rendered as::
+Every error the API can produce, whether raised by our own code, by FastAPI's
+request validation, or by Starlette's routing, is rendered as::
{"error": {"code": "...", "message": "...", "details": {...}}}
@@ -27,7 +28,9 @@
class ErrorDetail(BaseModel):
- """The body of an error response."""
+ """
+ the body of an error response
+ """
code: str = Field(description="Stable, machine-readable error identifier.")
message: str = Field(description="Human-readable explanation of the failure.")
@@ -38,27 +41,38 @@ class ErrorDetail(BaseModel):
class ErrorResponse(BaseModel):
- """The envelope returned for every error."""
+ """
+ the envelope returned for every error
+ """
error: ErrorDetail
class ApiError(Exception):
- """An error that maps directly onto the public error envelope.
-
- Subclasses fix ``status_code`` and ``code``; instances supply the message and
- any structured details.
+ """
+ an error that maps directly onto the public error envelope
"""
+ # Subclasses fix ``status_code`` and ``code``; instances supply the message
+ # and any structured details.
status_code: ClassVar[int] = 500
code: ClassVar[str] = "internal_error"
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
+ """
+ record the message and context for an error response
+ :param message: human-readable explanation of the failure
+ :param details: optional structured context, such as the limit that was exceeded
+ """
super().__init__(message)
self.message = message
self.details = details
def as_response(self) -> JSONResponse:
+ """
+ render this error in the shared envelope
+ :returns: a JSONResponse carrying the envelope and this error's status code
+ """
return error_response(
status_code=self.status_code,
code=self.code,
@@ -74,13 +88,23 @@ def error_response(
message: str,
details: dict[str, Any] | None = None,
) -> JSONResponse:
- """Build a JSON response in the standard error envelope."""
+ """
+ build a JSON response in the standard error envelope
+ :param status_code: HTTP status code to return
+ :param code: stable, machine-readable error identifier
+ :param message: human-readable explanation of the failure
+ :param details: optional structured context, omitted from the body when absent
+ :returns: a JSONResponse carrying the envelope
+ """
payload = ErrorResponse(error=ErrorDetail(code=code, message=message, details=details))
return JSONResponse(status_code=status_code, content=payload.model_dump(exclude_none=True))
def register_exception_handlers(app: FastAPI) -> None:
- """Route every error class the app can raise through the shared envelope."""
+ """
+ route every error class the app can raise through the shared envelope
+ :param app: the application to register the handlers on
+ """
app.add_exception_handler(ApiError, _handle_api_error)
app.add_exception_handler(SubmissionRejected, _handle_submission_rejected)
app.add_exception_handler(StarletteHTTPException, _handle_http_exception)
@@ -93,16 +117,25 @@ def register_exception_handlers(app: FastAPI) -> None:
async def _handle_api_error(request: Request, exc: Exception) -> Response:
+ """
+ render an error raised by our own HTTP layer
+ :param request: the request being handled
+ :param exc: the raised exception, always an ApiError
+ :returns: the envelope response
+ """
assert isinstance(exc, ApiError)
return exc.as_response()
async def _handle_submission_rejected(request: Request, exc: Exception) -> Response:
- """Render a domain rejection.
-
- Every ingestion rule failure is a well-formed request carrying an
- unacceptable submission, which is exactly what 422 describes.
"""
+ render a domain rejection
+ :param request: the request being handled
+ :param exc: the raised exception, always a SubmissionRejected
+ :returns: the envelope response, with a 422 status
+ """
+ # Every ingestion rule failure is a well-formed request carrying an
+ # unacceptable submission, which is exactly what 422 describes.
assert isinstance(exc, SubmissionRejected)
return error_response(
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
@@ -113,7 +146,12 @@ async def _handle_submission_rejected(request: Request, exc: Exception) -> Respo
async def _handle_http_exception(request: Request, exc: Exception) -> Response:
- """Render routing-level errors (unknown paths, wrong methods) in the envelope."""
+ """
+ render routing-level errors such as unknown paths and wrong methods
+ :param request: the request being handled
+ :param exc: the raised exception, always a Starlette HTTPException
+ :returns: the envelope response
+ """
assert isinstance(exc, StarletteHTTPException)
return error_response(
status_code=exc.status_code,
@@ -123,6 +161,12 @@ async def _handle_http_exception(request: Request, exc: Exception) -> Response:
async def _handle_request_validation_error(request: Request, exc: Exception) -> Response:
+ """
+ render a request that FastAPI could not validate
+ :param request: the request being handled
+ :param exc: the raised exception, always a RequestValidationError
+ :returns: the envelope response, with a 422 status
+ """
assert isinstance(exc, RequestValidationError)
return error_response(
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
@@ -132,7 +176,12 @@ async def _handle_request_validation_error(request: Request, exc: Exception) ->
async def _handle_unexpected_error(request: Request, exc: Exception) -> Response:
- """Return an opaque 500 rather than letting an internal error reach the client."""
+ """
+ return an opaque 500 rather than letting an internal error reach the client
+ :param request: the request being handled
+ :param exc: the unhandled exception, deliberately not described to the client
+ :returns: the envelope response, with a 500 status
+ """
return error_response(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
code="internal_error",
@@ -141,7 +190,11 @@ async def _handle_unexpected_error(request: Request, exc: Exception) -> Response
def _code_for_status(status_code: int) -> str:
- """Derive an error code from a status code, e.g. 405 -> ``method_not_allowed``."""
+ """
+ derive an error code from a status code, so that 405 gives ``method_not_allowed``
+ :param status_code: HTTP status code to name
+ :returns: the status phrase in snake case, or ``http_error`` if unrecognised
+ """
try:
phrase = HTTPStatus(status_code).phrase
except ValueError:
diff --git a/src/hymical_forms/ingestion.py b/src/hymical_forms/ingestion.py
index fb9fdb7..04eab5a 100644
--- a/src/hymical_forms/ingestion.py
+++ b/src/hymical_forms/ingestion.py
@@ -1,9 +1,10 @@
-"""Ingestion domain rules: endpoint identifiers and submission normalization.
+"""
+ingestion domain rules: endpoint identifiers and submission normalization
-This module is deliberately free of HTTP concepts. It answers two questions —
-"is this a well-formed endpoint identifier?" and "is this set of name/value pairs
-an acceptable submission?" — and leaves status codes and wire formats to the API
-layer.
+This module is deliberately free of HTTP concepts. It answers two questions,
+"is this a well-formed endpoint identifier?" and "is this set of name/value
+pairs an acceptable submission?", and leaves status codes and wire formats to
+the API layer.
"""
from __future__ import annotations
@@ -30,11 +31,13 @@
def is_valid_endpoint_id(value: str) -> bool:
- """Report whether ``value`` is a syntactically valid endpoint identifier.
-
- Interval 1 has no endpoint registry, so any syntactically valid identifier is
- treated as addressable.
"""
+ report whether a path segment is a syntactically valid endpoint identifier
+ :param value: candidate identifier taken from the request path
+ :returns: True if the identifier is well formed
+ """
+ # Interval 1 has no endpoint registry, so any syntactically valid identifier
+ # is treated as addressable.
return (
ENDPOINT_ID_MIN_LENGTH <= len(value) <= ENDPOINT_ID_MAX_LENGTH
and _ENDPOINT_ID_PATTERN.fullmatch(value) is not None
@@ -42,9 +45,17 @@ def is_valid_endpoint_id(value: str) -> bool:
class SubmissionRejected(Exception):
- """A submission violated an ingestion rule and must not be accepted."""
+ """
+ raised when a submission violates an ingestion rule and must not be accepted
+ """
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
+ """
+ record why a submission was refused
+ :param code: stable, machine-readable identifier for the broken rule
+ :param message: human-readable explanation of the failure
+ :param details: optional structured context, such as the limit that was exceeded
+ """
super().__init__(message)
self.code = code
self.message = message
@@ -53,13 +64,13 @@ def __init__(self, code: str, message: str, details: dict[str, Any] | None = Non
@dataclass(frozen=True, slots=True)
class Submission:
- """A validated form submission in its internal representation.
-
- Repeated field names are preserved as ordered tuples because HTML forms use
- them for checkbox groups and multi-selects; collapsing them would silently
- discard user input.
+ """
+ a validated form submission in its internal representation
"""
+ # Repeated field names are preserved as ordered tuples because HTML forms use
+ # them for checkbox groups and multi-selects; collapsing them would silently
+ # discard user input.
id: str
endpoint_id: str
received_at: datetime
@@ -67,12 +78,18 @@ class Submission:
@property
def field_count(self) -> int:
- """The number of name/value pairs the submission carries."""
+ """
+ count the name/value pairs the submission carries
+ :returns: the total number of submitted values across all field names
+ """
return sum(len(values) for values in self.fields.values())
def new_submission_id() -> str:
- """Generate an opaque, prefixed submission identifier."""
+ """
+ generate an opaque, prefixed submission identifier
+ :returns: a fresh submission id such as ``sub_1f0c9a...``
+ """
return f"{SUBMISSION_ID_PREFIX}{uuid.uuid4().hex}"
@@ -84,13 +101,15 @@ def build_submission(
max_field_name_length: int,
max_field_value_length: int,
) -> Submission:
- """Validate parsed form pairs and normalize them into a :class:`Submission`.
-
- ``items`` is the ordered sequence of name/value pairs exactly as parsed from
- the request body, including repeats.
-
- Raises:
- SubmissionRejected: if the submission is empty or breaches a limit.
+ """
+ validate parsed form pairs and normalize them into a submission
+ :param endpoint_id: the endpoint the submission was addressed to
+ :param items: ordered name/value pairs as parsed from the request body, repeats included
+ :param max_fields: largest number of name/value pairs accepted
+ :param max_field_name_length: largest field name accepted, in characters
+ :param max_field_value_length: largest field value accepted, in characters
+ :returns: the normalized submission
+ :raises SubmissionRejected: if the submission is empty or breaches a limit
"""
if len(items) > max_fields:
raise SubmissionRejected(
@@ -120,6 +139,12 @@ def build_submission(
def _validate_field_name(name: str, max_length: int) -> None:
+ """
+ check a submitted field name against the name rules
+ :param name: field name as submitted
+ :param max_length: largest field name accepted, in characters
+ :raises SubmissionRejected: if the name is empty, too long, or holds control characters
+ """
if not name:
raise SubmissionRejected(
"invalid_field_name",
@@ -139,6 +164,13 @@ def _validate_field_name(name: str, max_length: int) -> None:
def _validate_field_value(name: str, value: str, max_length: int) -> None:
+ """
+ check a submitted field value against the value rules
+ :param name: field name the value belongs to, used only in the error message
+ :param value: field value as submitted
+ :param max_length: largest field value accepted, in characters
+ :raises SubmissionRejected: if the value is too long or holds a null byte
+ """
if len(value) > max_length:
raise SubmissionRejected(
"field_value_too_long",
diff --git a/src/hymical_forms/main.py b/src/hymical_forms/main.py
index 0b5d40d..98904c4 100644
--- a/src/hymical_forms/main.py
+++ b/src/hymical_forms/main.py
@@ -1,4 +1,5 @@
-"""ASGI entrypoint.
+"""
+the ASGI entrypoint
Run with::
diff --git a/src/hymical_forms/middleware.py b/src/hymical_forms/middleware.py
index bccc048..01fbeb7 100644
--- a/src/hymical_forms/middleware.py
+++ b/src/hymical_forms/middleware.py
@@ -1,4 +1,6 @@
-"""ASGI middleware protecting the ingestion boundary."""
+"""
+middleware protecting the ingestion boundary at the ASGI layer
+"""
from __future__ import annotations
@@ -10,12 +12,18 @@
class RequestBodyTooLarge(ApiError):
- """The request body exceeded the configured maximum."""
+ """
+ raised when a request body exceeds the configured maximum
+ """
status_code = HTTPStatus.REQUEST_ENTITY_TOO_LARGE
code = "request_body_too_large"
def __init__(self, limit: int) -> None:
+ """
+ record the limit the body overran
+ :param limit: largest request body accepted, in bytes
+ """
super().__init__(
f"Request body exceeds the limit of {limit} bytes.",
details={"limit_bytes": limit},
@@ -23,23 +31,35 @@ def __init__(self, limit: int) -> None:
class BodySizeLimitMiddleware:
- """Reject requests whose body exceeds ``max_bytes``.
-
- Starlette buffers request bodies without an upper bound, so the cap has to sit
- in front of the form parsers rather than inside a route handler. Requests that
- declare an oversized ``Content-Length`` are refused before a single body byte
- is read; the rest are cut off as soon as the running total crosses the limit.
+ """
+ reject requests whose body exceeds a configured size
"""
+ # Starlette buffers request bodies without an upper bound, so the cap has to
+ # sit in front of the form parsers rather than inside a route handler.
+
def __init__(self, app: ASGIApp, *, max_bytes: int) -> None:
+ """
+ wrap an ASGI application with a request body size cap
+ :param app: the ASGI application to wrap
+ :param max_bytes: largest request body accepted, in bytes
+ """
self.app = app
self.max_bytes = max_bytes
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+ """
+ pass the request through, refusing any body over the limit
+ :param scope: ASGI connection scope
+ :param receive: ASGI callable yielding request messages
+ :param send: ASGI callable accepting response messages
+ """
if scope["type"] != "http":
await self.app(scope, receive, send)
return
+ # A request that declares an oversized Content-Length is refused before a
+ # single body byte is read.
declared = _declared_content_length(scope)
if declared is not None and declared > self.max_bytes:
await RequestBodyTooLarge(self.max_bytes).as_response()(scope, receive, send)
@@ -48,6 +68,11 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
received = 0
async def limited_receive() -> Message:
+ """
+ read the next request message, cutting off an oversized body
+ :returns: the next ASGI message
+ :raises RequestBodyTooLarge: once the running body total crosses the limit
+ """
nonlocal received
message = await receive()
if message["type"] == "http.request":
@@ -62,7 +87,11 @@ async def limited_receive() -> Message:
def _declared_content_length(scope: Scope) -> int | None:
- """Read ``Content-Length`` from the raw ASGI scope, ignoring unparseable values."""
+ """
+ read the Content-Length header from the raw ASGI scope
+ :param scope: ASGI connection scope
+ :returns: the declared body length, or None when absent or unparseable
+ """
for name, value in scope["headers"]:
if name == b"content-length":
try:
diff --git a/tests/conftest.py b/tests/conftest.py
index b9bdb7b..0dbfc18 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,5 @@
-"""Shared test fixtures.
+"""
+shared test fixtures
Tests build their own application instances so that limits can be lowered to
values that are cheap to exercise, and so that a developer's local environment
@@ -24,30 +25,47 @@
class IsolatedSettings(Settings):
- """Settings that ignore a local ``.env``, so a developer's file cannot skew a run."""
+ """
+ settings that ignore a local ``.env``, so a developer's file cannot skew a run
+ """
model_config = SettingsConfigDict(env_file=None)
@pytest.fixture(autouse=True)
def _ignore_ambient_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
- """Hide any ``FORMS_*`` variables the developer happens to have exported."""
+ """
+ hide any ``FORMS_*`` variables the developer happens to have exported
+ :param monkeypatch: pytest fixture used to remove the variables for one test
+ """
for name in list(os.environ):
if name.startswith("FORMS_"):
monkeypatch.delenv(name)
def build_settings(**overrides: int) -> Settings:
- """Build settings from defaults and explicit overrides only."""
+ """
+ build settings from defaults and explicit overrides only
+ :param overrides: setting values to replace the built-in defaults
+ :returns: settings that ignore the ambient environment
+ """
return IsolatedSettings(**overrides)
@pytest.fixture
def make_client() -> Iterator[ClientFactory]:
- """Return a factory for clients bound to an app with the given setting overrides."""
+ """
+ provide a factory for clients bound to an app with the given setting overrides
+ :returns: a factory that accepts setting overrides and returns a test client
+ """
with ExitStack() as stack:
def factory(**overrides: int) -> TestClient:
+ """
+ build a client for an app configured with the given overrides
+ :param overrides: setting values to replace the built-in defaults
+ :returns: a test client closed when the fixture tears down
+ """
return stack.enter_context(TestClient(create_app(build_settings(**overrides))))
yield factory
@@ -55,5 +73,9 @@ def factory(**overrides: int) -> TestClient:
@pytest.fixture
def client(make_client: ClientFactory) -> TestClient:
- """A client for an application running on default settings."""
+ """
+ provide a client for an application running on default settings
+ :param make_client: factory for clients bound to a configured app
+ :returns: a test client for an app on default settings
+ """
return make_client()
diff --git a/tests/test_endpoint_ids.py b/tests/test_endpoint_ids.py
index abaab37..03c3918 100644
--- a/tests/test_endpoint_ids.py
+++ b/tests/test_endpoint_ids.py
@@ -1,7 +1,8 @@
-"""Endpoint identifier rules.
+"""
+endpoint identifier rules
-An endpoint ID is 3-64 characters of lowercase ASCII letters, digits, ``-`` and
-``_``, and must start and end with a letter or digit.
+An endpoint ID is 3 to 64 characters of lowercase ASCII letters, digits, ``-``
+and ``_``, and must start and end with a letter or digit.
"""
from __future__ import annotations
diff --git a/tests/test_errors.py b/tests/test_errors.py
index 3c138ef..f0a30cf 100644
--- a/tests/test_errors.py
+++ b/tests/test_errors.py
@@ -1,4 +1,6 @@
-"""The shared error envelope."""
+"""
+the shared error envelope
+"""
from __future__ import annotations
@@ -73,7 +75,10 @@ def test_rejects_a_multipart_body_that_does_not_match_its_boundary(client: TestC
def test_rejects_file_uploads(client: TestClient) -> None:
- """File handling is out of scope, so a file part is refused rather than ignored."""
+ """
+ file handling is out of scope, so a file part is refused rather than ignored
+ :param client: test client for an app on default settings
+ """
response = client.post(
ENDPOINT,
data={"email": "dev@example.com"},
@@ -105,6 +110,9 @@ def test_unexpected_errors_do_not_leak_internals() -> None:
@app.get("/boom")
async def boom() -> None:
+ """
+ raise an error carrying a secret, to prove the handler does not relay it
+ """
raise RuntimeError("connection string: postgres://user:pa55w0rd@db/forms")
with TestClient(app, raise_server_exceptions=False) as client:
diff --git a/tests/test_health.py b/tests/test_health.py
index 8f0a62c..2e303a6 100644
--- a/tests/test_health.py
+++ b/tests/test_health.py
@@ -1,4 +1,6 @@
-"""Health endpoint behaviour."""
+"""
+health endpoint behaviour
+"""
from __future__ import annotations
diff --git a/tests/test_ingestion.py b/tests/test_ingestion.py
index 808bb76..fd12d96 100644
--- a/tests/test_ingestion.py
+++ b/tests/test_ingestion.py
@@ -1,4 +1,5 @@
-"""The internal submission representation.
+"""
+the internal submission representation
These tests pin the shape the rest of the system will eventually persist and
deliver, which the HTTP acknowledgement only summarises.
diff --git a/tests/test_limits.py b/tests/test_limits.py
index 6f2c1dd..6fdee22 100644
--- a/tests/test_limits.py
+++ b/tests/test_limits.py
@@ -1,4 +1,6 @@
-"""Limits that protect the ingestion boundary."""
+"""
+limits that protect the ingestion boundary
+"""
from __future__ import annotations
@@ -10,7 +12,11 @@
def _chunks(*parts: bytes) -> Iterator[bytes]:
- """Yield a body in pieces so the client streams it without a Content-Length."""
+ """
+ yield a body in pieces so the client streams it without a Content-Length
+ :param parts: body fragments to send in order
+ :returns: an iterator over the fragments
+ """
yield from parts
@@ -26,7 +32,10 @@ def test_rejects_a_body_larger_than_the_declared_limit(make_client: ClientFactor
def test_rejects_an_oversized_streamed_body(make_client: ClientFactory) -> None:
- """A chunked request cannot escape the limit by omitting Content-Length."""
+ """
+ a chunked request cannot escape the limit by omitting Content-Length
+ :param make_client: factory for clients bound to a configured app
+ """
client = make_client(max_body_bytes=64)
response = client.post(
@@ -125,7 +134,10 @@ def test_rejects_a_null_byte_in_a_field_value(make_client: ClientFactory) -> Non
def test_allows_newlines_inside_a_textarea_value(make_client: ClientFactory) -> None:
- """Multi-line textarea input is legitimate and must not trip the name rules."""
+ """
+ multi-line textarea input is legitimate and must not trip the name rules
+ :param make_client: factory for clients bound to a configured app
+ """
client = make_client()
response = client.post(ENDPOINT, data={"message": "line one\r\nline two"})
diff --git a/tests/test_submissions.py b/tests/test_submissions.py
index 7456ef6..5316d45 100644
--- a/tests/test_submissions.py
+++ b/tests/test_submissions.py
@@ -1,4 +1,6 @@
-"""Accepting form submissions."""
+"""
+accepting form submissions
+"""
from __future__ import annotations
@@ -21,7 +23,10 @@ def test_accepts_a_urlencoded_submission(client: TestClient) -> None:
def test_accepts_a_multipart_submission(client: TestClient) -> None:
- """Browsers send ``enctype="multipart/form-data"`` forms; text parts are accepted."""
+ """
+ browsers send ``enctype="multipart/form-data"`` forms, so text parts are accepted
+ :param client: test client for an app on default settings
+ """
boundary = "hymicalboundary"
body = (
f"--{boundary}\r\n"
@@ -62,7 +67,10 @@ def test_each_submission_gets_a_distinct_id(client: TestClient) -> None:
def test_repeated_field_names_are_all_counted(client: TestClient) -> None:
- """Checkbox groups submit one name several times; no value may be dropped."""
+ """
+ checkbox groups submit one name several times, so no value may be dropped
+ :param client: test client for an app on default settings
+ """
response = client.post(ENDPOINT, data={"topic": ["billing", "api", "docs"], "email": "a@b.co"})
assert response.status_code == 202
@@ -70,7 +78,10 @@ def test_repeated_field_names_are_all_counted(client: TestClient) -> None:
def test_accepts_a_field_with_an_empty_value(client: TestClient) -> None:
- """An optional text input that the user left blank is still a submitted field."""
+ """
+ an optional text input that the user left blank is still a submitted field
+ :param client: test client for an app on default settings
+ """
response = client.post(ENDPOINT, content=b"nickname=", headers=URLENCODED_HEADERS)
assert response.status_code == 202
@@ -95,7 +106,10 @@ def test_content_type_parameters_and_casing_are_ignored(client: TestClient) -> N
def test_does_not_echo_submitted_values(client: TestClient) -> None:
- """The acknowledgement is metadata only; user input is not reflected back."""
+ """
+ the acknowledgement is metadata only, so user input is not reflected back
+ :param client: test client for an app on default settings
+ """
response = client.post(ENDPOINT, data={"secret": "hunter2"})
assert "hunter2" not in response.text
From 1e604424a010799ad5f0190e4701901c910138a6 Mon Sep 17 00:00:00 2001
From: Quang <20378quang@gmail.com>
Date: Mon, 24 Aug 2026 10:38:24 -0400
Subject: [PATCH 3/6] feat: persist endpoints and the submissions sent to them
---
.env.example | 13 +-
.gitignore | 6 +
README.md | 217 ++++++++++++++----
...parent.png => logo_symbol_transparent.png} | Bin
pyproject.toml | 5 +
src/hymical_forms/api/endpoints.py | 126 ++++++++++
src/hymical_forms/api/submissions.py | 90 +++++++-
src/hymical_forms/app.py | 37 ++-
src/hymical_forms/config.py | 6 +
src/hymical_forms/db.py | 90 ++++++++
src/hymical_forms/errors.py | 32 ++-
src/hymical_forms/ingestion.py | 9 +
src/hymical_forms/models.py | 138 +++++++++++
src/hymical_forms/storage.py | 75 ++++++
tests/conftest.py | 71 +++++-
tests/test_endpoint_ids.py | 9 +-
tests/test_endpoints_api.py | 118 ++++++++++
tests/test_persistence.py | 210 +++++++++++++++++
18 files changed, 1175 insertions(+), 77 deletions(-)
rename docs/images/{logo_symbol_tramsparent.png => logo_symbol_transparent.png} (100%)
create mode 100644 src/hymical_forms/api/endpoints.py
create mode 100644 src/hymical_forms/db.py
create mode 100644 src/hymical_forms/models.py
create mode 100644 src/hymical_forms/storage.py
create mode 100644 tests/test_endpoints_api.py
create mode 100644 tests/test_persistence.py
diff --git a/.env.example b/.env.example
index 2211e98..b06cfc7 100644
--- a/.env.example
+++ b/.env.example
@@ -1,8 +1,15 @@
# Hymical Forms configuration.
#
-# Every setting is optional and shown below with its built-in default. Copy this
-# file to `.env` and uncomment the lines you want to change, or set the same
-# variables in your process environment.
+# Copy this file to `.env`, or set the same variables in your process environment.
+# FORMS_DATABASE_URL is required. Everything below it is optional and shown with
+# its built-in default; uncomment the lines you want to change.
+
+# SQLAlchemy database URL. PostgreSQL is the intended production database.
+FORMS_DATABASE_URL=postgresql+psycopg://forms:forms@localhost:5432/forms
+
+# SQLite is supported for local experimentation and backs the test suite. It is
+# not a supported production target.
+# FORMS_DATABASE_URL=sqlite:///./forms.db
# Largest request body accepted, in bytes. File uploads are not supported, so
# this only needs to accommodate text form fields.
diff --git a/.gitignore b/.gitignore
index 83972fa..d3323fe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,6 +61,12 @@ local_settings.py
db.sqlite3
db.sqlite3-journal
+# Local SQLite databases, e.g. FORMS_DATABASE_URL=sqlite:///./forms.db
+*.db
+*.db-journal
+*.sqlite
+*.sqlite3
+
# Flask stuff:
instance/
.webassets-cache
diff --git a/README.md b/README.md
index 98953bf..11d0226 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,14 @@
-# Hymical Forms
+
+
+
-Reliable form ingestion and webhook delivery for developers.
+Hymical Forms
+
+
+ Reliable form ingestion and webhook delivery for developers.
+
## The problem
@@ -13,27 +21,33 @@ open-source.
## Project status
-**Early development.** This build implements the ingestion boundary only.
+**Early development.** This build registers endpoints and stores the
+submissions sent to them. Nothing is delivered onwards yet.
-A submission is parsed, validated and acknowledged, and then discarded.
-Nothing is persisted and nothing is delivered anywhere. There is no
-authentication, no rate limiting, and no spam protection, so do not expose this
-to the public internet.
+Endpoint management is completely unauthenticated: anyone who can reach the API
+can create an endpoint. There is no rate limiting and no spam protection, so do
+not expose this to the public internet.
| Capability | Status |
| ----------------------------- | ------------------------- |
| Health endpoint | Implemented |
| Form ingestion + validation | Implemented |
| Request limits + error model | Implemented |
-| Persistence | **Not implemented** |
+| Endpoint registry | Implemented |
+| Submission persistence | Implemented |
| API keys / authentication | **Not implemented** |
| Webhook delivery and retries | **Not implemented** |
| Rate limiting, spam handling | **Not implemented** |
+| Schema migrations | **Not implemented** |
| Export, retention, dashboards | **Not implemented** |
## Requirements
-Python 3.11 or newer.
+- Python 3.11 or newer
+- PostgreSQL, which is the intended production database
+
+SQLite is supported for local experimentation and backs the test suite. It is
+not a supported production target.
## Install
@@ -43,12 +57,34 @@ python -m venv .venv && . .venv/bin/activate && pip install -e ".[dev]"
On Windows, activate with `.venv\Scripts\activate` instead.
+## Configure
+
+`FORMS_DATABASE_URL` is required and has no default. Set it in the environment
+or in a `.env` file in the working directory:
+
+```bash
+FORMS_DATABASE_URL=postgresql+psycopg://forms:forms@localhost:5432/forms
+```
+
+To try the service without running PostgreSQL:
+
+```bash
+FORMS_DATABASE_URL=sqlite:///./forms.db
+```
+
+See [`.env.example`](.env.example) for every setting and its default.
+
## Run
```bash
uvicorn hymical_forms.main:app --reload
```
+Missing tables are created at startup, so an empty database is enough to begin.
+Startup fails if the database cannot be reached, rather than serving requests
+that would only fail later. There is no migration framework yet, so startup
+never alters a table that already exists; see [Limitations](#limitations).
+
Interactive API documentation is served at `http://127.0.0.1:8000/docs`.
## API
@@ -61,17 +97,57 @@ Reports that the API process is running.
{ "status": "ok", "service": "hymical-forms", "version": "0.1.0" }
```
-This is a liveness signal only. Hymical Forms has no external dependencies yet,
-so there is nothing that readiness could report separately.
+This is a liveness signal only. It does not check the database, so it stays
+answerable while the database is down, which is what makes it useful for
+deciding whether to restart the process.
+
+### `POST /endpoints`
+
+Registers a form endpoint. Submissions are only accepted for endpoints that
+exist here.
+
+**This route is unauthenticated.** Authentication is deliberately out of scope
+for now, so keep the service on a private network.
+
+```bash
+curl -X POST http://127.0.0.1:8000/endpoints \
+ -H 'Content-Type: application/json' \
+ -d '{"id": "contact-form", "name": "Contact form"}'
+```
+
+| Field | Required | Meaning |
+| ----------- | -------- | -------------------------------------------------- |
+| `id` | yes | The public identifier the endpoint answers on |
+| `name` | yes | Human-readable label, 1 to 200 characters |
+| `is_active` | no | Whether it accepts submissions, defaults to `true` |
+
+**Endpoint IDs** are supplied by you, not generated, because the ID appears in
+the `action` URL of your HTML form and a memorable one is worth more than an
+opaque one. An ID is 3 to 64 characters of lowercase ASCII letters, digits, `-`
+and `_`, and must start and end with a letter or digit. It is also the primary
+key, so it cannot be changed later.
+
+Returns `201 Created`:
+
+```json
+{
+ "id": "contact-form",
+ "name": "Contact form",
+ "is_active": true,
+ "created_at": "2026-08-24T14:34:27.432598Z"
+}
+```
+
+Reusing an ID returns `409 endpoint_already_exists`. There is no route to list,
+update or delete endpoints yet.
### `POST /f/{endpoint_id}`
-Accepts a form submission.
+Accepts a form submission for a registered endpoint and stores it.
-**Endpoint IDs** are 3 to 64 characters of lowercase ASCII letters, digits, `-`
-and `_`, and must start and end with a letter or digit. There is no endpoint
-registry yet, so any syntactically valid ID is addressable; a malformed one is
-rejected with `404 invalid_endpoint_id`.
+A submission to an ID that does not exist is rejected with
+`404 endpoint_not_found`, and one to an inactive endpoint with
+`409 endpoint_inactive`. Neither leaves anything in the database.
**Content types.** `application/x-www-form-urlencoded` and
`multipart/form-data` are both accepted, so a plain HTML `