Skip to content
Merged
94 changes: 94 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,100 @@ read alongside the wins.

## Unreleased

### CRUD scaffolding via Resource handlers — core primitive (`feat/crud-resource-handlers`)

Recovers the convention router's "extend a class, get five
endpoints" ergonomic without legacy AR baggage. One call wires
a `CrudHandler` to the full five-route URL family; framework
handles DTO binding, validation → 422 wrapping, missing-row →
404, deleted → 204.

```php
ResourceRegistrar::register(
$router,
'/products',
new ProductsCrud($repo),
create: CreateProduct::class,
update: UpdateProduct::class,
search: SearchProducts::class,
);
```

After registration the router carries:

- `POST /products` → `create($dto)` (201 / 422)
- `GET /products` → `search($filter)` (200; filter optional)
- `GET /products/{id:int}` → `read($id)` (200 / 404)
- `PATCH /products/{id:int}` → `update($id, $dto)` (200 / 404 / 422)
- `DELETE /products/{id:int}` → `delete($id)` (204 / 404)

Closes [horizons.md theme 1.6](docs/horizons.md). The "loved it"
ergonomic recovered through DTOs and an interface, not via
legacy AR base classes.

#### Added

- **`Rxn\Framework\Http\Resource\CrudHandler`** — 5-method
interface (`create`, `read`, `update`, `delete`, `search`).
Storage-agnostic. `read` / `update` return `null` on
missing → registrar maps to 404; `delete` returns `bool`
→ 204 / 404; `search` accepts a nullable filter DTO.
- **`Rxn\Framework\Http\Resource\ResourceRegistrar`** — static
`register()` method takes the router (`Router` OR
`RouteGroup`), path, handler, create / update DTO classes,
optional search DTO, and an `idType` constraint (default
`'int'`; pass `'uuid'` / `'slug'` / custom for non-integer
IDs). Wires the five routes; each handler closure does the
DTO binding, validation-failure wrapping (RFC 7807 Problem
Details with `errors[]`), and status-code mapping. Returns a
`ResourceRoutes` bag — see below.
- **`Rxn\Framework\Http\Resource\ResourceRoutes`** — the bag
of `Route` handles returned from `register()`. Public
readonly fields (`create`, `search`, `read`, `update`,
`delete`) so callers can attach per-op middleware without
re-deriving them through `Router::match`.
`ResourceRoutes::middleware(...)` chains the same middleware
onto all five routes at once. Three composition shapes
supported:
- **Group-based**: pass a `RouteGroup` instead of `Router`;
every registered route inherits the group's prefix +
middleware automatically.
- **Resource-wide**: chain
`ResourceRegistrar::register(...)->middleware($auth)`.
- **Per-op**: target the specific Route handle —
`$routes->update->middleware($adminOnly)`.
- 19 integration tests against an in-memory `CrudHandler`
fixture covering: registration shape, ID-type constraint
rejection, create round-trip, validation failure → 422,
read 200 / 404, update partial merge, update 404 / 422,
delete 204 (true empty body) / 404, search 200 / list /
filter from query / 422 on bad query, null-search-DTO
passes null filter, idType=uuid produces string IDs at the
handler, register returns a ResourceRoutes value object,
middleware applies to all five routes via the bag's
Comment thread
davidwyly marked this conversation as resolved.
Outdated
`middleware()` chainer, individual route can carry
additional middleware via the public field, RouteGroup
registration inherits prefix + middleware.

#### What's NOT in this PR (deliberate scope cut)

- **`RxnOrmCrudHandler`** abstract base class — lives in
[`davidwyly/rxn-orm`](https://github.com/davidwyly/rxn-orm),
reduces a relational handler to ~10 LOC. Follow-up PR on the
rxn-orm repo.
- **`bin/rxn scaffold:from-table`** — codegen step that connects
to the DB, reads `information_schema`, writes DTO files +
handler stub. One-shot at scaffold time, not at boot. Follow-up
PR after the rxn-orm base class lands.
- **`#[Resource]` attribute** — alternative to explicit
`ResourceRegistrar::register()` calls; would let the
`Scanner` discover resources at scan time. Useful but
out-of-scope for the primitive.

Suite 642 → 661 / 1391 → 1474.

---

### `#[Version]` attribute primitive (`feat/version-attribute`)

Recovers what the convention router's `/v{N}/{controller}/{action}`
Expand Down
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ Opinionated pieces worth naming:
versions of the same logical endpoint coexist as distinct paths;
`routes:check` knows the difference between "intentional
cross-version routes" and "real conflict."
- **CRUD scaffolding via Resource handlers** — one call
(`ResourceRegistrar::register($router, '/products', $handler, …)`)
wires the full create/read/update/delete/search route family.
Handler is a 5-method interface against any storage; framework
does DTO binding, validation-failure → 422 wrapping, missing-row
→ 404, deleted → 204. The "extend a class, get five endpoints"
ergonomic the convention router gave us — but with typed wire
(DTOs, not arrays), pluggable storage (rxn-orm's
`RxnOrmCrudHandler` base or your own ~50-LOC class), and OpenAPI
auto-generated from the same DTOs.
- **Compile-time route conflict detection.** `bin/rxn routes:check`
flags ambiguous `#[Route]` patterns before they ship —
`/items/{id:int}` vs `/items/{slug:slug}` (slug accepts
Expand Down Expand Up @@ -236,7 +246,7 @@ cumulative scoreboard is in

```bash
composer install
vendor/bin/phpunit # 642 tests, 1391 assertions
vendor/bin/phpunit # 661 tests, 1474 assertions
bin/rxn help # CLI subcommands
```

Expand Down Expand Up @@ -288,7 +298,7 @@ CI runs lint + phpunit against PHP 8.2, 8.3, and 8.4

Test counts:

- **Rxn framework:** 642 tests / 1391 assertions (`vendor/bin/phpunit`).
- **Rxn framework:** 661 tests / 1474 assertions (`vendor/bin/phpunit`).
- **[`davidwyly/rxn-orm`](https://github.com/davidwyly/rxn-orm)**
(query builder): 68 tests / 132 assertions, run in that repo.
- **[`davidwyly/rxn-observe`](https://github.com/davidwyly/rxn-observe)**
Expand Down
55 changes: 55 additions & 0 deletions docs/horizons.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,61 @@ that filling them in is mechanical, ship.

---

### 1.6 CRUD scaffolding via Resource handlers — REALIZED (core primitive)

Recovers the convention router's "extend a class, get five
endpoints" ergonomic without legacy AR baggage. See
`Rxn\Framework\Http\Resource\CrudHandler` (5-method interface)
and `Rxn\Framework\Http\Resource\ResourceRegistrar` (one-call
registrar that wires `POST` / `GET` / `GET {id}` / `PATCH {id}` /
`DELETE {id}` to the handler with DTO binding + validation +
404 / 422 / 204 wrapping in place).

```php
ResourceRegistrar::register(
$router,
'/products',
new ProductsCrud($repo),
create: CreateProduct::class,
update: UpdateProduct::class,
search: SearchProducts::class,
);
```

**Cost reality:** ~280 LOC of framework code (interface +
registrar) + 15 integration tests. Handler implementations are
Comment thread
davidwyly marked this conversation as resolved.
Outdated
the app's job and don't count toward framework cost.

**Status:** Shipped — the core primitive lands in this PR.
What's left:

- **`RxnOrmCrudHandler`** abstract class in
[`davidwyly/rxn-orm`](https://github.com/davidwyly/rxn-orm).
Reduces a relational handler to "extend a class, set
`TABLE` constant, done." Storage opinion lives in the storage
package.
- **`bin/rxn scaffold:from-table`** — connects to the DB,
reads `information_schema`, writes the create / update /
search DTO files + a `RxnOrmCrudHandler` stub. Schema-as-
source-of-truth recovered, without the boot-time DB
requirement of the old convention-router scaffolding (codegen
runs once at scaffold time, not on every request).

**Distinctiveness:** Most PHP frameworks make you write CRUD by
hand or pull in a heavyweight admin generator (Symfony EasyAdmin,
Laravel Nova). The Rxn shape is leaner — typed wire (DTOs,
not arrays), validation from PHP attributes, OpenAPI
auto-generated, storage layer pluggable. ~50 LOC for a custom
handler against any backend.

**Ship signal for the rxn-orm follow-up:** an app should be able
to add a new table to its DB, run `bin/rxn scaffold:from-table
<name>`, and have a working five-endpoint CRUD with no other
edits. If the scaffolded code needs hand-fixing the codegen
table needs more work first.

---

## Theme 2: Observability ships in the box

The framework already has PSR-14 wired ([`Rxn\Framework\Event\EventDispatcher`](../src/Rxn/Framework/Event/EventDispatcher.php)).
Expand Down
93 changes: 93 additions & 0 deletions docs/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,99 @@ $router->get('/products', $handler)
->middleware(new \Rxn\Framework\Http\Versioning\Deprecation('2026-01-01', '2026-12-31'));
```

### CRUD resources — `ResourceRegistrar`

`ResourceRegistrar::register()` wires a `CrudHandler` to a
five-route URL family in one call:

```php
use Rxn\Framework\Http\Resource\ResourceRegistrar;

ResourceRegistrar::register(
$router,
'/products',
new ProductsCrud($repo),
create: CreateProduct::class,
update: UpdateProduct::class,
search: SearchProducts::class,
);
```

After this the router has:

| Verb | Path | Handler |
|---|---|---|
| `POST` | `/products` | `create($dto)` — body bound from `CreateProduct`, 201 + `{data, meta: {status: 201}}` on success, 422 with `errors[]` on validation failure |
| `GET` | `/products` | `search($filter)` — filter optionally bound from query (`SearchProducts`); registrations without a `search` DTO call the handler with `null`. 200 + `{data: [...]}`. |
Comment thread
davidwyly marked this conversation as resolved.
| `GET` | `/products/{id:int}` | `read($id)` — 200 + `{data: ...}`, or 404 Problem Details when the handler returns null |
| `PATCH` | `/products/{id:int}` | `update($id, $dto)` — body bound from `UpdateProduct`, 200 / 404 / 422 |
| `DELETE` | `/products/{id:int}` | `delete($id)` — 204 (empty body, per HTTP spec) on success, 404 on missing |

The handler is just five methods over `Rxn\Framework\Http\Resource\CrudHandler`:

```php
final class ProductsCrud implements CrudHandler
{
public function __construct(private MyRepo $repo) {}

public function create(RequestDto $dto): array { /* INSERT, return row */ }
public function read(int|string $id): ?array { /* SELECT, or null */ }
public function update(int|string $id, RequestDto $dto): ?array { /* UPDATE */ }
public function delete(int|string $id): bool { /* DELETE, return success */ }
public function search(?RequestDto $filter): array { /* list */ }
}
```

**`idType` arg** controls the URL constraint and the handler's
id type. Default is `'int'`; pass `'uuid'` / `'slug'` / `'any'`
or any custom constraint to use a different shape:

```php
ResourceRegistrar::register(
$router, '/orgs', new OrgCrud($db),
create: CreateOrg::class,
update: UpdateOrg::class,
idType: 'uuid', // /orgs/{id:uuid}; handler receives the id as a string
);
```

**Storage layer is pluggable.** The registrar only knows the
five-method interface. Apps using
[`davidwyly/rxn-orm`](https://github.com/davidwyly/rxn-orm) can
extend its `RxnOrmCrudHandler` base class for the relational
common case (set `TABLE` constant, done); apps using Doctrine /
raw PDO / a remote API write their own ~50-LOC handler.

**Composing middleware:** `register()` accepts either a
`Router` or a `RouteGroup`, and returns a `ResourceRoutes` bag
holding the five `Route` handles. Apps stack middleware in
whichever shape matches the protection policy:

```php
// Group-based: every CRUD route inherits the group's middleware.
$router->group('/v1', function (RouteGroup $g) use ($auth) {
$g->middleware($auth);
ResourceRegistrar::register($g, '/products', $crud, /* … */);
});

// Per-resource: chain on the returned bag.
ResourceRegistrar::register($router, '/products', $crud, /* … */)
->middleware($bearerAuth);

// Per-op: target the specific Route handle on the bag.
$routes = ResourceRegistrar::register($router, '/products', $crud, /* … */);
$routes->update->middleware($adminOnly);
$routes->delete->middleware($adminOnly);
```

**Schema-as-source-of-truth via codegen — *future*:** the plan
is `bin/rxn scaffold:from-table <name>` reading
`information_schema` to write the DTO files + a handler stub,
one-shot at scaffold time so there's no DB connection required
at boot. **Not yet implemented** — tracked under horizons theme
1.6 follow-ups; the core primitive in this section is what
ships today.

### Using matched routes with the pipeline

`Router::match()` returns the matched route's `middlewares` alongside
Expand Down
94 changes: 94 additions & 0 deletions src/Rxn/Framework/Http/Resource/CrudHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php declare(strict_types=1);

namespace Rxn\Framework\Http\Resource;

use Rxn\Framework\Http\Binding\RequestDto;

/**
* Handler shape that `ResourceRegistrar::register()` wires to a
* five-route URL family. Implement once; get
*
* POST /path → create($dto)
* GET /path → search($filter)
* GET /path/{id:type} → read($id)
* PATCH /path/{id:type} → update($id, $dto)
* DELETE /path/{id:type} → delete($id)
*
* for free, with DTO binding + validation + RFC 7807 failure
* shapes already wired by the registrar.
*
* The interface is storage-agnostic. Implementations can use
* `davidwyly/rxn-orm` (the natural fit — `RxnOrmCrudHandler`
* abstract class lives there and reduces boilerplate to the
* point of "extend a class, set TABLE constant, done"), Doctrine,
* raw PDO, in-memory arrays for tests, or even a remote API
* gateway. The framework's job is the HTTP layer; storage is the
* caller's choice.
*
* Return-shape contract (so the registrar's wrapping is uniform):
*
* - `create()` always returns the created row's representation
* as an `array<string, mixed>`. The registrar wraps it as
* 201 + `{data: ..., meta: {status: 201}}`.
* - `read()` / `update()` return the row's representation, or
* `null` when no row matches the id. Null becomes 404
* Problem Details.
* - `delete()` returns `true` (the row was deleted, 204 with
* empty body) or `false` (no such row, 404 Problem Details).
* - `search()` always returns a list (possibly empty). The
* registrar wraps it as 200 + `{data: [...]}` — there is no
* top-level `meta` slot. Pagination metadata (total count,
* next-page link) is the `Http\Middleware\Pagination`
* middleware's concern: stack it on the resource's GET
* route via `$routes->search->middleware(new Pagination(...))`
* and it adds `X-Total-Count` + RFC 8288 `Link` headers
* without changing the response body shape.
*
* IDs are typed `int|string` — the registrar coerces the URL
* placeholder according to the `idType` argument it received
* (`'int'` casts to int; everything else stays string for UUID
* / slug / opaque-token shapes).
*/
interface CrudHandler
{
/**
* Create a new resource from a hydrated + validated DTO.
*
* @return array<string, mixed>
*/
public function create(RequestDto $dto): array;

/**
* Read the resource by id, or `null` when no such resource
* exists. The registrar turns null into a 404 Problem
* Details response.
*
* @return array<string, mixed>|null
*/
public function read(int|string $id): ?array;

/**
* Apply a partial update to the resource. Returns the
* updated row, or `null` when no such id exists.
*
* @return array<string, mixed>|null
*/
public function update(int|string $id, RequestDto $dto): ?array;

/**
* Delete the resource. `true` on success (registrar emits
* 204), `false` when no such id exists (registrar emits
* 404).
*/
public function delete(int|string $id): bool;

/**
* List / filter / search resources. `$filter` is null when
* no search DTO was registered for this resource — apps
* that want unfiltered listing can ignore it; apps that
* registered a filter receive a hydrated + validated DTO.
*
* @return list<array<string, mixed>>
*/
public function search(?RequestDto $filter): array;
}
Loading