Erscheinungsbild
0009 — Formularvertrag als Zod-Schema mit erzeugtem JSON Schema
Status: angenommen · Milestone: M6
Kontext
Ein Formular hat zwei Seiten, und sie liegen in verschiedenen Repositories: das Astro-Frontend hier, der PHP-Handler und die Joomla-Erweiterung com_kickastroforms woanders. Beide werden zu verschiedenen Zeitpunkten veröffentlicht.
Damit stellt sich die Frage, wo steht, wie eine Übermittlung aussieht. Der übliche Weg — auf jeder Seite eine eigene Beschreibung — hat einen Fehlermodus, der besonders unangenehm ist: Frontend und Backend laufen auseinander, und zwar unbemerkt. Es gibt keine Typprüfung über die Repositoriengrenze, keinen Compiler, der widerspricht. Auffallen würde es zuerst dem ersten echten Absender, dessen Anfrage abgelehnt wird — und der es niemandem meldet, weil er annimmt, er selbst habe etwas falsch gemacht.
Dazu kommt die Frage, was das Frontend über das Backend wissen darf. Der Starter soll beide Provider bedienen und den Wechsel zwischen ihnen zu einer Konfigurationsänderung machen.
Entscheidung
Der Vertrag steht als Zod-Schema in src/lib/forms/contract.ts. Daraus entstehen die TypeScript-Typen des Clients und — über z.toJSONSchema() — die sprachneutrale Fassung unter contract/form-submission.schema.json, die die Backends lesen. pnpm forms:contract schreibt sie neu; ein Vitest-File-Snapshot bricht ab, sobald die eingecheckte Datei veraltet ist.
Die Fehlercodes sind eine eigene Zusage. src/lib/forms/errors.ts legt sieben Servercodes mit je einem HTTP-Status fest, dazu zwei Codes, die ausschließlich im Browser entstehen (network_error, unexpected_response) und die ein Server deshalb nicht senden darf.
Der Client kennt genau eine Funktion. submitForm({ formId, values, context, security }) liest Provider und Endpunkt aus der Konfiguration. Der Unterschied zwischen PHP und Joomla ist auf Adresse, Kopfzeilen und CORS-Modus zusammengeschrumpft (providers.ts).
Die Feldliste ist eine Allowlist, kein Render-Schema. src/content/forms.ts nennt je Formular ID, schemaVersion und die Felder mit Typ, Pflicht und Grenzen. Beschriftungen, Reihenfolge und Raster stehen dort nicht — die schreibt die Seite von Hand mit den Primitives aus src/components/ui/.
Begründung
Warum Zod und nicht handgeschriebenes JSON Schema. Die Frage ist nicht, welches Format mächtiger ist, sondern welches sich in diesem Repository nicht ändern lässt, ohne dass es auffällt. Zod ist bereits die Quelle der Projektkonfiguration und der Content Collections; ein zweiter Mechanismus für denselben Zweck wäre eine Stelle mehr zum Lernen. Die Typen entstehen ohne Generierungsschritt, und der Generierungsschritt in die andere Richtung ist ein Testlauf.
Handgeschriebenes JSON Schema wäre sprachneutral von Anfang an — und würde einen Generierungsschritt für die TypeScript-Typen brauchen, den man vergessen kann. Es verlagert das Driftproblem nur.
Warum nicht OpenAPI 3.1. Es beschriebe zusätzlich Endpunkt, Methode und Statuscodes, und für die Joomla-Erweiterung wäre das reizvoll. Für genau einen POST-Endpunkt ist es aber ein erheblicher Apparat, und die Statuscodes sind mit x-errorCodes im Artefakt genauso festgehalten. Sollte die Companion-Erweiterung später mehr Endpunkte bekommen, lässt sich OpenAPI aus demselben Zod-Schema erzeugen — die Entscheidung ist nicht verbaut.
Warum fields enger ist als im ursprünglichen Entwurf. Dort stand Record<string, unknown>. Aus einem HTML-Formular kommt aber immer eine Zeichenkette, ein Wahrheitswert oder eine Liste von Zeichenketten. unknown zuzulassen gäbe dem Backend die Aufgabe, verschachtelte Strukturen zu prüfen, die nie entstehen — und dem JSON Schema keine verwertbare Aussage.
Warum die Einwilligung nicht unter den Feldern steht. Sie ist keine Angabe zur Anfrage, sondern die Grundlage ihrer Verarbeitung, und sie trägt einen Zeitstempel. Das Ankreuzfeld steht mit consent: 'privacy' in der Allowlist — die Laufzeit verschiebt den Wert beim Bauen des Requests in den Bereich consent. Beides mitzuschicken hieße, zwei Angaben über dieselbe Sache zu führen, die auseinanderlaufen können.
Warum kein Formularbuilder. Ein Renderer, der Felder aus einer Datenstruktur erzeugt, ist nach kurzer Zeit ein eigenes System mit eigener Layoutsprache — und das erste Kundenprojekt mit einem zweispaltigen Formular bringt es an die Grenze. Geteilt wird nur, was das Backend tatsächlich braucht. Dass Markup und Allowlist zusammenpassen, prüft tests/dist/forms.test.ts am ausgelieferten HTML.
Warum 400 und 422 getrennt sind. 400 heißt: Der Request war nicht der vereinbarte — daran kann der Absender nichts ändern, das ist ein Fehler im Frontend. 422 heißt: Der Request war der vereinbarte, aber die Eingaben halten die Fachregeln nicht ein. Beides auf 400 zu legen nimmt dem Client die Möglichkeit, das zu unterscheiden, und führt zu Formularen, die zum Nachbessern auffordern, wo nichts nachzubessern ist.
Verworfene Alternativen
Je eine Beschreibung im Frontend und im Backend. Der Ausgangspunkt dieser Entscheidung.
Der Client kennt den Provider. Wäre einfacher zu schreiben und machte den Providerwechsel zu einer Änderung an jeder Formularseite. Genau das soll er nicht sein.
Validierung nur im Backend. Wäre die ehrlichere Aufgabenteilung — die Prüfung im Browser ist umgehbar und damit keine Zusage. Sie bleibt trotzdem, weil ein Formular, das für jeden Tippfehler einen Netzwerkumlauf braucht, unangenehm zu bedienen ist. Beide Prüfungen laufen, und die verbindliche ist die im Backend.
Kein Zod im Browser. Der Formular-Chunk misst dadurch rund 23 KB (brotli). Der Preis ist bekannt und bewusst bezahlt: Die Antwort des Backends ist ungeprüfte Eingabe, und ihre Deutung entscheidet, ob jemand seine Anfrage für gestellt hält. Der Chunk wird dynamisch geladen und nur auf Seiten mit Formular.
Konsequenzen
contract/form-submission.schema.jsonliegt im Repository und steht in.prettierignore— die Formatierung stammt vom Generator. Eine Vertragsänderung wird dadurch im Pull Request als Diff sichtbar; das ist der Zweck.- Eine Änderung an
fieldseines Formulars verlangt,schemaVersionzu erhöhen. Sie geht mit jeder Übermittlung hinaus, damit ein Backend eine veraltete Seite aus dem Browser-Cache erkennen kann. LIMITSsteht insrc/lib/forms/limits.tsund nicht incontract.ts. Der Grund ist das Bündel: Die Kampagnenerfassung läuft auf jeder Seite und darf Zod nicht nachziehen.- Die Kampagnenzuordnung über Seitenwechsel hinweg ist ein Speicherzugriff zu Marketingzwecken. Sie steht deshalb als Verbraucher in
consentConsumers()und erscheint im Einstellungsdialog — auch in einem Projekt ganz ohne Tracking. Die Bannertexte unterscheiden dafür seit M6, was hinter der Kategorie steht: Ohne Tag Manager wird dort nichts über Google behauptet. - Ein Formular ohne konfiguriertes Backend rendert nicht.
pages/kontakt.astrozeigt in dem Fall die direkten Kontaktwege — eine leere Kontaktseite wäre schlimmer als keine. - Playwright hat ein drittes Projekt
forms. Es fährt wieconsentdie Fixture „full", weil nur dort ein Endpunkt konfiguriert ist; die Antworten kommen auspage.route(), solange es den Handler aus M7 nicht gibt.