Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ on:
branches: [main]
paths:
- 'src/**'
- 'bin/**'
- 'docs/**'
- 'composer.json'
- '.github/workflows/**'
- 'Makefile'
Expand All @@ -16,6 +18,8 @@ on:
branches: [main, develop]
paths:
- 'src/**'
- 'bin/**'
- 'docs/**'
- 'composer.json'
- '.github/workflows/**'
- 'Makefile'
Expand Down Expand Up @@ -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
137 changes: 137 additions & 0 deletions bin/check-envelope-consistency.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php

declare(strict_types=1);

namespace JardisSupport\Contract\Bin;

/**
* Checks that the status ladder documented in docs/response-envelope.md
* matches the actual `ResponseStatus` enum cases 1:1 (same case names, same
* HTTP-code values, no case missing on either side).
*
* The contract package intentionally carries no implementation code in
* `src/` (interfaces, enums, value objects and exceptions only — see
* README.md "Design Principles"), so this drift check lives here as a
* self-contained script under `bin/`, not as a Handler class in `src/`.
* Namespaced (rather than global functions) to avoid polluting the global
* namespace from a script that is never autoloaded.
*
* Exits 0 and prints an OK line when the enum and the doc table agree.
* Exits 1 and prints every mismatch when they don't.
*
* Usage:
* php bin/check-envelope-consistency.php
*/

require __DIR__ . '/../vendor/autoload.php';

use JardisSupport\Contract\Kernel\ResponseStatus;
use RuntimeException;

/**
* @return array<string, int> 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<string, int> 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<string, int> $fromEnum
* @param array<string, int> $fromDoc
* @return list<string> 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)
));
158 changes: 158 additions & 0 deletions docs/response-envelope.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/Kernel/ResponseStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions support/makefile/qa-port.mk
Original file line number Diff line number Diff line change
Expand Up @@ -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