From 53fe4d25a7f0a1005fdadc9a6941c8222f907153 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 17 Apr 2026 22:55:44 +0200 Subject: [PATCH] feat(form-builder): standardised error envelope for public form API (D6) S2c D6. Seven concrete exceptions over a shared PublicFormApiException base + a single renderer in bootstrap/app.php produce the contract: { "message": "...", "code": "...", "errors"?: {...} } Codes: SCHEMA_NOT_FOUND (404), TOKEN_EXPIRED (410), TOKEN_REVOKED (410), SCHEMA_UNPUBLISHED (410), SUBMISSION_ALREADY_SUBMITTED (409), RATE_LIMITED (429 with Retry-After header), VALIDATION_FAILED (422 with per-field errors). Used by PublicFormController (resolve) and PublicFormSubmissionController (load/submit lifecycle). Every public-form endpoint now emits the same envelope regardless of which branch failed; the renderer only fires on PublicFormApiException so the authenticated API still uses its default Laravel shapes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../FormBuilder/FieldValidationException.php | 23 +++++++++++++ .../FormBuilder/PublicFormApiException.php | 33 +++++++++++++++++++ .../FormBuilder/RateLimitedException.php | 14 ++++++++ .../FormBuilder/SchemaNotFoundException.php | 13 ++++++++ .../SchemaUnpublishedException.php | 13 ++++++++ .../SubmissionAlreadySubmittedException.php | 17 ++++++++++ .../FormBuilder/TokenExpiredException.php | 13 ++++++++ .../FormBuilder/TokenRevokedException.php | 13 ++++++++ api/bootstrap/app.php | 13 ++++++++ 9 files changed, 152 insertions(+) create mode 100644 api/app/Exceptions/FormBuilder/FieldValidationException.php create mode 100644 api/app/Exceptions/FormBuilder/PublicFormApiException.php create mode 100644 api/app/Exceptions/FormBuilder/RateLimitedException.php create mode 100644 api/app/Exceptions/FormBuilder/SchemaNotFoundException.php create mode 100644 api/app/Exceptions/FormBuilder/SchemaUnpublishedException.php create mode 100644 api/app/Exceptions/FormBuilder/SubmissionAlreadySubmittedException.php create mode 100644 api/app/Exceptions/FormBuilder/TokenExpiredException.php create mode 100644 api/app/Exceptions/FormBuilder/TokenRevokedException.php diff --git a/api/app/Exceptions/FormBuilder/FieldValidationException.php b/api/app/Exceptions/FormBuilder/FieldValidationException.php new file mode 100644 index 00000000..a0125506 --- /dev/null +++ b/api/app/Exceptions/FormBuilder/FieldValidationException.php @@ -0,0 +1,23 @@ +> $fieldErrors + */ + public function __construct(array $fieldErrors, string $message = 'The submitted values failed validation.') + { + parent::__construct('VALIDATION_FAILED', 422, $message); + $this->fieldErrors = $fieldErrors; + } +} diff --git a/api/app/Exceptions/FormBuilder/PublicFormApiException.php b/api/app/Exceptions/FormBuilder/PublicFormApiException.php new file mode 100644 index 00000000..45e59605 --- /dev/null +++ b/api/app/Exceptions/FormBuilder/PublicFormApiException.php @@ -0,0 +1,33 @@ +>|null */ + public ?array $fieldErrors = null; + + /** @var array */ + public array $headers = []; + + public function __construct( + public readonly string $publicCode, + public readonly int $status, + string $message, + ) { + parent::__construct($message); + } +} diff --git a/api/app/Exceptions/FormBuilder/RateLimitedException.php b/api/app/Exceptions/FormBuilder/RateLimitedException.php new file mode 100644 index 00000000..9d0bf025 --- /dev/null +++ b/api/app/Exceptions/FormBuilder/RateLimitedException.php @@ -0,0 +1,14 @@ +headers['Retry-After'] = (string) max($retryAfterSeconds, 0); + } +} diff --git a/api/app/Exceptions/FormBuilder/SchemaNotFoundException.php b/api/app/Exceptions/FormBuilder/SchemaNotFoundException.php new file mode 100644 index 00000000..31e669be --- /dev/null +++ b/api/app/Exceptions/FormBuilder/SchemaNotFoundException.php @@ -0,0 +1,13 @@ +withExceptions(function (Exceptions $exceptions): void { + // Public Form Builder standardised error envelope (S2c D6). + $exceptions->render(function (\App\Exceptions\FormBuilder\PublicFormApiException $e, Request $request) { + $body = [ + 'message' => $e->getMessage(), + 'code' => $e->publicCode, + ]; + if ($e->fieldErrors !== null) { + $body['errors'] = $e->fieldErrors; + } + + return response()->json($body, $e->status, $e->headers); + }); + // Database connection / query errors → 503 $exceptions->render(function (QueryException|PDOException $e, Request $request) { if ($request->expectsJson() || $request->is('api/*')) {