8.5 KiB
Architecture
Overview
┌──────────────────────────────┐ ┌──────────────────────────────────────────┐
│ React SPA (Vite, Tailwind) │─────▶│ kereru-forms (Go, Gin) │
│ - form filling │ HTTP │ ┌────────────┐ ┌────────────────────┐ │
│ - management │ │ │ OpenAPI │ │ Strict handlers │ │
│ - light/dark/system theme │ │ │ generated │─▶│ (validation, PDF, │ │
└──────────────────────────────┘ │ │ router │ │ access codes) │ │
│ └────────────┘ └─────────┬──────────┘ │
│ │ │
│ ┌────────────┐ ┌────────▼──────────┐ │
│ │ Telemetry │ │ Store interface │ │
│ │ (OTLP) │ │ SQLite adapter │ │
│ └────────────┘ └────────┬──────────┘ │
└───────────────────────────│──────────────┘
▼
SQLite database file
The API is mounted both at the root (for Kubernetes probes and direct clients) and under /api (used by the SPA). The backend binary can also serve the built SPA (KERERU_STATIC_DIR) with history fallback, so a single container is enough for most deployments.
Backend layout
backend/
├── openapi/openapi.yaml Contract: paths, schemas, enums
├── oapi-codegen.yaml Code generation settings
├── cmd/kereru-forms/main.go Wiring, listeners, graceful shutdown
└── internal/
├── api/ Generated server + strict handlers + router
├── config/ YAML/JSON + .env + env + flags, precedence
├── database/ Store interface, SQLite implementation, migrations
├── logging/ zerolog setup + Gin access logging
├── pdf/ PDF rendering of a form definition
└── telemetry/ OpenTelemetry traces and metrics (OTLP/HTTP)
Request flow
- Gin middleware: telemetry span (
otelgin), panic recovery, request id, request metadata (client IP/user agent), structured access log, CORS. api.RegisterHandlersroutes to the generated strict handler, which performs OpenAPI request decoding/validation.api.Servermethods validate business rules and call thedatabase.Store.- Responses are mapped back to generated types; errors use the
Errorschema ({error, code}).
Configuration
internal/config implements a deterministic precedence chain: defaults → YAML/JSON file (--conf) → .env (next to executable or cwd; never overrides existing env) → environment variables → CLI flags. The --conf flag is pre-scanned so the file is available as defaults for the remaining flags. Both IPv4 and IPv6 listeners are supported and started concurrently.
Data model
forms represents one concrete version. form_group_id groups all versions of the same logical form; (form_group_id, version) is unique. Creating a new version clones the current definition, increments version, activates the new row and inactivates the previous one. Historical submissions keep form_version and denormalised question_key values, so they remain readable after questions change.
| Table | Purpose |
|---|---|
forms |
Form version metadata, contact details, state |
questions |
Ordered questions with type-specific configuration (JSON options, bounds) |
submissions |
Response header: user identifier, form version, server-captured tracking |
answers |
One row per answered question, value stored as JSON |
access_codes |
Single-use codes (used, used_at, owning submission) |
schema_migrations |
Applied schema versions |
All timestamps are stored as RFC 3339 UTC strings. IDs are UUIDs (TEXT).
Validation
Form definitions: non-empty title, unique keys ([A-Za-z][A-Za-z0-9_-]*), supported question types, select questions require unique non-empty option values, numeric bounds ordered.
Submissions: identifier type must be email|username|phone|guid; email identifiers are parsed; answers must reference questions of that form; duplicates rejected; required questions enforced; type checks for boolean/number/select/text; numeric bounds; select values must exist; text length capped and single-line questions reject newlines.
Access codes are consumed in the same SQL transaction as the submission insert. The conditional UPDATE ... WHERE used = 0 makes reuse safe under concurrency; failure rolls back the submission.
Observability
- Logs: zerolog, JSON or console, level configurable. Access logs include method, path, status, latency, client IP, user agent and
X-Request-ID. - Traces/metrics: OpenTelemetry SDK with OTLP/HTTP exporters. Spans cover HTTP requests (
otelgin) and individual use cases (GetForm,SubmitForm,GetFormPdf, …) with form/submission attributes. Metrics are exported every 15s. Telemetry is opt-in (KERERU_TELEMETRY_ENABLED). - Health:
GET /healthreturns status, version and uptime for liveness/readiness probes.
internal/pdf renders the form definition with go-pdf/fpdf: title, description, introduction, contact block, numbered questions, type hints, options and answer lines. Built-in PDF fonts cover WinAnsi; macrons and common typographic characters are transliterated so the output is always valid. For full Unicode, embed a TTF font instead.
Frontend layout
frontend/src/
├── api/… (reserved)
├── components/ Layout, ThemeToggle, QuestionField, StatusBadge
├── hooks/useTheme light/dark/system with localStorage + matchMedia
├── lib/api.ts Typed fetch client, ApiError, PDF download
├── lib/types.ts Mirrors the OpenAPI schemas
├── pages/ Home, FillForm, Manage, FormBuilder, FormDetail, SubmissionDetail
└── test/ Vitest + Testing Library
Theming uses Tailwind's class dark mode. An inline script in index.html applies the stored preference before paint to avoid a flash. The useTheme hook keeps documentElement.dark, localStorage and the OS preference in sync.
Forms may carry a company name, contact details and a logo URL; the UI renders the logo on the home, fill and detail views. Remote logos are not embedded in the generated PDF.
The fill flow asks for the respondent identifier and (optionally) an access code first, then renders the questions by type. On success the local answers are cleared and only the confirmation is shown — previously entered data is never re-presented. The management section lists active and inactive versions and supports activation, versioning, deletion, PDF download, access-code generation and submission review.
Extending the database
database.Store is the boundary used by the API layer. To add PostgreSQL or MariaDB:
- Add a new adapter implementing
Store(or generalise the SQL adapter with a dialect for placeholders and upserts). - Extend
database.Openwith the driver case and adjust migrations (TEXT timestamps and JSON strings map cleanly totimestamptz/jsonbandvarchar/json). - Keep transaction semantics for submission + access-code consumption.
Known limitations / next steps
- Management endpoints have no authentication or authorisation yet; front them with an auth proxy or add an auth layer.
- SQLite is a single-writer store; for horizontal scaling use a network database adapter and remove the single-connection pool limit.
- Access codes are generated with 8 characters from a 32-symbol alphabet (~40 bits) — sufficient for single-use gating, not for secrets.
- PDF output uses built-in fonts and does not embed form logos (see above).