Skip to content

REST API Versioning

Flow ID: SY-31 | Module(s): application/core, application/controllers, src/Rest/Versioning, src/Rest/Middleware, job/GenerateOpenApiJson, public/api-docs.html | Complexity: Medium Last Updated: 2026-04-30

Business Context

The REST API versioning system provides a URL-based major-version namespace (/rest/v{N}/...) that allows the platform to introduce breaking API changes without disrupting existing integrations. A version lifecycle state machine (activedeprecatedobsolete) signals transition windows to API consumers via standardised response headers (RFC 9745 Deprecation, RFC 8594 Sunset) before hard-retiring an old version.

Versioning is implemented as transparent router middleware: the CI3 router override strips the v{N} segment from the URI before route matching, so the 1685-line rest_routes.php requires no modification when a new version is introduced. The captured version number flows through the dispatch chain, where VersionResolver maps it to a VersionResult DTO that drives header injection, error responses, and — when a breaking change warrants it — a per-version controller override swap.

Documentation for all active (and deprecated) API versions is generated automatically from OpenAPI annotations and published through a version-aware Scalar UI at public/api-docs.html.

API Reference

URL Patterns

There are no new REST endpoints. The versioning layer is transparent infrastructure that applies to every existing REST endpoint.

URL PatternExampleBehaviour
/rest/{resource}/rest/product/productResolves to the configured latest major version
/rest/v{N}/{resource}/rest/v1/product/productResolves to explicit version N
/{locale}/rest/v{N}/{resource}/en/rest/v2/product/productLocale-prefixed with explicit version N
/rest/v{unknown}/{resource}/rest/v99/product/product400 Bad Request — "Invalid API version"
/rest/v{obsolete}/{resource}(when a version becomes obsolete)410 Gone — with deprecation pointer headers

Response Headers

All successful REST responses include at minimum:

HeaderValueCondition
Api-Version{N}Always
DeprecationIMF-fixdateDeprecated or obsolete versions (RFC 9745)
SunsetIMF-fixdateWhen sunset date is configured (RFC 8594)
Link</rest/v{latest}/>; rel="successor-version"When version != latest

Header generation is implemented in src/Rest/Versioning/VersionResult.php:43-60.

Code Flow

HTTP Request: /rest/v1/product/product
  |
  v
MY_Router::_parse_routes()                         [application/core/MY_Router.php:31-37]
  |-- regex matches /rest/v{N}/... or /{locale}/rest/v{N}/...
  |-- captures $apiVersion = 1
  |-- rewrites URI to /rest/product/product (strips version segment)
  |-- continues standard route matching against rest_routes.php (1685 lines, unmodified)
  |
  v
RouterDispatcher::dispatch()                        [application/controllers/RouterDispatcher.php:22-60]
  |-- reads $this->router->apiVersion                [line 23]
  |-- calls VersionResolver::resolve($apiVersion)    [line 24]
  |
  |-- if null result (unknown version)
  |     |-- sendError(400, "Invalid API version")     [lines 26-33]
  |
  |-- if result->isObsolete()
  |     |-- set Deprecation + Sunset + Link headers  [lines 35-50]
  |     |-- sendError(410, "API version obsolete", pointer to latest)
  |
  |-- if controller override exists for this version
  |     |-- swap $class = overrideMap[$class]        [lines 53-55]
  |
  |-- set Api-Version header (+ deprecation headers if deprecated)
  |                                                  [lines 58-60]
  |-- di()->get($class) — instantiate controller     [line 69]
  |-- pass $apiVersion into RequestContext           [lines 94-100]
  |-- run middleware pipeline (Authentication, Authorization,
  |   ResourceContext, RelationFilter, Audit)
  |-- dispatch to controller action
  |
  v
Response returned with version headers

Version Resolution Detail

src/Rest/Versioning/VersionResolver::resolve() at src/Rest/Versioning/VersionResolver.php:35-56:

  1. If $requested is null (unversioned URL) → resolve to $config['latest']
  2. Look up $config['versions'][$requested]
  3. If not found → return null (triggers 400 in RouterDispatcher)
  4. Build and return a VersionResult DTO with status, override map, and deprecation/sunset dates

Controller Override Mechanism

When a breaking change introduces a new version, the old version's controller is preserved as an override class. The config map controller_overrides[N][BaseController::class] = V1OverrideController::class causes RouterDispatcher to swap $class before di()->get($class) is called (RouterDispatcher.php:53-55). This swap happens pre-instantiation, so no controller code is aware of it.

Override controllers that extend the base controller automatically inherit the base controller's RBAC policy via the PolicyResolver parent-class fallback (src/Rest/Middleware/PolicyResolver.php:27-29).

Data Model

No database tables are involved. This flow operates entirely at the routing, service, and configuration layers. Version definitions are stored in application/config/rest_api_versions.php. No migrations exist for this feature.

Domain Layer

Modern Domain (src/Rest/Versioning/)

FileLinesResponsibility
src/Rest/Versioning/VersionResolver.php106DI-registered service; loads and validates config, resolves requested version to a VersionResult, exposes getActiveVersions() and getLatestVersion()
src/Rest/Versioning/VersionResult.php61Immutable DTO with readonly constructor properties; isActive(), isDeprecated(), isObsolete() predicates; getHeaders() generating RFC-compliant response headers

VersionResolver methods:

MethodLinesPurpose
resolve(?int $requested)35-56Core resolution; null input → latest; unknown → null; found → VersionResult
getActiveVersions()63-69Returns config versions excluding obsolete; used by OpenAPI generation
getLatestVersion()71-74Returns $config['latest'] integer
getConfig()79-83Returns full config array

VersionResult constructor properties (src/Rest/Versioning/VersionResult.php:9-18):

version (int), status (string), isExplicit (bool), latestVersion (int), controllerOverrides (array), deprecated (?string), sunset (?string)

REST Layer (src/Rest/)

FileLinesResponsibility
src/Rest/Auth/container.php46Registers VersionResolver in DI at line 37
src/Rest/Middleware/RequestContext.php51Extended with ?int $apiVersion = null at line 18; available for future version-aware middleware
src/Rest/Middleware/PolicyResolver.php43Modified at lines 27-29 to add parent-class fallback so override controllers inherit the base controller's RBAC policy

Legacy Layer (application/)

FileLinesResponsibility
application/core/MY_Router.php83CI3 router override; adds ?int $apiVersion property (line 17-20) and version extraction in _parse_routes() (lines 31-37)
application/controllers/RouterDispatcher.php125Central REST dispatch; version resolution, error responses, header injection, controller override swap (lines 22-60); passes $apiVersion into RequestContext (lines 94-100)
application/modules/job/libraries/GenerateOpenApiJson.php328CLI job; generates per-version openapi-v{N}.json spec files and the api-versions.json manifest (lines 65-110); rewrites /rest/... paths to /rest/v{N}/... via prefixPaths() (lines 295-309)
public/api-docs.html197Version-selector UI; fetches api-versions.json on load (line 177); renders sticky version dropdown with status badges (lines 74-141); reloads Scalar API reference on version change (lines 67-70)

Configuration

application/config/rest_api_versions.php (57 lines)

KeyTypePurpose
versionsarray<int, array>Version definitions keyed by major version integer
versions.{N}.statusstringLifecycle status: active, deprecated, or obsolete
versions.{N}.releasedstringRelease date in Y-m-d format
versions.{N}.deprecated?stringDeprecation date; populates RFC 9745 Deprecation header when set
versions.{N}.sunset?stringSunset date; populates RFC 8594 Sunset header when set
versions.{N}.changelogarray<{version: string, date: string, summary: string}>Structured changelog entries shown in the docs UI; legacy plain string is still accepted and auto-normalised by VersionResolver::normalizeChangelog()
latestintDefault version applied to unversioned /rest/{resource} requests
controller_overridesarray<int, array>Per-version map of BaseController::class => OverrideController::class; empty array means no overrides for that version

No environment variables, registry keys, or feature flags are involved. There is no runtime config path.

DI Container Registration

VersionResolver is registered as a shared service:

src/Rest/Auth/container.php:37

php
$services->set(\Advisable\Rest\Versioning\VersionResolver::class);

The src/Rest/Auth/container.php module is loaded via application/config/container/modules.php:49.

OpenAPI Generation

The per-version spec files are generated manually — there is no cron trigger:

bash
composer openapi-json
# or equivalently:
php cli.php job/GenerateOpenApiJson

The job writes public/openapi-v{N}.json for each non-obsolete version, copies the latest to public/openapi.json for backward compatibility (lines 100-104), and writes public/api-versions.json as the version manifest consumed by api-docs.html (lines 106-110).

Client Extension Points

Adding a New Major Version

To introduce a new major version:

  1. Add an entry to application/config/rest_api_versions.php under versions with status: 'active' and update latest.
  2. If the new version introduces breaking changes to specific endpoints, create override controller classes extending the base controllers.
  3. Register overrides in controller_overrides[N] in the same config file.
  4. Run composer openapi-json to regenerate versioned spec files.
  5. No changes to application/config/rest_routes.php are required — the router strips the version segment transparently.

Deprecating a Version

Change versions.{N}.status from active to deprecated and set deprecated and sunset dates. The platform will automatically inject Deprecation, Sunset, and Link response headers on all requests to that version. No code changes are required.

Override Controller Pattern

Version-override controllers live alongside their base controllers in src/Rest/{Context}/Controllers/. They extend the base controller and override only the breaking-change methods. The PolicyResolver parent-class fallback at src/Rest/Middleware/PolicyResolver.php:27-29 ensures the inherited RBAC policy continues to apply without any manual policy registration.

Business Rules

  1. Unversioned requests always resolve to latest. A request to /rest/product/product receives the same response as /rest/v{latest}/product/product. The latest value in config is the single source of truth. (src/Rest/Versioning/VersionResolver.php:23-27)

  2. Unknown version numbers return 400, not 404. The 400 response returns a generic "Invalid API version" message without echoing the requested version number. (application/controllers/RouterDispatcher.php:26-33)

  3. Obsolete versions are rejected at the router layer, not in controllers. The 410 response is returned by RouterDispatcher before any controller is instantiated, so obsolete version traffic never touches business logic. (application/controllers/RouterDispatcher.php:35-50)

  4. Deprecation headers are injected on every response for deprecated versions. This applies equally to 2xx success, 4xx client error, and 5xx server error responses that reach the header-injection site. (application/controllers/RouterDispatcher.php:58-60, src/Rest/Versioning/VersionResult.php:43-60)

  5. The version segment is stripped before route matching. MY_Router::_parse_routes() rewrites the URI in place so that rest_routes.php requires no modification. (application/core/MY_Router.php:31-37)

  6. Controller override swap occurs before DI instantiation. The $class variable is replaced at RouterDispatcher.php:53-55 before di()->get($class) at line 69. The override controller is indistinguishable from the base controller at the DI and middleware layers.

  7. Override controllers inherit RBAC policy from their parent class. PolicyResolver falls back to get_parent_class($controllerClass) when no direct policy entry exists. (src/Rest/Middleware/PolicyResolver.php:27-29)

  8. OpenAPI spec files are not generated automatically. public/openapi-v{N}.json and public/api-versions.json do not exist until composer openapi-json is run manually. (application/modules/job/libraries/GenerateOpenApiJson.php:65-110)

  9. Locale-prefixed versioned URLs are supported. The regex in MY_Router handles both rest/v{N}/... and {2-char locale}/rest/v{N}/... formats. (application/core/MY_Router.php:33)

  10. prefixPaths() only rewrites paths starting with /rest/. Webhook and non-REST paths are intentionally excluded from per-version spec generation. (application/modules/job/libraries/GenerateOpenApiJson.php:303)

Known Issues & Security Gaps

  1. No rate limiting on version enumeration. Fixed: The 400 response for unknown versions no longer echoes back the requested version number — it returns a generic "Invalid API version" message. Version enumeration via response differentiation is no longer possible. Rate limiting remains a broader infrastructure concern. (application/controllers/RouterDispatcher.php:27-32)

  2. VersionResolver re-reads config on every non-shared instantiation. The constructor calls require on application/config/rest_api_versions.php at construction time (src/Rest/Versioning/VersionResolver.php:11-27). Under current DI registration as a shared service this is once per request. If the service is ever re-registered as non-shared, each resolution call will re-read the file. The constructor now validates the config structure, but the file I/O concern remains.

  3. class_exists($controllerClass, false) in PolicyResolver silently fails for late-loaded classes. The false argument disables autoloading. In the current dispatch order the controller is instantiated before PolicyResolver runs, so the class is always loaded. If dispatch order changes, the parent-class RBAC fallback will silently return no policy rather than raise an error. (src/Rest/Middleware/PolicyResolver.php:27)

  4. Generated spec files do not exist until manually triggered. public/openapi-v{N}.json and public/api-versions.json are absent from a fresh clone. api-docs.html degrades gracefully (falls back to openapi.json at lines 184-193), but a missing openapi.json would produce a broken documentation page. No CI step generates these files. (public/api-docs.html:116-132)

  5. No integration or end-to-end tests for the version routing pipeline. The MY_Router URI extraction regex, the RouterDispatcher version dispatch block, controller override swap, and response header injection are all exercised only by unit tests of individual components. No test drives an HTTP request through the full stack. (application/core/MY_Router.php:33, application/controllers/RouterDispatcher.php:22-60)

  6. docs/guides/rest-middleware.md is stale with respect to versioning. Fixed: The middleware guide now includes a "Pre-pipeline: Version Resolution" section documenting the MY_Router extraction, VersionResolver dispatch, 400/410 handling, controller override swap, and header injection that runs before the 5-middleware chain. (docs/guides/rest-middleware.md:20-37)

  7. RFC 9745 Link rel="deprecation" header is not generated. The RFC recommends an optional link to a human-readable deprecation policy document. VersionResult generates rel="successor-version" but not rel="deprecation". (src/Rest/Versioning/VersionResult.php:55)

Tests

Unit Tests

FileLinesTestsWhat Is Covered
tests/Unit/Rest/Versioning/VersionResolverTest.php28012Explicit active/deprecated resolution; null → latest; unknown version → null; obsolete resolution; getActiveVersions() excludes obsolete but keeps deprecated; getLatestVersion(); constructor validation (missing file, missing keys, valid config)
tests/Unit/Rest/Versioning/VersionResultTest.php1498isActive/isDeprecated/isObsolete() predicates; header output for active (Api-Version only), deprecated (all 4 headers, IMF-fixdate format), deprecated without sunset, obsolete; override array passthrough

Selected Test Cases (VersionResolverTest.php)

MethodLinesAssertion
resolvesExplicitActiveVersion140-152Explicit v2 → active, isExplicit=true
resolvesExplicitDeprecatedVersion155-167Explicit v1 → deprecated, override map populated
resolvesNullToLatestVersion170-179Null → latest (v2), isExplicit=false
returnsNullForUnknownVersion182-189v99 → null
resolvesObsoleteVersion192-203Obsolete → isObsolete()=true
getActiveVersionsExcludesObsolete206-217Obsolete filtered out
getActiveVersionsIncludesDeprecated220-228Deprecated retained
getLatestVersionReturnsConfigValue231-236Returns config latest value

Coverage Gaps

  • No tests for MY_Router URI extraction regex (application/core/MY_Router.php:33)
  • No integration tests for RouterDispatcher version dispatch (400/410 paths, header injection, override swap)
  • No tests for GenerateOpenApiJson per-version spec generation (application/modules/job/libraries/GenerateOpenApiJson.php:65-110)
  • No tests for api-docs.html JavaScript version selector
  • No tests for PolicyResolver parent-class fallback in a versioning context
  • SY-19 Config Registry — The platform's general configuration pattern; versioning config uses a PHP file rather than the registry, by design.
  • SY-22 Job Manager — Covers the CLI job infrastructure that GenerateOpenApiJson runs within.
  • SY-27 Deferred Task Runner — Background task infrastructure; version resolution in RouterDispatcher completes before any deferred tasks are flushed.
  • REST Middleware & RBAC guide — Documents the 5-middleware pipeline (Authentication → Authorization → ResourceContext → RelationFilter → Audit) that executes after version resolution; covers PolicyResolver in detail.