Servers

<qf-form> talks to a small API. Its contract is spec/openapi.yaml:

Request Does
GET /forms/{id} The latest version of a form.
GET /forms/{id}/versions/{version} A fixed version. It never changes, so it is cached for a year.
POST /forms/{id}/versions/{version}/datasources/{lookup} Runs a lookup the form declares.
POST /forms/{id}/versions/{version}/submissions Checks and accepts a submission.

Two SDKs implement it, with the same behaviour and the same tests.

ASP.NET Core

builder.Services.AddQuillflow()
    .AddFormsFromDirectory("forms")
    .AddConnector("company", async (p, ctx, ct) => await cvr.LookupAsync((string)p["cvr"]!, ctx.User, ct))
    .OnSubmit(async (submission, ctx, ct) => new SubmissionReceipt(await cases.CreateAsync(submission.Data, ct)));

app.MapQuillflow("/api").RequireAuthorization().RequireRateLimiting("forms");

MapQuillflow returns an ordinary route group, so authentication, authorization, rate limiting and CORS are added the usual way. ctx.User is the signed-in user. Plug in your own storage with UseFormStore<T>() (definitions in a database) and UseIdempotencyStore<T>() (shared between servers).

Node

import { createHandler, HttpError, memoryFormStore, toNodeListener } from "@quillflow/server-node";

const handler = createHandler({
  basePath: "/api",
  forms: memoryFormStore(definitions),
  connectors: { company: ({ cvr }, { principal }) => cvrRegister.lookup(String(cvr), principal) },
  onSubmit: async (submission, { principal }) => ({ reference: await cases.create(submission.data, principal) }),
  authenticate: (request) => {
    const user = sessionUser(request);
    if (!user) throw new HttpError(401, "Log in first");
    return user;
  },
});

http.createServer(toNodeListener(handler));                     // node:http or Express
export const POST = (request: Request) => handler(request);     // or any fetch-style framework

The handler takes a standard Request and returns a Response, so it also runs in Hono and Next.js route handlers.

What happens to a submission

  1. The body must be JSON and at most 1 MB (configurable).
  2. The form's own engine loads the data and runs every lookup again on the server.
  3. Every rule is checked again. Problems come back as 422 with one message per field, in the caller's language (from Accept-Language).
  4. Only the data that is visible under the form's rules is kept. Calculated values are recomputed, and unknown keys are dropped.
  5. Your handler gets that cleaned data and returns a reference, such as a case number.

Retries are safe. <qf-form> sends an Idempotency-Key. The same key with the same data returns the original receipt, even when two requests arrive at the same moment; the same key with different data is refused.

Errors use problem details (application/problem+json). Internal errors are logged and never sent to the browser.