Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,4 @@ Thumbs.db

# Quickstart example's file-backed JSON store (created at request time)
/var/
/.claude/
75 changes: 75 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,81 @@ read alongside the wins.

## Unreleased

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

Recovers what the convention router's `/v{N}/{controller}/{action}`
URL shape gave us — automatic URL-versioned routing — without the
convention router. Apps now declare versions via attribute:

```php
class ProductsController
{
#[Route('GET', '/products/{id:int}')]
#[Version('v1')]
public function showV1(int $id): array { /* … */ }

#[Route('GET', '/products/{id:int}')]
#[Version('v2')]
public function showV2(int $id): array { /* … */ }
}
```

The `Scanner` registers each at `/v1/products/{id:int}` and
`/v2/products/{id:int}`. Class-level `#[Version]` applies to
every route in the class; method-level wins when both are
present. `routes:check` sees `/v1/...` and `/v2/...` as
distinct paths and won't flag them as conflicts.
Comment thread
davidwyly marked this conversation as resolved.

#### Added

- **`Rxn\Framework\Http\Attribute\Version`** — value object
attribute. `version` (label, e.g. `'v1'` / `'1.0'` /
`'2025-10-15'`), optional `deprecatedAt` / `sunsetAt` (any
`DateTimeImmutable`-parseable date string).
- **`Rxn\Framework\Http\Versioning\Deprecation`** — PSR-15
middleware that emits RFC 8594 `Deprecation:` / `Sunset:`
headers. Both as IMF-fixdate (`Thu, 01 Jan 2026 00:00:00 GMT`).
Unparseable date inputs are silently dropped — the contract is
best-effort signalling, not "die on bad config."
- **Scanner integration** — when a route's effective `#[Version]`
carries `deprecatedAt` / `sunsetAt`, the Scanner auto-attaches
the `Deprecation` middleware to that route. Apps don't write
per-handler header boilerplate.
- **Path-prefix idempotence** — Scanner detects routes that
already start with the version prefix and doesn't double-prefix
them. So a hand-written `'/v1/old'` + `#[Version('v1')]` lands
at `/v1/old`, not `/v1/v1/old`.
- **`Version::applyTo()`** — single source of truth for "what URL
does this versioned route actually register at." Both the
Scanner and `Routing\ConflictDetector` call it, so the runtime
registration and the static-analysis detection stay in lockstep.
- **`Routing\ConflictDetector` honours `#[Version]`** — `collect()`
now applies the same prefix the Scanner would, so `routes:check`
treats `/v1/widgets/{id}` and `/v2/widgets/{id}` as distinct
paths (no false-positive conflict). Same-version same-pattern
is still flagged as the real ambiguity it is.

#### Tests

- 12 Scanner integration tests (method-level prefixes, class-level
applies, method overrides class, unversioned routes stay
unprefixed, no cross-version conflicts, deprecation middleware
attached when needed and not otherwise, RFC 8594 headers
formatted correctly, deprecation middleware decorates
short-circuit responses (auth 401 / rate-limit 429), version-
label tolerance for stray slashes, empty-version rejection,
path-prefix idempotence).
Comment thread
davidwyly marked this conversation as resolved.
Outdated
- 8 Deprecation middleware unit tests (bare ISO date, full ISO
with timezone, UTC conversion, null args, unparseable dates,
deprecation-only / sunset-only, terminal response preservation).
- 3 ConflictDetector tests covering the version-aware shape
(collect applies the version prefix, cross-version routes
aren't flagged, same-version same-pattern still flags).

Suite 618 → 641 / 1329 → 1389.

---

### Strip convention router + legacy AR layer (`chore/strip-convention-router`)

The convention router (`App::run()`, `Service\Api`, `Service\Stats`,
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ Opinionated pieces worth naming:
- **Typed route constraints** (`{id:int}`, `{slug:slug}`,
`{id:uuid}`, custom) so `/users/foo` falls through to 404
instead of reaching a controller that has to validate and throw.
- **API versioning as a primitive** — `#[Version('v1')]` on a
method (or class) prefixes the route's path; `#[Version('v1',
deprecatedAt: '…', sunsetAt: '…')]` auto-attaches a middleware
that emits RFC 8594 `Deprecation:` / `Sunset:` headers. Multiple
versions of the same logical endpoint coexist as distinct paths;
`routes:check` knows the difference between "intentional
cross-version routes" and "real conflict."
- **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 @@ -229,7 +236,7 @@ cumulative scoreboard is in

```bash
composer install
vendor/bin/phpunit # 618 tests, 1329 assertions
vendor/bin/phpunit # 641 tests, 1389 assertions
bin/rxn help # CLI subcommands
```
Comment thread
davidwyly marked this conversation as resolved.

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

Test counts:

- **Rxn framework:** 618 tests / 1329 assertions (`vendor/bin/phpunit`).
- **Rxn framework:** 641 tests / 1389 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
62 changes: 62 additions & 0 deletions docs/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,68 @@ $router->group('/api/v1', function (RouteGroup $g) use ($rateLimit, $auth) {
`GET /api/v1/products/42` inherits `$rateLimit`; the `/admin/*`
routes inherit `$rateLimit` + `$auth`.

### API versioning — `#[Version]`

`#[Version('v1')]` on a controller method (or class) prefixes
its `#[Route]` paths with `/v1`. The `Scanner` registers each
version as a distinct path, so multiple versions of the same
logical endpoint coexist:

```php
use Rxn\Framework\Http\Attribute\{Route, Version};

class ProductsController
{
#[Route('GET', '/products/{id:int}')]
#[Version('v1')]
public function showV1(int $id): array { /* … */ }

#[Route('GET', '/products/{id:int}')]
#[Version('v2')]
public function showV2(int $id): array { /* … */ }
}
```

After `Scanner::register()`:

- `GET /v1/products/42` → `showV1`
- `GET /v2/products/42` → `showV2`

Class-level `#[Version]` applies to every `#[Route]` in the
class. Method-level wins when both are present.

`bin/rxn routes:check` won't flag cross-version routes as
conflicts — `/v1/products/{id:int}` and `/v2/products/{id:int}`
are different paths in the Router.

Comment thread
davidwyly marked this conversation as resolved.
#### Deprecation signals

Pass `deprecatedAt` and/or `sunsetAt` (any `DateTimeImmutable`-
parseable date string) and the Scanner auto-attaches a
`Versioning\Deprecation` middleware that emits the matching
RFC 8594 response headers:

```php
#[Route('GET', '/old/{id:int}')]
#[Version('v1', deprecatedAt: '2026-01-01', sunsetAt: '2026-12-31')]
public function show(int $id): array { /* … */ }
```

Outgoing responses gain:

- `Deprecation: Thu, 01 Jan 2026 00:00:00 GMT`
- `Sunset: Thu, 31 Dec 2026 00:00:00 GMT`

Both are advisory — they don't change response status, just
signal to API clients (and gateways) that the endpoint is on
its way out. Apps that prefer to wire the middleware by hand
can construct it directly:

```php
$router->get('/products', $handler)
->middleware(new \Rxn\Framework\Http\Versioning\Deprecation('2026-01-01', '2026-12-31'));
```

### Using matched routes with the pipeline

`Router::match()` returns the matched route's `middlewares` alongside
Expand Down
96 changes: 83 additions & 13 deletions src/Rxn/Framework/Http/Attribute/Scanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,33 @@
use Psr\Http\Server\MiddlewareInterface;
use Rxn\Framework\Container;
use Rxn\Framework\Http\Router;
use Rxn\Framework\Http\Versioning\Deprecation;

/**
* Turn #[Route] + #[Middleware] attributes on controller classes
* into live entries on a Router. Class-level #[Middleware] wraps
* every method's route; method-level #[Middleware] adds to the
* stack after class-level. Each middleware class is resolved
* through the container so autowired constructor deps work.
* Turn #[Route] + #[Middleware] + #[Version] attributes on
* controller classes into live entries on a Router.
*
* - **Class-level `#[Middleware]`** wraps every method's route;
* method-level `#[Middleware]` adds to the stack after
* class-level.
* - **Class-level `#[Version]`** prefixes every route in the
* class with `/$version`. Method-level `#[Version]` overrides
* class-level when both are present.
* - **`#[Version]` with `deprecatedAt` / `sunsetAt`** auto-
* attaches a `Versioning\Deprecation` middleware to the
* route — outgoing responses gain RFC 8594 `Deprecation:` /
* `Sunset:` headers without per-handler boilerplate.
*
* (new Scanner($container))->register(
* $router,
* [ProductsController::class, OrdersController::class],
* );
*
* The handler stored on the route is `[ClassName, 'method']` —
* callers pick the dispatch strategy (container->get + invoke,
* direct call on a fresh instance, etc.).
* Each middleware class is resolved through the container so
* autowired constructor deps work. The handler stored on the
* route is `[ClassName, 'method']` — callers pick the dispatch
* strategy (container->get + invoke, direct call on a fresh
* instance, etc.).
*/
final class Scanner
{
Expand All @@ -44,6 +55,7 @@ private function registerOne(Router $router, string $class): void
}
$ref = new \ReflectionClass($class);
$classMiddlewares = $this->resolveMiddlewareAttrs($ref->getAttributes(Middleware::class));
$classVersion = $this->firstVersion($ref->getAttributes(Version::class));

foreach ($ref->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
if ($method->getDeclaringClass()->getName() !== $ref->getName()) {
Expand All @@ -56,16 +68,51 @@ private function registerOne(Router $router, string $class): void
$methodMiddlewares = $this->resolveMiddlewareAttrs($method->getAttributes(Middleware::class));
$stack = array_merge($classMiddlewares, $methodMiddlewares);

// Method-level #[Version] wins over class-level. The
// effective version applies to every #[Route] on this
// method (the same handler can serve multiple verbs /
// paths via repeated #[Route], but they all share the
// method's version annotation).
$methodVersion = $this->firstVersion($method->getAttributes(Version::class));
$effectiveVersion = $methodVersion ?? $classVersion;

// Auto-attach the deprecation middleware once per
// method, not per Route attribute, so the same response
// header doesn't get added twice for repeat-routed
// handlers.
//
// Prepend, not append: Pipeline runs middleware in
// registration order, so the first one wraps every
// later one. We need Deprecation OUTERMOST so its
// `process()` decorates whatever response comes back —
// including short-circuit responses from inner
// middleware (e.g. auth's 401, rate-limit's 429). If
// we appended, those failure paths would leave the
// route without the documented headers.
$perRouteStack = $stack;
if ($effectiveVersion !== null && self::hasDeprecation($effectiveVersion)) {
array_unshift(
$perRouteStack,
new Deprecation(
$effectiveVersion->deprecatedAt,
$effectiveVersion->sunsetAt,
),
);
}

foreach ($routeAttrs as $attr) {
/** @var Route $route */
$route = $attr->newInstance();
$handle = [$class, $method->getName()];
$entry = $router->add($route->method, $route->path, $handle);
$route = $attr->newInstance();
$path = $effectiveVersion === null
? $route->path
: $effectiveVersion->applyTo($route->path);
$handle = [$class, $method->getName()];
$entry = $router->add($route->method, $path, $handle);
if ($route->name !== null) {
$entry->name($route->name);
}
if ($stack !== []) {
$entry->middleware(...$stack);
if ($perRouteStack !== []) {
$entry->middleware(...$perRouteStack);
}
}
}
Expand All @@ -91,4 +138,27 @@ private function resolveMiddlewareAttrs(array $attrs): array
}
return $out;
}

/**
* Take the first `#[Version]` attribute on a method or class.
* `#[Version]` is not declared `IS_REPEATABLE`, so there's at
* most one, but reflection still hands us a list — we just
* pick element zero or null.
*
* @param list<\ReflectionAttribute<Version>> $attrs
*/
private function firstVersion(array $attrs): ?Version
{
if ($attrs === []) {
return null;
}
/** @var Version $v */
$v = $attrs[0]->newInstance();
return $v;
}

private static function hasDeprecation(Version $version): bool
{
return $version->deprecatedAt !== null || $version->sunsetAt !== null;
}
}
Loading
Loading