Skip to content

feat(Http): CRUD scaffolding via Resource handlers (theme 1.6, core primitive)#69

Merged
davidwyly merged 8 commits into
masterfrom
feat/crud-resource-handlers
May 4, 2026
Merged

feat(Http): CRUD scaffolding via Resource handlers (theme 1.6, core primitive)#69
davidwyly merged 8 commits into
masterfrom
feat/crud-resource-handlers

Conversation

@davidwyly

@davidwyly davidwyly commented May 4, 2026

Copy link
Copy Markdown
Owner

Summary

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, missing-row → 404, deleted → 204.

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

After registration the router carries:

Verb Path Handler call Result
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 (empty body) / 404

Middleware composition

The returned ResourceRoutes exposes the five Route handles so callers can attach middleware in three shapes:

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

// Resource-wide — chainable.
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);
```

register() accepts either a Router or a RouteGroup as the first argument.

Design

Concern Choice
Schema source DTOs (typed wire). Future codegen step (bin/rxn scaffold:from-table) writes them from information_schema — schema-as-truth without runtime DB-at-boot coupling
Storage Pluggable. Handler is a 5-method interface. RxnOrmCrudHandler base for relational, ~50 LOC for any other backend
Routing Explicit ResourceRegistrar::register() — no magic URL convention. Routes are visible in the Router after registration
idType 'int' by default (URL placeholder + cast). 'uuid' / 'slug' / 'any' / custom keeps the captured string
204 vs envelope delete returns a true PSR-7 204 with empty body (per HTTP spec) — registrar bypasses the array envelope mapper for that one case
Pagination Http\Middleware\Pagination's concern, not the handler's. Stack it on the search route via \$routes->search->middleware(...) for X-Total-Count + RFC 8288 Link headers
OpenAPI Falls out for free — DTOs already drive the generator, CRUD operations just appear in the spec

What's NOT in this PR (deliberate scope cut, see horizons 1.6 follow-ups)

  • RxnOrmCrudHandler abstract base — lives in 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. Follow-up.
  • #[Resource] attribute — alternative to explicit registration; would let Scanner discover resources at scan time. Useful but out-of-scope for the primitive.

Tests

19 integration tests against an in-memory fixture (Tests/Http/Resource/):

  • Registration shape — 5 routes registered with the documented HTTP methods + paths
  • idType=int rejects non-integer ids at the route level
  • Create round-trips through Binder to handler; bad body → 422 with errors[]
  • Read 200 / 404 (handler returns null → 404 Problem Details)
  • Update partial-field merge; 404 on missing; 422 on validation failure
  • Delete 204 with truly empty body; 404 on missing
  • Search 200 with full list; filter from query string; 422 on bad query
  • Null search DTO → handler called with null filter
  • idType='uuid' produces string IDs at the handler
  • register() returns a ResourceRoutes value object (4 new tests)
  • ResourceRoutes::middleware() applies to all five routes
  • Per-op middleware composition through public Route fields
  • RouteGroup registration inherits prefix + group middleware

Files

```
src/Rxn/Framework/Http/Resource/
├── CrudHandler.php 5-method interface
├── ResourceRegistrar.php register() static + DTO binding + status-code mapping
└── ResourceRoutes.php value object holding 5 Route handles + middleware() chainer

src/Rxn/Framework/Tests/Http/Resource/
├── ResourceRegistrarTest.php
└── Fixture/
├── CreateWidget.php DTO with #[Required], #[Length], #[Min], #[InSet]
├── UpdateWidget.php all-nullable DTO for partial updates
├── SearchWidgets.php filter DTO with optional fields
├── InMemoryWidgetCrud.php test-only handler implementation
└── TagMiddleware.php identity-checkable middleware for assertions

docs/horizons.md theme 1.6 marked REALIZED (core)
docs/routing.md new "CRUD resources — ResourceRegistrar" section + "Composing middleware" section; scaffold:from-table noted as future
README.md novelty bullet
CHANGELOG.md full Unreleased entry
```

Test plan

  • `vendor/bin/phpunit --testsuite=framework` — 661 / 1474 green (was 642 / 1391; added 19)
  • `php -l` clean across all new files
  • `composer validate --strict` clean
  • No regressions in existing Router / Binder / OpenAPI tests

…rimitive)

Recovers the convention router's "extend a class, get five
endpoints" ergonomic without legacy AR baggage.

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

After registration the router carries the documented five-route
family with DTO binding + validation + status-code mapping
already wired:

  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 (empty body) / 404

Storage-agnostic: handler implements 5 methods over CrudHandler
interface. Apps using davidwyly/rxn-orm will get a base class
that reduces a relational handler to "extend a class, set TABLE
constant, done"; that ships in the rxn-orm repo as a follow-up.
Apps using Doctrine / raw PDO / remote APIs write their own ~50
LOC handler.

idType arg controls both the URL constraint AND the handler's id
type: default 'int' (cast to int before the handler sees it),
'uuid' / 'slug' / 'any' / custom keep the captured string.

Validation failures surface as 422 application/problem+json with
errors[] (the framework's standard RFC 7807 shape). DELETE
returns a true PSR-7 204 with empty body, bypassing the array
envelope mapper since 204 must have no content per HTTP spec.

Added:
- Rxn\Framework\Http\Resource\CrudHandler interface (5 methods)
- Rxn\Framework\Http\Resource\ResourceRegistrar::register() static
- 15 integration tests against an in-memory fixture covering
  registration shape, idType=int reject, create round-trip, 422
  on bad body, read 200/404, update partial merge, update 404 /
  422, delete 204 (empty body) / 404, search 200 / list / filter
  from query / 422 on bad query, null-search-DTO calls handler
  with null filter, idType=uuid produces string IDs at the
  handler.

Out of scope (deliberate, follow-ups):
- RxnOrmCrudHandler base in davidwyly/rxn-orm
- bin/rxn scaffold:from-table codegen step
- #[Resource] attribute for Scanner-driven discovery

Doc updates:
- docs/horizons.md theme 1.6 marked REALIZED (core primitive),
  with explicit "what's left" pointing at the rxn-orm and
  scaffold codegen follow-ups.
- docs/routing.md: new "CRUD resources — ResourceRegistrar"
  section with the full route table + handler shape + idType.
- README.md: novelty bullet for CRUD scaffolding.

Suite 642 → 657 / 1391 → 1445.
Copilot AI review requested due to automatic review settings May 4, 2026 04:10

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8f2f225938

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Rxn/Framework/Http/Resource/ResourceRegistrar.php Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a core HTTP primitive for CRUD resource routing by introducing a CrudHandler interface plus a ResourceRegistrar::register() helper that registers a standard five-route CRUD URL family and wires DTO binding/validation + common status mapping. This fits into the framework’s routing + Binder stack, aiming to recover the “one registration yields 5 endpoints” ergonomics without coupling to a specific storage layer.

Changes:

  • Introduce Rxn\Framework\Http\Resource\CrudHandler and ResourceRegistrar to register POST/GET/GET{id}/PATCH{id}/DELETE{id} routes with Binder-driven DTO binding and status handling.
  • Add integration tests + DTO fixtures and an in-memory CRUD handler to verify route registration, id constraints, validation → 422, missing rows → 404, and delete → 204.
  • Update docs/README/CHANGELOG/horizons to document the new CRUD resource primitive.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/Rxn/Framework/Http/Resource/CrudHandler.php Defines the 5-method storage-agnostic CRUD handler contract.
src/Rxn/Framework/Http/Resource/ResourceRegistrar.php Registers the five CRUD routes and performs DTO binding + response/status mapping.
src/Rxn/Framework/Tests/Http/Resource/ResourceRegistrarTest.php Integration coverage for routing shape, binding/validation, and status behaviors.
src/Rxn/Framework/Tests/Http/Resource/Fixture/CreateWidget.php Create DTO fixture with validation attributes.
src/Rxn/Framework/Tests/Http/Resource/Fixture/UpdateWidget.php Patch/update DTO fixture (all-optional fields).
src/Rxn/Framework/Tests/Http/Resource/Fixture/SearchWidgets.php Search/filter DTO fixture for query binding.
src/Rxn/Framework/Tests/Http/Resource/Fixture/InMemoryWidgetCrud.php In-memory CrudHandler implementation for tests.
docs/routing.md Documents ResourceRegistrar usage and resulting routes.
docs/horizons.md Marks theme 1.6 CRUD scaffolding as realized via this primitive.
README.md Adds a novelty bullet and updates test counts.
CHANGELOG.md Adds an Unreleased entry describing the CRUD resource feature.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Rxn/Framework/Http/Resource/ResourceRegistrar.php
Comment thread src/Rxn/Framework/Http/Resource/ResourceRegistrar.php Outdated
Comment thread src/Rxn/Framework/Http/Resource/ResourceRegistrar.php Outdated
Comment thread docs/routing.md Outdated
- P1: middleware composition was impossible. register() took only
  Router and returned void, so callers couldn't:
    1. Register through a RouteGroup (where prefix + group
       middleware get applied to every route the group adds)
    2. Get Route handles back to attach middleware afterward
  Both are needed for protected CRUD APIs (auth on all five,
  stricter check on destructive ops only). API change:
    - First arg is now Router|RouteGroup (RouteGroup support
      gives group-prefix + group-middleware inheritance for
      free, since RouteGroup::add already applies them).
    - Return type is the new ResourceRoutes value object —
      five public readonly Route fields plus a chainable
      middleware() that fans the same stack across all five.
- Closures `(array $params)` (read, delete) → `(array $params,
  ServerRequestInterface $request)` for accurate signatures.
  Same false-alarm-but-clean-it-up pattern as the Version /
  HealthCheck fixes.
- Docstring on validation-failure shape said "returns array
  envelope (meta.status=422)"; implementation actually returns
  a PSR-7 Problem Details response directly. Updated docstring
  to match the implementation across create / update / search.
- docs/routing.md: bin/rxn scaffold:from-table is documented as
  a future codegen step, not as if it exists today. Marked
  explicitly under "horizons theme 1.6 follow-ups".
- docs/routing.md: new "Composing middleware" section covering
  the three composition shapes (group-based, resource-wide,
  per-op) so the API change is discoverable.

New ResourceRoutes value object lives at
Rxn\Framework\Http\Resource\ResourceRoutes — public readonly
fields (create / search / read / update / delete) plus
middleware(...) chainer + all() iterator helper.

4 new tests:
- testRegisterReturnsResourceRoutesValueObject — return type
- testMiddlewareAppliesToAllFiveRoutes — bag-level chainer
- testIndividualRouteCanCarryAdditionalMiddleware — public-field access
- testRegisterAcceptsRouteGroupAndInheritsPrefixPlusMiddleware
  — RouteGroup integration end-to-end

Plus one fixture (TagMiddleware) standing in for an identity-
checkable middleware so tests can assertContains() the exact
instance back from Router::match() output.

Suite 657 → 661 / 1445 → 1474.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Rxn/Framework/Http/Resource/ResourceRegistrar.php Outdated
Comment thread src/Rxn/Framework/Http/Resource/CrudHandler.php Outdated
Comment thread CHANGELOG.md Outdated
- ResourceRegistrar: remove unused `use Rxn\Framework\Http\Route`
  import. The registrar collects Route handles into ResourceRoutes
  but never references the class by name (the ResourceRoutes
  constructor takes them by position).
- CrudHandler docblock: drop the "implementation may surface
  pagination metadata in top-level meta" claim. The registrar
  wraps search results as `{data: [...]}` only; there's no meta
  slot at the response level. Pagination is the Pagination
  middleware's concern (X-Total-Count + RFC 8288 Link headers
  via `$routes->search->middleware(new Pagination(...))`),
  separate from the handler return shape.
- No CHANGELOG / count change in this commit; PR description
  updated separately to reflect the current 19-test / 661-1474
  totals.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Rxn/Framework/Http/Resource/ResourceRegistrar.php Outdated
Comment thread src/Rxn/Framework/Http/Resource/ResourceRegistrar.php
@davidwyly

Copy link
Copy Markdown
Owner Author

@copilot apply changes based on the comments in this thread

…istration time

- Normalize $path at top of register(): single leading slash, no trailing slash,
  so Router and RouteGroup behave identically regardless of caller conventions.
- Validate $create / $update / $search class-strings implement RequestDto via
  is_subclass_of() at registration time; throw InvalidArgumentException rather
  than deferring to a runtime 500 on first request.
- Add 4 new tests: path normalization (missing leading slash) + 3 invalid DTO
  class cases (create / update / search).

Agent-Logs-Url: https://github.com/davidwyly/rxn/sessions/7508d7fb-5f5e-4347-a0a3-7283f8670e3e

Co-authored-by: davidwyly <13552783+davidwyly@users.noreply.github.com>

Copilot AI commented May 4, 2026

Copy link
Copy Markdown
Contributor

@copilot apply changes based on the comments in this thread

Applied both suggestions from that review thread in commit 34baeee:

  1. Path normalization$path = '/' . ltrim(rtrim($path, '/'), '/'); at the top of register() ensures a single leading slash and no trailing slash, so Router and RouteGroup behave identically regardless of whether the caller passes 'widgets' or '/widgets'. Four new tests cover the slash-less case.

  2. Up-front DTO validation — iterates over [$create, $update, $search] (filtering nulls) and throws \InvalidArgumentException immediately at registration time if any class-string doesn't implement RequestDto. Three new tests cover each DTO parameter.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Rxn/Framework/Http/Resource/ResourceRegistrar.php
@davidwyly

Copy link
Copy Markdown
Owner Author

@copilot apply changes based on the comments in this thread

… via array_filter

array_filter without a callback drops falsy values including empty strings.
Validate $create and $update unconditionally; include $search only when non-null.
Add two tests for empty-string $create/$update to cover the gap.

Agent-Logs-Url: https://github.com/davidwyly/rxn/sessions/7e25d0c7-83ff-4554-b52e-d55d45c32056

Co-authored-by: davidwyly <13552783+davidwyly@users.noreply.github.com>

Copilot AI commented May 4, 2026

Copy link
Copy Markdown
Contributor

@copilot apply changes based on the comments in this thread

Fixed in commit 8794e83. $create and $update are now iterated unconditionally; $search is appended only when non-null. The array_filter idiom that silently dropped empty strings is gone. Two new tests cover the empty-string $create and $update cases.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread docs/routing.md
Comment thread src/Rxn/Framework/Http/Resource/ResourceRegistrar.php Outdated
Comment thread docs/horizons.md Outdated
@davidwyly

Copy link
Copy Markdown
Owner Author

@copilot apply changes based on the comments in this thread

- routing.md: reword POST/PATCH rows from "body bound from" to "DTO bound via
  Binder (query + body, body wins)" to match Binder::bindRequest() semantics
- ResourceRegistrar.php: same wording fix in create/update docblock entries
- horizons.md: fix integration test count 15 → 19

Agent-Logs-Url: https://github.com/davidwyly/rxn/sessions/662b9e64-5636-40f0-b46a-e4dfa5e9a0a0

Co-authored-by: davidwyly <13552783+davidwyly@users.noreply.github.com>

Copilot AI commented May 4, 2026

Copy link
Copy Markdown
Contributor

@copilot apply changes based on the comments in this thread

Applied all three in commit e4938a7:

  1. docs/routing.md — POST and PATCH rows: "body bound from" → "DTO bound via Binder (query + body, body wins) from".
  2. ResourceRegistrar.phpcreate and update docblock entries updated with the same Binder-accurate wording.
  3. docs/horizons.md — integration test count corrected from 15 → 19.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Rxn/Framework/Http/Resource/ResourceRegistrar.php Outdated
@davidwyly

Copy link
Copy Markdown
Owner Author

@copilot apply changes based on the comments in this thread

Replace Binder::bindRequest() with Binder::bind($search, $request->getQueryParams())
in the search handler so a GET request body cannot influence filter binding.
Aligns implementation with the $search docblock contract ("query shape for GET,
no body") and the routing docs.

All 25 ResourceRegistrar tests pass.

Agent-Logs-Url: https://github.com/davidwyly/rxn/sessions/014b0d35-e9c5-4758-8133-a6d689d9b18f

Co-authored-by: davidwyly <13552783+davidwyly@users.noreply.github.com>

Copilot AI commented May 4, 2026

Copy link
Copy Markdown
Contributor

@copilot apply changes based on the comments in this thread

Fixed in commit f1e8a45. The search handler now calls Binder::bind($search, $request->getQueryParams()) instead of Binder::bindRequest(), so GET search binding is strictly query-param-only and a request body cannot influence it.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Rxn/Framework/Http/Resource/ResourceRegistrar.php
Comment thread src/Rxn/Framework/Http/Resource/ResourceRegistrar.php Outdated
… tests

The Copilot SWE agent's pulled commits already addressed:
- path normalization for RouteGroup interop (PRRT_QQhf)
- DTO class-string validation at registration (PRRT_QQhn, PRRT_QWPm)
- doc wording: "query + body, body wins" not "request body" (PRRT_RNYs, PRRT_RNZB)
- horizons.md test count (PRRT_RNZN)
- search binds from query params only, not body (PRRT_YB-K)

Two threads still needed code work:

- PRRT_e-xI: existing DTO-class validation fell back to a single
  `is_subclass_of` check + a misleading "must implement
  RequestDto" message for typos / nonexistent class names.
  Three distinct failure modes now produce three distinct
  messages: empty string ("DTO class name cannot be empty"),
  nonexistent ("class '<name>' does not exist"), exists-but-
  doesn't-implement-RequestDto (existing message). Each says
  which arg ($create / $update / $search) for fast triage.
- PRRT_e-xo: $idType was interpolated directly into the route
  placeholder grammar. A label containing '-' or empty would
  surface as a generic "Malformed route placeholder" pointing
  at Router::compile, not the registrar. Now validated up
  front against [a-zA-Z_][a-zA-Z0-9_]* with a targeted message
  before interpolation.

Three new tests:
- testRegisterThrowsOnNonexistentDtoClass — exercises the new
  class_exists check; asserts the message says "does not exist"
  so operators know to fix the import vs the class hierarchy
- testRegisterThrowsOnIdTypeContainingInvalidCharacters — '-' rejected
- testRegisterThrowsOnEmptyIdType — empty rejected

CHANGELOG test count bumped 19 → 28 to reflect everything in
this PR (Copilot's added 6 path/DTO/idType tests + my 3 new
ones).

Suite 661 → 670 / 1474 → 1490.
@davidwyly davidwyly requested a review from Copilot May 4, 2026 22:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@davidwyly davidwyly merged commit 8d6b267 into master May 4, 2026
8 checks passed
@davidwyly davidwyly deleted the feat/crud-resource-handlers branch May 4, 2026 22:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants