Skip to content

feat(Http): #[Version] attribute for URL-versioned routes + Deprecation middleware#68

Merged
davidwyly merged 7 commits into
masterfrom
feat/version-attribute
May 4, 2026
Merged

feat(Http): #[Version] attribute for URL-versioned routes + Deprecation middleware#68
davidwyly merged 7 commits into
masterfrom
feat/version-attribute

Conversation

@davidwyly

@davidwyly davidwyly commented May 4, 2026

Copy link
Copy Markdown
Owner

Summary

Recovers the convention router's /v{N}/{controller}/{action} value (automatic URL-versioned routing) without the convention router. Apps declare versions via attribute; the Scanner prefixes the route's path during registration.

```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 { /* … */ }

}
```

After Scanner::register():

  • GET /v1/products/42showV1
  • GET /v2/products/42showV2

Class-level #[Version] applies to every route in the class; method-level wins when both are present. `bin/rxn routes:check` (now also version-aware via ConflictDetector) sees /v1/... and /v2/... as distinct paths and won't flag them as conflicts. Same-version same-pattern is still flagged as the real ambiguity it is.

What's in the PR

File Role
Http\Attribute\Version Value object attribute. TARGET_METHOD | TARGET_CLASS. Version::applyTo(string $path): string is the single source of truth for path computation, called by both Scanner and ConflictDetector. Optional deprecatedAt / sunsetAt take any DateTimeImmutable-parseable string.
Http\Versioning\Deprecation PSR-15 middleware emitting RFC 8594 Deprecation: / Sunset: headers as IMF-fixdate. Best-effort: empty / unparseable inputs silently dropped.
Http\Attribute\Scanner Reads #[Version] on method/class; prepends auto-attached Deprecation middleware so it wraps the whole route stack (decorates short-circuit responses too). Path-prefix idempotent; stray-slash version labels normalised; empty labels rejected.
Http\Routing\ConflictDetector Now version-aware: collect() applies the same prefix the Scanner would, so routes:check doesn't false-positive on cross-version routes.
24 new tests 13 Scanner integration + 8 Deprecation middleware unit + 3 ConflictDetector
docs/routing.md New "API versioning — #[Version]" section with deprecation example
README.md Novelty bullet for "API versioning as a primitive"

Test plan

  • `vendor/bin/phpunit --testsuite=framework` — 642 / 1391 green (was 618 / 1329 — added 24 tests across Scanner / Deprecation / ConflictDetector)
  • `php -l` clean across all new files
  • `composer validate --strict` clean
  • No conflict with existing routing — pre-existing Scanner tests still pass; route-conflict-detector tests still pass
  • CI Integration job (HTTP smoke against `examples/quickstart`) passes

…on middleware

Recovers the convention router's `/v{N}/{controller}/{action}` URL
shape's value (automatic URL-versioned routing) without the
convention router. Apps declare versions via attribute; the Scanner
prefixes the route's path during registration.

  #[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. routes:check sees
/v1/... and /v2/... as distinct paths and won't flag them.

- Rxn\Framework\Http\Attribute\Version: TARGET_METHOD | TARGET_CLASS.
  `version` is the API label ('v1' / '1.0' / '2025-10-15');
  Scanner adds the slash. Optional deprecatedAt / sunsetAt take
  any DateTimeImmutable-parseable date string.
- Rxn\Framework\Http\Versioning\Deprecation: PSR-15 middleware
  emitting RFC 8594 Deprecation: / Sunset: response headers as
  IMF-fixdate. Unparseable inputs silently dropped — the contract
  is best-effort signalling, not "die on bad config."
- Scanner integration: when an effective Version carries
  deprecation metadata, the Scanner auto-attaches the Deprecation
  middleware. No per-handler header boilerplate.
- Path-prefix idempotence: Scanner detects routes already
  prefixed with the version (hand-written `/v1/old` +
  `#[Version('v1')]`) and doesn't double-prefix to `/v1/v1/old`.
- 9 Scanner tests + 8 Deprecation middleware tests.
- docs/routing.md: new "API versioning — #[Version]" section.
- README: novelty bullet for "API versioning as a primitive."
- .gitignore: /.claude/ tooling artifacts.

Suite 618 → 635 / 1329 → 1368.
Copilot AI review requested due to automatic review settings May 4, 2026 01:44

@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: 4ce27a3dc6

ℹ️ 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/Attribute/Scanner.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 first-class URL-based API versioning to the attribute-driven router by introducing a #[Version] attribute that prefixes registered route paths (e.g. /v1/...), plus an optional deprecation signalling middleware that emits RFC 8594 Deprecation / Sunset headers.

Changes:

  • Introduces Http\Attribute\Version and extends Http\Attribute\Scanner to prefix route paths based on class/method version attributes (method-level overrides class-level).
  • Adds Http\Versioning\Deprecation PSR-15 middleware and auto-attaches it when deprecatedAt / sunsetAt are provided on #[Version].
  • Adds new unit/integration tests and updates docs/README/CHANGELOG to document the feature.

Reviewed changes

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

Show a summary per file
File Description
src/Rxn/Framework/Http/Attribute/Scanner.php Adds #[Version] support: path prefixing + optional auto-attached Deprecation middleware
src/Rxn/Framework/Http/Attribute/Version.php New #[Version] attribute value object (method/class targets)
src/Rxn/Framework/Http/Versioning/Deprecation.php New middleware emitting RFC 8594 Deprecation / Sunset headers
src/Rxn/Framework/Tests/Http/Versioning/DeprecationTest.php Unit tests for date parsing/formatting and header emission behavior
src/Rxn/Framework/Tests/Http/Attribute/VersionScannerTest.php Integration tests for Scanner version prefixing + middleware attachment
src/Rxn/Framework/Tests/Http/Attribute/Fixture/VersionedController.php Fixture controller for method-level versioning tests
src/Rxn/Framework/Tests/Http/Attribute/Fixture/DeprecatedVersionController.php Fixture controller for deprecated version middleware attachment tests
src/Rxn/Framework/Tests/Http/Attribute/Fixture/ClassVersionedController.php Fixture controller for class-level versioning and method override tests
docs/routing.md Documents #[Version] usage and deprecation signaling
README.md Adds feature bullet and updates test counts
CHANGELOG.md Adds “Unreleased” entry describing the new versioning primitive
.gitignore Ignores /.claude/

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

Comment thread src/Rxn/Framework/Http/Attribute/Scanner.php Outdated
Comment thread src/Rxn/Framework/Http/Attribute/Version.php Outdated
- Scanner now PREPENDS the auto-attached Deprecation middleware
  to the route's stack instead of appending. Pipeline runs
  middleware in registration order, so the first one wraps every
  later one — Deprecation has to be outermost so it decorates
  short-circuit responses too. Without the fix, an inner auth
  middleware returning 401 (or rate-limit returning 429) would
  bypass the Deprecation/Sunset headers entirely; clients hitting
  a deprecated endpoint with a missing token wouldn't learn the
  endpoint is on its way out.
- Scanner::prefixWithVersion() now `trim($version, '/')`s both
  ends, not just leading. Stray-slash labels — `'v1/'`,
  `'/v1/'` — used to yield `/v1//products` (double-slash; Router
  treated the empty segment as literal and the route became
  unmatchable). Empty trimmed labels (`#[Version('')]`) now
  fail loud at scan time with InvalidArgumentException.
- Http\Attribute\Version docstring claimed deprecation dates
  must be "ISO 8601 date strings". The middleware actually
  accepts anything DateTimeImmutable can parse (bare dates, full
  ISO with timezone, RFC 7231 IMF-fixdate). Updated the
  docstring to match.

New test cases (3 in VersionScannerTest):
- Deprecation middleware decorates a short-circuit 401 response
  end-to-end (drives the route's middleware chain through
  Pipeline + a stub terminal that never runs).
- Stray-slash version labels — 'v1', '/v1', 'v1/', '/v1/' — all
  produce the same /v1 prefix; eval'd controllers cover all four.
- Empty `#[Version('')]` is rejected with a clear message.

Plus one fixture middleware (AlwaysReject) standing in for an
auth-style 401 short-circuit.

Suite 635 → 638 / 1368 → 1380.

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 12 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/Versioning/Deprecation.php
Comment thread src/Rxn/Framework/Tests/Http/Attribute/VersionScannerTest.php Outdated
@davidwyly

Copy link
Copy Markdown
Owner Author

@copilot apply changes based on the comments in this thread

- toImfFixdate(): guard against empty/whitespace-only strings before
  DateTimeImmutable construction (empty string parses as 'now', which
  would emit a moving timestamp instead of being dropped)
- VersionScannerTest: remove dead $controllers array and foreach loop
  that was never used (actual test cases driven by eval'd $cases loop)

Agent-Logs-Url: https://github.com/davidwyly/rxn/sessions/0a276a00-b476-4ee8-8b5d-a03cdd0e0816

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

Done in commit 2489861:

  1. Deprecation::toImfFixdate() — added if (trim($input) === '') return null; guard before the DateTimeImmutable construction. An empty/whitespace string parses as "now" without this, emitting a moving timestamp instead of silently dropping the header.
  2. VersionScannerTest::testVersionLabelTolerantOfStraySlashes() — removed the unused $controllers array and the foreach loop that populated it; the test was already fully driven by the $cases eval loop below.

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 12 out of 13 changed files in this pull request and generated 4 comments.


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

Comment thread src/Rxn/Framework/Tests/Http/Attribute/VersionScannerTest.php Outdated
Comment thread docs/routing.md
Comment thread src/Rxn/Framework/Http/Attribute/Version.php
Comment thread CHANGELOG.md
The README/CHANGELOG/Version-docstring all promised that
`bin/rxn routes:check` treats `/v1/...` and `/v2/...` as
distinct paths. Three of the four unresolved threads pointed
out that the runtime promise wasn't met — `Routing\ConflictDetector::collect()`
only reflected `#[Route]` and didn't know about `#[Version]`,
so two methods with the same `#[Route('/products/{id:int}')]`
but different `#[Version('v1')]` / `#[Version('v2')]` would
still be flagged as conflicts.

This commit makes the docs honest:

- New `Version::applyTo(string $path): string` — single source
  of truth for "what URL does this versioned route actually
  register at." The `Scanner` and `ConflictDetector` both call
  it, so runtime registration and static-analysis detection
  stay in lockstep on path computation.
- `ConflictDetector::collect()` reads `#[Version]` (method-level
  with class-level fallback, matching Scanner's resolution rule)
  and applies the prefix during entry construction.
- Three new ConflictDetector tests + one fixture
  (VersionedConflictController):
    - collect() applies the version prefix
    - cross-version same-pattern routes are NOT flagged
    - same-version same-pattern routes ARE flagged (version
      prefixing isn't an escape hatch from the detector's job)

Plus the fourth thread (Copilot, VersionScannerTest line 224):

- The eval'd controller classes in
  `testVersionLabelTolerantOfStraySlashes()` now declare an
  explicit `namespace ...;` inside the eval'd source. Without
  it, PHP put the classes in the global namespace, and passing
  the bare name to `scan()` happened to work via PHP's global-
  fallback class lookup but read confusingly. Test now uses an
  FQCN consistently and asserts class-existence before
  scanning.

Scanner cleanup:

- The path-prefixing helpers `prefixWithVersion()` /
  `hasDeprecation()` were duplicated between Scanner and the
  new ConflictDetector path; pulled the prefix logic onto
  Version itself (`Version::applyTo()`). Scanner now calls
  `$effectiveVersion->applyTo(...)` instead of a private static.

Suite 638 → 641 / 1380 → 1389. Lint clean, composer valid.

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 15 out of 16 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/Attribute/Version.php
Comment thread README.md
@davidwyly davidwyly requested a review from Copilot May 4, 2026 02:49
@davidwyly

Copy link
Copy Markdown
Owner Author

@copilot apply changes based on the comments in this thread

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 15 out of 16 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/Attribute/Version.php
#[Route('/')] + #[Version('v1')] concatenated to '/v1/' — Router::compile
normalises that for matching but Route::$pattern and ConflictDetector
output retained the trailing slash. Special-case path === '/' to return
the bare prefix '/v1'. Adds testRootPathDoesNotGetTrailingSlash to cover
the regression.

Agent-Logs-Url: https://github.com/davidwyly/rxn/sessions/b8733f25-c2b2-422d-afb6-1d4a510decd6

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

Done in commit db3f18d:

  • Version::applyTo() now special-cases $path === '/', returning the bare prefix (/v1) instead of concatenating to /v1/. Router::compile() normalised it for matching, but Route::$pattern and ConflictDetector output retained the trailing slash.
  • Added testRootPathDoesNotGetTrailingSlash() to cover the regression.

The README numbers (641 tests, 1389 assertions) are accurate per the reviewer's own calculation — that note was about the PR description, which I can't update via code.

@davidwyly davidwyly requested a review from Copilot May 4, 2026 03:01

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 15 out of 16 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/Attribute/Version.php Outdated
Comment thread CHANGELOG.md Outdated
davidwyly added 2 commits May 3, 2026 21:59
`Version::applyTo('/')` + version `'v1'` was producing `/v1/`,
which Router::compile would normalise away during match — but
the stored Route::pattern would carry the trailing slash forever.
That meant Router::url() output and ConflictDetector reports
would surface `/v1/` while the runtime matched `/v1`. Two URLs
for the same logical route is the kind of inconsistency that
bites later (e.g., generated client SDKs picking the wrong form).

Special-cased $path === '/' / '' to return just the prefix.
New test (testRootPathPlusVersionDoesNotProduceTrailingSlash)
locks the contract by registering #[Route('/')] + #[Version('v1')]
and asserting both that /v1 matches AND that the stored pattern
is the canonical form, not /v1/.

Also tightened the empty-version exception message:
`"#[Version] label cannot be empty (got '$this->version')"` →
`"... (got '{$this->version}')"`. PHP's simple-syntax string
interpolation handles `$this->prop` correctly, but the explicit
brace form is unambiguous and harder to misread.

Suite 641 → 642 / 1389 → 1391. Test count breakdown: 13 Scanner
integration + 8 Deprecation middleware + 3 ConflictDetector.
CHANGELOG bumped to match.
…o feat/version-attribute

# Conflicts:
#	src/Rxn/Framework/Http/Attribute/Version.php
#	src/Rxn/Framework/Tests/Http/Attribute/VersionScannerTest.php
@davidwyly davidwyly merged commit 30ae096 into master May 4, 2026
4 checks passed
@davidwyly davidwyly deleted the feat/version-attribute branch May 4, 2026 04:02
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