From 24ab529f33f46cd4e201a7b155093e1867bba2b7 Mon Sep 17 00:00:00 2001 From: Headgent Development Date: Thu, 16 Jul 2026 22:31:00 +0200 Subject: [PATCH] feat(kernel): 405 status, envelope contract doc, consistency check Adds MethodNotAllowed=405 to ResponseStatus (P0 boundary-error contract), a transport-neutral docs/response-envelope.md spec (envelope shape, status ladder, 422 rule-payload, object-coercion + 204 exceptions, RFC 7231 Allow format), and an automated bin/check-envelope-consistency.php drift check wired into make + CI so the doc table and the enum can never silently diverge. --- .github/workflows/ci.yml | 37 +++++++ bin/check-envelope-consistency.php | 137 +++++++++++++++++++++++++ docs/response-envelope.md | 158 +++++++++++++++++++++++++++++ src/Kernel/ResponseStatus.php | 1 + support/makefile/qa-port.mk | 4 + 5 files changed, 337 insertions(+) create mode 100644 bin/check-envelope-consistency.php create mode 100644 docs/response-envelope.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef8a1c0..772f2b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,8 @@ on: branches: [main] paths: - 'src/**' + - 'bin/**' + - 'docs/**' - 'composer.json' - '.github/workflows/**' - 'Makefile' @@ -16,6 +18,8 @@ on: branches: [main, develop] paths: - 'src/**' + - 'bin/**' + - 'docs/**' - 'composer.json' - '.github/workflows/**' - 'Makefile' @@ -91,3 +95,36 @@ jobs: env: DOCKER_CONFIG: /tmp/.docker run: docker logout + + envelope-consistency: + name: envelope-consistency + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Setup environment + run: cp .env.example .env + + - name: Login to Docker Hub + if: ${{ github.actor != 'dependabot[bot]' }} + env: + DOCKER_CONFIG: /tmp/.docker + run: | + mkdir -p $DOCKER_CONFIG + echo "${{ secrets.DOCKER_PAT }}" | docker login -u "${{ secrets.DOCKER_USER_NAME }}" --password-stdin + + - name: Install dependencies + run: make install + + - name: Check docs/response-envelope.md matches ResponseStatus enum cases + run: make check-envelope-consistency + + - name: Cleanup + if: always() + run: make remove + + - name: Logout from Docker Hub + if: always() + env: + DOCKER_CONFIG: /tmp/.docker + run: docker logout diff --git a/bin/check-envelope-consistency.php b/bin/check-envelope-consistency.php new file mode 100644 index 0000000..a976dbe --- /dev/null +++ b/bin/check-envelope-consistency.php @@ -0,0 +1,137 @@ + case name => HTTP-code value, as declared on the enum. + */ +function enumCases(): array +{ + $result = []; + foreach (ResponseStatus::cases() as $case) { + $result[$case->name] = $case->value; + } + return $result; +} + +/** + * @return array case name => HTTP-code value, as documented in the + * "## Status ladder" table of docs/response-envelope.md. + * + * @throws RuntimeException when the doc file is unreadable or the section is missing. + */ +function documentedCases(string $docPath): array +{ + $contents = file_get_contents($docPath); + if ($contents === false) { + throw new RuntimeException("cannot read {$docPath}"); + } + + $section = extractStatusLadderSection($contents); + + $result = []; + foreach (explode("\n", $section) as $line) { + if (preg_match('/^\|\s*`([A-Za-z0-9]+)`\s*\|\s*(\d{3})\s*\|/', $line, $matches) === 1) { + $result[$matches[1]] = (int) $matches[2]; + } + } + return $result; +} + +/** + * @throws RuntimeException when the "## Status ladder" heading is missing. + */ +function extractStatusLadderSection(string $contents): string +{ + $headingPos = strpos($contents, '## Status ladder'); + if ($headingPos === false) { + throw new RuntimeException("'## Status ladder' heading not found in doc"); + } + + $rest = substr($contents, $headingPos + strlen('## Status ladder')); + $nextHeadingPos = strpos($rest, "\n## "); + return $nextHeadingPos === false ? $rest : substr($rest, 0, $nextHeadingPos); +} + +/** + * @param array $fromEnum + * @param array $fromDoc + * @return list human-readable mismatch descriptions, empty when consistent. + */ +function diff(array $fromEnum, array $fromDoc): array +{ + $problems = []; + + $missingInDoc = array_diff_key($fromEnum, $fromDoc); + if ($missingInDoc !== []) { + $problems[] = 'Cases missing from the doc table: ' . implode(', ', array_keys($missingInDoc)); + } + + $missingInEnum = array_diff_key($fromDoc, $fromEnum); + if ($missingInEnum !== []) { + $problems[] = 'Doc rows without a matching enum case: ' . implode(', ', array_keys($missingInEnum)); + } + + $valueMismatches = []; + foreach (array_intersect_key($fromEnum, $fromDoc) as $name => $value) { + if ($fromDoc[$name] !== $value) { + $valueMismatches[] = sprintf('%s: enum=%d doc=%d', $name, $value, $fromDoc[$name]); + } + } + if ($valueMismatches !== []) { + $problems[] = 'HTTP-code mismatches: ' . implode('; ', $valueMismatches); + } + + return $problems; +} + +$docPath = dirname(__DIR__) . '/docs/response-envelope.md'; + +try { + $fromEnum = enumCases(); + $fromDoc = documentedCases($docPath); +} catch (RuntimeException $exception) { + fwrite(STDERR, "[envelope-consistency] FAILED: {$exception->getMessage()}\n"); + exit(1); +} + +$problems = diff($fromEnum, $fromDoc); + +if ($problems !== []) { + fwrite(STDERR, "[envelope-consistency] FAILED: ResponseStatus enum and docs/response-envelope.md disagree.\n"); + foreach ($problems as $problem) { + fwrite(STDERR, " {$problem}\n"); + } + exit(1); +} + +fwrite(STDOUT, sprintf( + "[envelope-consistency] OK: %d ResponseStatus cases match docs/response-envelope.md.\n", + count($fromEnum) +)); diff --git a/docs/response-envelope.md b/docs/response-envelope.md new file mode 100644 index 0000000..b74dc2a --- /dev/null +++ b/docs/response-envelope.md @@ -0,0 +1,158 @@ +# DomainResponse Envelope Contract + +Transport-neutral specification of the JSON envelope every Jardis domain +response is mapped into. It is fixed here, in `jardissupport/contract`, so +that **any** transport layer — the reference `jardiscore/app` HTTP mapper, +a hand-rolled CLI/worker adapter, or a third-party framework integration — +can implement the same client-facing contract without importing +`jardiscore/app`. One error/response format for every client, independent of +which package produced it. + +The status vocabulary (`ResponseStatus`, see below) already lives in this +package. This document adds the envelope shape around it: the JSON body +structure, the status-code ladder, the `422` rule-violation payload, and two +documented exceptions (object coercion, the `204` no-body case). + +--- + +## Envelope shape + +Every response — success or error, domain-produced or boundary-produced — +is serialized as a single JSON object with exactly these four top-level +keys: + +```json +{ + "status": 200, + "data": {}, + "errors": {}, + "meta": {} +} +``` + +| Key | Type | Source | +|----------|-------------------|-----------------------------------------------------------| +| `status` | `int` | `ResponseStatus` case value (`getStatus()`) | +| `data` | `object` | `DomainResponseInterface::getData()` | +| `errors` | `object` | `DomainResponseInterface::getErrors()` | +| `meta` | `object` | `DomainResponseInterface::getMetadata()` | + +`data`, `errors` and `meta` are always JSON **objects**, never arrays — see +the Object-Coercion Rule below. + +--- + +## Status ladder + +`JardisSupport\Contract\Kernel\ResponseStatus` is the single source of truth +for the status vocabulary. Every case below MUST be representable in the +envelope; a consistency check (see the package's `make` targets) keeps this +table and the enum in sync. + +| Case | HTTP code | Meaning | +|--------------------|-----------|--------------------------------------------------------------------------| +| `Success` | 200 | Successful read or command, no resource created | +| `Created` | 201 | Command created a new resource | +| `NoContent` | 204 | Successful, no response body (see the 204 Exception below) | +| `ValidationError` | 400 | Malformed or invalid request (e.g. syntactically invalid JSON body) | +| `Unauthorized` | 401 | Missing or invalid authentication | +| `Forbidden` | 403 | Authenticated, but not permitted | +| `NotFound` | 404 | No matching route or resource — also the boundary "no route" case | +| `MethodNotAllowed` | 405 | Route exists, but not for the requested HTTP method (see Allow-Header) | +| `Conflict` | 409 | Conflicting state (e.g. version/uniqueness conflict) | +| `RuleViolation` | 422 | A guarded business Rule rejected the command (see 422 Payload below) | +| `InternalError` | 500 | Unexpected failure; response body is generic, see Boundary Errors below | + +--- + +## 422 payload: `{rule, messageKey, context}` + +When `status` is `RuleViolation` (422), `data` carries the rejection payload +unchanged, with exactly these three keys: + +```json +{ + "status": 422, + "data": { + "rule": "OrderMustNotBeShipped", + "messageKey": "order.already_shipped", + "context": { "orderId": "42" } + }, + "errors": {}, + "meta": {} +} +``` + +| Field | Meaning | +|--------------|----------------------------------------------------------------------------| +| `rule` | Identifier of the Rule class that rejected the command | +| `messageKey` | Stable, translatable message key — part of the API, stable across Rule versions | +| `context` | Structured detail data for the rejection (arbitrary JSON-serializable object) | + +A technical failure while evaluating a Rule (e.g. a repository read fails) +is **not** a `RuleViolation` — it surfaces as `InternalError` (500), never +422. + +--- + +## Object-Coercion Rule + +`DomainResponseInterface::getData()` / `getErrors()` / `getMetadata()` are +typed as PHP `array`. An empty PHP array (`[]`) is ambiguous in JSON — it +serializes as a JSON array, not an object. Every transport-layer mapper +implementing this contract MUST explicitly coerce an empty `data`/`errors`/ +`meta` array to a JSON **object** (`{}`), never leave it as `[]`. Non-empty +associative arrays already serialize as JSON objects and need no coercion. + +--- + +## 204 exception: no body + +`NoContent` (204) responses carry **no response body at all** — this is an +HTTP-spec requirement (a 204 response must not have a message body), not a +contract-schema choice. This is the one documented exception to the envelope +shape above: a 204 response is not `{"status":204,"data":{},...}`, it is an +empty body with the 204 status line. + +--- + +## Allow-header format (405) + +A `MethodNotAllowed` (405) response additionally carries an `Allow` HTTP +header listing every HTTP method registered for the matched route, per +RFC 7231 §7.4.1: a single header value, methods separated by `, ` (comma + +space), e.g.: + +``` +Allow: GET, POST, PATCH +``` + +--- + +## Boundary errors (404 / 405) use the same envelope + +`404` (no matching route) and `405` (method not allowed) can occur **before** +any domain code runs — no `BoundedContext`/`Context` was ever invoked. These +boundary errors are not exempt from this contract: they answer in the exact +same JSON envelope schema as a domain-produced error, so a client never has +to distinguish "domain said 404" from "no route matched" by response shape. +The only addition for 405 is the `Allow` header above. + +`500` (an exception escaping before a domain result was produced) follows +the same rule: same envelope shape, generic `data`/`errors` content — no +exception message, stack trace, or class name leaks into the response body. +Full error detail belongs in the server-side log, not the client response. + +--- + +## Non-goals of this document + +This document fixes the **client-facing contract**: envelope shape, status +ladder, the two documented exceptions, and the 405 `Allow` format. It does +not prescribe: + +- how a transport layer obtains a `DomainResponseInterface` (that is + `jardiscore/kernel` + generated domain code), +- how routing, middleware, or bootstrap work (transport-layer concern), +- a specific implementation — `jardiscore/app` is the reference mapper for + this contract, not a requirement to depend on it. diff --git a/src/Kernel/ResponseStatus.php b/src/Kernel/ResponseStatus.php index 075d0be..c92a3e5 100644 --- a/src/Kernel/ResponseStatus.php +++ b/src/Kernel/ResponseStatus.php @@ -23,6 +23,7 @@ enum ResponseStatus: int case Unauthorized = 401; case Forbidden = 403; case NotFound = 404; + case MethodNotAllowed = 405; case Conflict = 409; case RuleViolation = 422; case InternalError = 500; diff --git a/support/makefile/qa-port.mk b/support/makefile/qa-port.mk index e218a76..788fe2d 100644 --- a/support/makefile/qa-port.mk +++ b/support/makefile/qa-port.mk @@ -6,3 +6,7 @@ phpstan: ## Run PHPStan analysis phpcs: ## Run coding standards $(DOCKER_COMPOSE) run --rm --no-deps phpcli vendor/bin/phpcs /app/src .PHONY: phpcs + +check-envelope-consistency: ## Check docs/response-envelope.md status ladder matches ResponseStatus enum cases + $(DOCKER_COMPOSE) run --rm --no-deps phpcli php /app/bin/check-envelope-consistency.php +.PHONY: check-envelope-consistency