kereru-forms/docs/ARCHITECTURE.md

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

  1. Gin middleware: telemetry span (otelgin), panic recovery, request id, request metadata (client IP/user agent), structured access log, CORS.
  2. api.RegisterHandlers routes to the generated strict handler, which performs OpenAPI request decoding/validation.
  3. api.Server methods validate business rules and call the database.Store.
  4. Responses are mapped back to generated types; errors use the Error schema ({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 /health returns status, version and uptime for liveness/readiness probes.

PDF

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:

  1. Add a new adapter implementing Store (or generalise the SQL adapter with a dialect for placeholders and upserts).
  2. Extend database.Open with the driver case and adjust migrations (TEXT timestamps and JSON strings map cleanly to timestamptz/jsonb and varchar/json).
  3. 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).