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/
63 changes: 63 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,69 @@ 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`.

#### 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).

Suite 618 → 638 / 1329 → 1380.

---

### 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 # 638 tests, 1380 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:** 638 tests / 1380 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
126 changes: 113 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
: self::prefixWithVersion($route->path, $effectiveVersion->version);
$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,57 @@ 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;
}

/**
* `'/products/{id:int}'` + version `'v1'` → `'/v1/products/{id:int}'`.
*
* If the route path already starts with the version prefix,
* pass it through unchanged — apps that hand-prefix their
* paths AND mark them with `#[Version]` shouldn't end up with
* `/v1/v1/...`.
*
* The version label is trimmed of leading and trailing slashes
* before the prefix is built, so `'v1'` / `'/v1'` / `'v1/'` /
* `'/v1/'` all produce the same `/v1` prefix and concatenation
* never yields a double slash. An empty trimmed label is
* rejected — `#[Version('')]` is meaningless.
*/
private static function prefixWithVersion(string $path, string $version): string
{
$label = trim($version, '/');
if ($label === '') {
throw new \InvalidArgumentException(
"#[Version] label cannot be empty (got '$version')"
);
}
$prefix = '/' . $label;
if (str_starts_with($path, $prefix . '/') || $path === $prefix) {
return $path;
}
// `$path` is conventionally rooted at `/` — concat is enough.
return $prefix . (str_starts_with($path, '/') ? $path : '/' . $path);
}
}
50 changes: 50 additions & 0 deletions src/Rxn/Framework/Http/Attribute/Version.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php declare(strict_types=1);

namespace Rxn\Framework\Http\Attribute;

/**
* Mark a controller method (or class) as serving a specific API
* version. The Scanner reads this and prepends the version label
* to the registered route's path:
*
* #[Route('GET', '/products/{id:int}')]
* #[Version('v1')]
* public function showV1(int $id): array { ... }
*
* // → registered as GET /v1/products/{id:int}
*
* Class-level `#[Version]` applies to every method-level
* `#[Route]` in the class. Method-level `#[Version]` wins when
* both are present.
*
* Multiple versions of the same logical endpoint coexist
* naturally: `/v1/products/{id}` and `/v2/products/{id}` are
* distinct paths in the Router. The route-conflict detector
* (`bin/rxn routes:check`) sees them as such and doesn't flag
* them.
*
Comment thread
davidwyly marked this conversation as resolved.
* Deprecation: pass `deprecatedAt` and/or `sunsetAt` (any
* `DateTimeImmutable`-parseable date string — bare ISO dates
* like `'2026-01-01'`, full ISO 8601 with timezone, RFC 7231
* IMF-fixdate, etc. all accepted) and the Scanner attaches a
* `Versioning\Deprecation` middleware that emits the corresponding
* RFC 8594 response headers — `Deprecation:` for "this version
* is on the way out" and `Sunset:` for "after this date, expect
* 410 Gone or removal."
*
* #[Version('v1', deprecatedAt: '2026-01-01', sunsetAt: '2026-12-31')]
*
* The version *label* is the API version, not the URL prefix:
* pass `'v1'` (Scanner adds the slash). Date-style versions
* (`'2025-10-15'`) and semantic versions (`'1.0'`) are also
* accepted — whatever the API contract decided.
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS)]
final class Version
{
public function __construct(
public readonly string $version,
public readonly ?string $deprecatedAt = null,
public readonly ?string $sunsetAt = null,
) {}
}
Loading