feat: absorb Front\Api frontage from php-framework
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s

Move the API frontage ServiceProvider (Core\Front\Api\Boot) and related
files (ApiVersionService, VersionedRoutes, middleware, config) from
php-framework into this package. Namespaces unchanged — added PSR-4
mapping for Core\Front\Api\ and auto-discovery provider.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-04 13:05:14 +00:00
parent 774ecae403
commit 261bc16cb5
8 changed files with 1319 additions and 2 deletions

View file

@ -16,12 +16,15 @@
"autoload": {
"psr-4": {
"Core\\Api\\": "src/Api/",
"Core\\Website\\Api\\": "src/Website/Api/"
"Core\\Website\\Api\\": "src/Website/Api/",
"Core\\Front\\Api\\": "src/Front/Api/"
}
},
"extra": {
"laravel": {
"providers": []
"providers": [
"Core\\Front\\Api\\Boot"
]
}
},
"minimum-stability": "stable",

View file

@ -0,0 +1,253 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Front\Api;
use Illuminate\Http\Request;
/**
* API Version Service.
*
* Provides helper methods for working with API versions in controllers
* and other application code.
*
* ## Usage in Controllers
*
* ```php
* use Core\Front\Api\ApiVersionService;
*
* class UserController
* {
* public function __construct(
* protected ApiVersionService $versions
* ) {}
*
* public function index(Request $request)
* {
* if ($this->versions->isV2($request)) {
* return $this->indexV2($request);
* }
* return $this->indexV1($request);
* }
* }
* ```
*
* ## Version Negotiation
*
* The service supports version negotiation where controllers can provide
* different responses based on the requested version:
*
* ```php
* return $this->versions->negotiate($request, [
* 1 => fn() => $this->responseV1(),
* 2 => fn() => $this->responseV2(),
* ]);
* ```
*/
class ApiVersionService
{
/**
* Get the current API version from the request.
*
* Returns null if no version middleware has processed the request.
*/
public function current(?Request $request = null): ?int
{
$request ??= request();
return $request->attributes->get('api_version');
}
/**
* Get the current API version as a string (e.g., 'v1').
*/
public function currentString(?Request $request = null): ?string
{
$request ??= request();
return $request->attributes->get('api_version_string');
}
/**
* Check if the request is for a specific version.
*/
public function is(int $version, ?Request $request = null): bool
{
return $this->current($request) === $version;
}
/**
* Check if the request is for version 1.
*/
public function isV1(?Request $request = null): bool
{
return $this->is(1, $request);
}
/**
* Check if the request is for version 2.
*/
public function isV2(?Request $request = null): bool
{
return $this->is(2, $request);
}
/**
* Check if the request version is at least the given version.
*/
public function isAtLeast(int $version, ?Request $request = null): bool
{
$current = $this->current($request);
return $current !== null && $current >= $version;
}
/**
* Check if the current version is deprecated.
*/
public function isDeprecated(?Request $request = null): bool
{
$current = $this->current($request);
$deprecated = config('api.versioning.deprecated', []);
return $current !== null && in_array($current, $deprecated, true);
}
/**
* Get the configured default version.
*/
public function defaultVersion(): int
{
return (int) config('api.versioning.default', 1);
}
/**
* Get the current/latest version.
*/
public function latestVersion(): int
{
return (int) config('api.versioning.current', 1);
}
/**
* Get all supported versions.
*
* @return array<int>
*/
public function supportedVersions(): array
{
return config('api.versioning.supported', [1]);
}
/**
* Get all deprecated versions.
*
* @return array<int>
*/
public function deprecatedVersions(): array
{
return config('api.versioning.deprecated', []);
}
/**
* Get sunset dates for versions.
*
* @return array<int, string>
*/
public function sunsetDates(): array
{
return config('api.versioning.sunset', []);
}
/**
* Check if a version is supported.
*/
public function isSupported(int $version): bool
{
return in_array($version, $this->supportedVersions(), true);
}
/**
* Negotiate response based on API version.
*
* Calls the appropriate handler based on the request's API version.
* Falls back to lower version handlers if exact match not found.
*
* ```php
* return $versions->negotiate($request, [
* 1 => fn() => ['format' => 'v1'],
* 2 => fn() => ['format' => 'v2', 'extra' => 'field'],
* ]);
* ```
*
* @param array<int, callable> $handlers Version handlers keyed by version number
* @return mixed Result from the appropriate handler
*
* @throws \InvalidArgumentException If no suitable handler found
*/
public function negotiate(Request $request, array $handlers): mixed
{
$version = $this->current($request) ?? $this->defaultVersion();
// Try exact match first
if (isset($handlers[$version])) {
return $handlers[$version]();
}
// Fall back to highest version that's <= requested version
krsort($handlers);
foreach ($handlers as $handlerVersion => $handler) {
if ($handlerVersion <= $version) {
return $handler();
}
}
// No suitable handler found
throw new \InvalidArgumentException(
"No handler found for API version {$version}. Available versions: ".implode(', ', array_keys($handlers))
);
}
/**
* Transform response data based on API version.
*
* Useful for removing or adding fields based on version.
*
* ```php
* return $versions->transform($request, $data, [
* 1 => fn($data) => Arr::except($data, ['new_field']),
* 2 => fn($data) => $data,
* ]);
* ```
*
* @param array<int, callable> $transformers Version transformers
*/
public function transform(Request $request, mixed $data, array $transformers): mixed
{
$version = $this->current($request) ?? $this->defaultVersion();
// Try exact match first
if (isset($transformers[$version])) {
return $transformers[$version]($data);
}
// Fall back to highest version that's <= requested version
krsort($transformers);
foreach ($transformers as $transformerVersion => $transformer) {
if ($transformerVersion <= $version) {
return $transformer($data);
}
}
// No transformer, return data unchanged
return $data;
}
}

111
src/Front/Api/Boot.php Normal file
View file

@ -0,0 +1,111 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Front\Api;
use Core\Front\Api\Middleware\ApiSunset;
use Core\Front\Api\Middleware\ApiVersion;
use Core\LifecycleEventProvider;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
/**
* API frontage - API stage.
*
* Provides api middleware group for API routes and API versioning support.
*
* ## API Versioning
*
* This provider registers middleware for API versioning:
* - `api.version` - Parses and validates API version from URL or headers
* - `api.sunset` - Adds deprecation/sunset headers to endpoints
*
* Configure versioning in config/api.php:
* ```php
* 'versioning' => [
* 'default' => 1, // Default version when none specified
* 'current' => 1, // Current/latest version
* 'supported' => [1], // List of supported versions
* 'deprecated' => [], // Deprecated but still supported versions
* 'sunset' => [], // Sunset dates: [1 => '2025-06-01']
* ],
* ```
*
* @see ApiVersion Middleware for version parsing
* @see ApiVersionService Service for programmatic version checks
* @see VersionedRoutes Helper for version-based route registration
*/
class Boot extends ServiceProvider
{
/**
* Configure api middleware group.
*/
public static function middleware(Middleware $middleware): void
{
$middleware->group('api', [
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
]);
// Register versioning middleware aliases
$middleware->alias([
'api.version' => ApiVersion::class,
'api.sunset' => ApiSunset::class,
]);
}
public function register(): void
{
// Merge API configuration
$this->mergeConfigFrom(__DIR__.'/config.php', 'api');
// Register API version service as singleton
$this->app->singleton(ApiVersionService::class);
}
public function boot(): void
{
$this->configureRateLimiting();
$this->registerMiddlewareAliases();
// Fire ApiRoutesRegistering event for lazy-loaded modules
LifecycleEventProvider::fireApiRoutes();
}
/**
* Register middleware aliases via router.
*
* This ensures aliases are available even if the static middleware()
* method isn't called (e.g., in testing or custom bootstrap).
*/
protected function registerMiddlewareAliases(): void
{
/** @var Router $router */
$router = $this->app->make(Router::class);
$router->aliasMiddleware('api.version', ApiVersion::class);
$router->aliasMiddleware('api.sunset', ApiSunset::class);
}
/**
* Configure API rate limiting.
*/
protected function configureRateLimiting(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
}
}

View file

@ -0,0 +1,112 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Front\Api\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* API Sunset Middleware.
*
* Adds the HTTP Sunset header to responses to indicate when an endpoint
* will be deprecated or removed.
*
* The Sunset header is defined in RFC 8594 and indicates that a resource
* will become unresponsive at the specified date.
*
* ## Usage
*
* Apply to routes that will be sunset:
*
* ```php
* Route::middleware('api.sunset:2025-06-01')->group(function () {
* Route::get('/legacy-endpoint', LegacyController::class);
* });
* ```
*
* Or with a replacement link:
*
* ```php
* Route::middleware('api.sunset:2025-06-01,/api/v2/new-endpoint')->group(function () {
* Route::get('/old-endpoint', OldController::class);
* });
* ```
*
* ## Response Headers
*
* The middleware adds these headers:
* - Sunset: <date in RFC7231 format>
* - Deprecation: true
* - Link: <replacement-url>; rel="successor-version" (if replacement provided)
*
* @see https://datatracker.ietf.org/doc/html/rfc8594 RFC 8594: The "Sunset" HTTP Header Field
*/
class ApiSunset
{
/**
* Handle an incoming request.
*
* @param string $sunsetDate The sunset date (YYYY-MM-DD or RFC7231 format)
* @param string|null $replacement Optional replacement endpoint URL
*/
public function handle(Request $request, Closure $next, string $sunsetDate, ?string $replacement = null): Response
{
/** @var Response $response */
$response = $next($request);
// Convert date to RFC7231 format if needed
$formattedDate = $this->formatSunsetDate($sunsetDate);
// Add Sunset header
$response->headers->set('Sunset', $formattedDate);
// Add Deprecation header
$response->headers->set('Deprecation', 'true');
// Add warning header
$version = $request->attributes->get('api_version', 'unknown');
$response->headers->set(
'X-API-Warn',
"This endpoint is deprecated and will be removed on {$sunsetDate}."
);
// Add Link header for replacement if provided
if ($replacement !== null) {
$response->headers->set('Link', "<{$replacement}>; rel=\"successor-version\"");
}
return $response;
}
/**
* Format the sunset date to RFC7231 format.
*
* Accepts dates in YYYY-MM-DD format or already-formatted RFC7231 dates.
*/
protected function formatSunsetDate(string $date): string
{
// Check if already in RFC7231 format (contains comma, day name)
if (str_contains($date, ',')) {
return $date;
}
try {
return (new \DateTimeImmutable($date))
->setTimezone(new \DateTimeZone('GMT'))
->format(\DateTimeInterface::RFC7231);
} catch (\Exception) {
// If parsing fails, return as-is
return $date;
}
}
}

View file

@ -0,0 +1,246 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Front\Api\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* API Version Middleware.
*
* Parses the API version from the request and sets it on the request attributes.
* Supports version extraction from:
*
* 1. URL path prefix: /api/v1/users, /api/v2/users
* 2. Accept-Version header: Accept-Version: v1, Accept-Version: 2
* 3. Accept header with vendor type: Accept: application/vnd.hosthub.v1+json
*
* The resolved version is stored in request attributes and can be accessed via:
* - $request->attributes->get('api_version') - returns integer (e.g., 1, 2)
* - $request->attributes->get('api_version_string') - returns string (e.g., 'v1', 'v2')
*
* ## Configuration
*
* Configure in config/api.php:
* ```php
* 'versioning' => [
* 'default' => 1, // Default version when none specified
* 'current' => 1, // Current/latest version
* 'supported' => [1], // List of supported versions
* 'deprecated' => [], // List of deprecated (but still supported) versions
* 'sunset' => [], // Versions with sunset dates: [1 => '2025-06-01']
* ],
* ```
*
* ## Usage in Routes
*
* ```php
* // Apply to specific routes
* Route::middleware('api.version')->group(function () {
* Route::get('/users', [UserController::class, 'index']);
* });
*
* // Or with version constraint
* Route::middleware('api.version:2')->group(function () {
* // Only accepts v2 requests
* });
* ```
*
* ## Deprecation Headers
*
* When a request uses a deprecated API version, the response includes:
* - Deprecation: true
* - Sunset: <date> (if configured)
* - X-API-Warn: "API version X is deprecated..."
*
* @see ApiVersionService For programmatic version checks
*/
class ApiVersion
{
/**
* Handle an incoming request.
*
* @param int|null $requiredVersion Minimum version required (optional)
*/
public function handle(Request $request, Closure $next, ?int $requiredVersion = null): Response
{
$version = $this->resolveVersion($request);
$versionConfig = config('api.versioning', []);
$default = $versionConfig['default'] ?? 1;
$current = $versionConfig['current'] ?? 1;
$supported = $versionConfig['supported'] ?? [1];
$deprecated = $versionConfig['deprecated'] ?? [];
$sunset = $versionConfig['sunset'] ?? [];
// Use default if no version specified
if ($version === null) {
$version = $default;
}
// Validate version is supported
if (! in_array($version, $supported, true)) {
return $this->unsupportedVersion($version, $supported, $current);
}
// Check minimum version requirement
if ($requiredVersion !== null && $version < $requiredVersion) {
return $this->versionTooLow($version, $requiredVersion);
}
// Store version in request
$request->attributes->set('api_version', $version);
$request->attributes->set('api_version_string', "v{$version}");
/** @var Response $response */
$response = $next($request);
// Add version header to response
$response->headers->set('X-API-Version', (string) $version);
// Add deprecation headers if applicable
if (in_array($version, $deprecated, true)) {
$response->headers->set('Deprecation', 'true');
$response->headers->set('X-API-Warn', "API version {$version} is deprecated. Please upgrade to v{$current}.");
// Add Sunset header if configured
if (isset($sunset[$version])) {
$sunsetDate = $sunset[$version];
// Convert to HTTP date format if not already
if (! str_contains($sunsetDate, ',')) {
$sunsetDate = (new \DateTimeImmutable($sunsetDate))->format(\DateTimeInterface::RFC7231);
}
$response->headers->set('Sunset', $sunsetDate);
}
}
return $response;
}
/**
* Resolve the API version from the request.
*
* Priority order:
* 1. URL path (/api/v1/...)
* 2. Accept-Version header
* 3. Accept header vendor type
*/
protected function resolveVersion(Request $request): ?int
{
// 1. Check URL path for version prefix
$version = $this->versionFromPath($request);
if ($version !== null) {
return $version;
}
// 2. Check Accept-Version header
$version = $this->versionFromHeader($request);
if ($version !== null) {
return $version;
}
// 3. Check Accept header for vendor type
return $this->versionFromAcceptHeader($request);
}
/**
* Extract version from URL path.
*
* Matches: /api/v1/..., /api/v2/...
*/
protected function versionFromPath(Request $request): ?int
{
$path = $request->path();
// Match /api/v{n}/ or /v{n}/ at the start
if (preg_match('#^(?:api/)?v(\d+)(?:/|$)#', $path, $matches)) {
return (int) $matches[1];
}
return null;
}
/**
* Extract version from Accept-Version header.
*
* Accepts: v1, v2, 1, 2
*/
protected function versionFromHeader(Request $request): ?int
{
$header = $request->header('Accept-Version');
if ($header === null) {
return null;
}
// Strip 'v' prefix if present
$version = ltrim($header, 'vV');
if (is_numeric($version)) {
return (int) $version;
}
return null;
}
/**
* Extract version from Accept header vendor type.
*
* Matches: application/vnd.hosthub.v1+json
*/
protected function versionFromAcceptHeader(Request $request): ?int
{
$accept = $request->header('Accept', '');
// Match vendor media type: application/vnd.{name}.v{n}+json
if (preg_match('#application/vnd\.[^.]+\.v(\d+)\+#', $accept, $matches)) {
return (int) $matches[1];
}
return null;
}
/**
* Return 400 response for unsupported version.
*
* @param array<int> $supported
*/
protected function unsupportedVersion(int $requested, array $supported, int $current): Response
{
return response()->json([
'error' => 'unsupported_api_version',
'message' => "API version {$requested} is not supported.",
'requested_version' => $requested,
'supported_versions' => $supported,
'current_version' => $current,
'hint' => 'Use Accept-Version header or URL prefix (e.g., /api/v1/) to specify version.',
], 400, [
'X-API-Version' => (string) $current,
]);
}
/**
* Return 400 response when version is too low.
*/
protected function versionTooLow(int $requested, int $required): Response
{
return response()->json([
'error' => 'api_version_too_low',
'message' => "This endpoint requires API version {$required} or higher.",
'requested_version' => $requested,
'minimum_version' => $required,
], 400, [
'X-API-Version' => (string) $requested,
]);
}
}

266
src/Front/Api/README.md Normal file
View file

@ -0,0 +1,266 @@
# API Versioning
Core PHP Framework provides built-in API versioning support with deprecation handling and sunset headers.
## Quick Start
### 1. Configure Versions
Add to your `config/api.php`:
```php
'versioning' => [
'default' => 1, // Version when none specified
'current' => 2, // Latest/current version
'supported' => [1, 2], // All supported versions
'deprecated' => [1], // Deprecated but still working
'sunset' => [ // Removal dates
1 => '2025-12-31',
],
],
```
### 2. Apply Middleware
The `api.version` middleware is automatically available. Apply it to routes:
```php
// Version-agnostic routes (uses default version)
Route::middleware('api.version')->group(function () {
Route::get('/status', StatusController::class);
});
// Version-specific routes with URL prefix
use Core\Front\Api\VersionedRoutes;
VersionedRoutes::v1(function () {
Route::get('/users', [UserController::class, 'indexV1']);
});
VersionedRoutes::v2(function () {
Route::get('/users', [UserController::class, 'indexV2']);
});
```
### 3. Version Negotiation in Controllers
```php
use Core\Front\Api\ApiVersionService;
class UserController
{
public function __construct(
protected ApiVersionService $versions
) {}
public function index(Request $request)
{
return $this->versions->negotiate($request, [
1 => fn() => $this->indexV1(),
2 => fn() => $this->indexV2(),
]);
}
}
```
## Version Resolution
The middleware resolves the API version from (in priority order):
1. **URL Path**: `/api/v1/users` or `/v2/users`
2. **Accept-Version Header**: `Accept-Version: v1` or `Accept-Version: 2`
3. **Accept Header**: `Accept: application/vnd.hosthub.v1+json`
4. **Default**: Falls back to configured default version
## Response Headers
Successful responses include:
```
X-API-Version: 2
```
Deprecated versions also include:
```
Deprecation: true
X-API-Warn: API version 1 is deprecated. Please upgrade to v2.
Sunset: Wed, 31 Dec 2025 00:00:00 GMT
```
## Error Responses
### Unsupported Version (400)
```json
{
"error": "unsupported_api_version",
"message": "API version 99 is not supported.",
"requested_version": 99,
"supported_versions": [1, 2],
"current_version": 2,
"hint": "Use Accept-Version header or URL prefix (e.g., /api/v1/) to specify version."
}
```
### Version Too Low (400)
```json
{
"error": "api_version_too_low",
"message": "This endpoint requires API version 2 or higher.",
"requested_version": 1,
"minimum_version": 2
}
```
## Versioned Routes Helper
The `VersionedRoutes` class provides a fluent API for registering version-specific routes:
```php
use Core\Front\Api\VersionedRoutes;
// Simple version registration
VersionedRoutes::v1(function () {
Route::get('/users', UserController::class);
});
// With URL prefix (default)
VersionedRoutes::v2(function () {
Route::get('/users', UserControllerV2::class);
}); // Accessible at /api/v2/users
// Header-only versioning (no URL prefix)
VersionedRoutes::version(2)
->withoutPrefix()
->routes(function () {
Route::get('/users', UserControllerV2::class);
}); // Accessible at /api/users with Accept-Version: 2
// Multiple versions for the same routes
VersionedRoutes::versions([1, 2], function () {
Route::get('/health', HealthController::class);
});
// Deprecated version with sunset
VersionedRoutes::v1()
->deprecated('2025-06-01')
->routes(function () {
Route::get('/legacy', LegacyController::class);
});
```
## ApiVersionService
Inject `ApiVersionService` for programmatic version checks:
```php
use Core\Front\Api\ApiVersionService;
class UserController
{
public function __construct(
protected ApiVersionService $versions
) {}
public function show(Request $request, User $user)
{
$data = $user->toArray();
// Version-specific transformations
return $this->versions->transform($request, $data, [
1 => fn($d) => Arr::except($d, ['created_at', 'metadata']),
2 => fn($d) => $d,
]);
}
}
```
### Available Methods
| Method | Description |
|--------|-------------|
| `current($request)` | Get version number (e.g., 1, 2) |
| `currentString($request)` | Get version string (e.g., 'v1') |
| `is($version, $request)` | Check exact version |
| `isV1($request)` | Check if version 1 |
| `isV2($request)` | Check if version 2 |
| `isAtLeast($version, $request)` | Check minimum version |
| `isDeprecated($request)` | Check if version is deprecated |
| `defaultVersion()` | Get configured default |
| `latestVersion()` | Get current/latest version |
| `supportedVersions()` | Get all supported versions |
| `deprecatedVersions()` | Get deprecated versions |
| `sunsetDates()` | Get sunset dates map |
| `isSupported($version)` | Check if version is supported |
| `negotiate($request, $handlers)` | Call version-specific handler |
| `transform($request, $data, $transformers)` | Transform data per version |
## Sunset Middleware
For endpoint-specific deprecation, use the `api.sunset` middleware:
```php
Route::middleware('api.sunset:2025-06-01')->group(function () {
Route::get('/legacy-endpoint', LegacyController::class);
});
// With replacement hint
Route::middleware('api.sunset:2025-06-01,/api/v2/new-endpoint')->group(function () {
Route::get('/old-endpoint', OldController::class);
});
```
Adds headers:
```
Sunset: Sun, 01 Jun 2025 00:00:00 GMT
Deprecation: true
X-API-Warn: This endpoint is deprecated and will be removed on 2025-06-01.
Link: </api/v2/new-endpoint>; rel="successor-version"
```
## Versioning Strategy
### Guidelines
1. **Add, don't remove**: New fields can be added to any version
2. **New version for breaking changes**: Removing/renaming fields requires new version
3. **Deprecate before removal**: Give clients time to migrate
4. **Document changes**: Maintain changelog per version
### Version Lifecycle
```
v1: Active -> Deprecated (with sunset) -> Removed from supported
v2: Active (current)
v3: Future
```
### Environment Variables
```env
API_VERSION_DEFAULT=1
API_VERSION_CURRENT=2
API_VERSIONS_SUPPORTED=1,2
API_VERSIONS_DEPRECATED=1
```
## Testing
Test versioned endpoints by setting the Accept-Version header:
```php
$response = $this->withHeaders([
'Accept-Version' => 'v2',
])->getJson('/api/users');
$response->assertHeader('X-API-Version', '2');
```
Or use URL prefix:
```php
$response = $this->getJson('/api/v2/users');
```

View file

@ -0,0 +1,248 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Front\Api;
use Illuminate\Support\Facades\Route;
/**
* Versioned Routes Helper.
*
* Provides fluent helpers for registering version-based API routes.
*
* ## Basic Usage
*
* Register routes for a specific version:
*
* ```php
* use Core\Front\Api\VersionedRoutes;
*
* VersionedRoutes::v1(function () {
* Route::get('/users', [UserController::class, 'index']);
* });
*
* VersionedRoutes::v2(function () {
* Route::get('/users', [UserControllerV2::class, 'index']);
* });
* ```
*
* ## URL Prefixed Versions
*
* By default, routes are prefixed with the version (e.g., /api/v1/users):
*
* ```php
* VersionedRoutes::v1(function () {
* Route::get('/users', ...); // Accessible at /api/v1/users
* });
* ```
*
* ## Header-Only Versions
*
* For routes that use header-based versioning only:
*
* ```php
* VersionedRoutes::version(1)
* ->withoutPrefix()
* ->routes(function () {
* Route::get('/users', ...); // Accessible at /api/users with Accept-Version: 1
* });
* ```
*
* ## Multiple Versions
*
* Register the same routes for multiple versions:
*
* ```php
* VersionedRoutes::versions([1, 2], function () {
* Route::get('/status', [StatusController::class, 'index']);
* });
* ```
*
* ## Deprecation
*
* Mark a version as deprecated with custom sunset date:
*
* ```php
* VersionedRoutes::v1()
* ->deprecated('2025-06-01')
* ->routes(function () {
* Route::get('/legacy', ...);
* });
* ```
*/
class VersionedRoutes
{
protected int $version;
protected bool $usePrefix = true;
protected ?string $sunsetDate = null;
protected bool $isDeprecated = false;
/**
* @var array<string>
*/
protected array $middleware = [];
/**
* Create a new versioned routes instance.
*/
public function __construct(int $version)
{
$this->version = $version;
}
/**
* Create routes for version 1.
*/
public static function v1(?callable $routes = null): static
{
$instance = new static(1);
if ($routes !== null) {
$instance->routes($routes);
}
return $instance;
}
/**
* Create routes for version 2.
*/
public static function v2(?callable $routes = null): static
{
$instance = new static(2);
if ($routes !== null) {
$instance->routes($routes);
}
return $instance;
}
/**
* Create routes for a specific version.
*/
public static function version(int $version): static
{
return new static($version);
}
/**
* Register routes for multiple versions.
*
* @param array<int> $versions
*/
public static function versions(array $versions, callable $routes): void
{
foreach ($versions as $version) {
(new static($version))->routes($routes);
}
}
/**
* Don't use URL prefix for this version.
*
* Routes will be accessible without /v{n} prefix but will
* still require version header for version-specific behaviour.
*/
public function withoutPrefix(): static
{
$this->usePrefix = false;
return $this;
}
/**
* Use URL prefix for this version.
*
* This is the default behaviour.
*/
public function withPrefix(): static
{
$this->usePrefix = true;
return $this;
}
/**
* Mark this version as deprecated.
*
* @param string|null $sunsetDate Optional sunset date (YYYY-MM-DD or RFC7231 format)
*/
public function deprecated(?string $sunsetDate = null): static
{
$this->isDeprecated = true;
$this->sunsetDate = $sunsetDate;
return $this;
}
/**
* Add additional middleware to the version routes.
*
* @param array<string>|string $middleware
*/
public function middleware(array|string $middleware): static
{
$this->middleware = array_merge(
$this->middleware,
is_array($middleware) ? $middleware : [$middleware]
);
return $this;
}
/**
* Register the routes for this version.
*/
public function routes(callable $routes): void
{
$attributes = $this->buildRouteAttributes();
Route::group($attributes, $routes);
}
/**
* Build the route group attributes.
*
* @return array<string, mixed>
*/
protected function buildRouteAttributes(): array
{
$attributes = [
'middleware' => $this->buildMiddleware(),
];
if ($this->usePrefix) {
$attributes['prefix'] = "v{$this->version}";
}
return $attributes;
}
/**
* Build the middleware stack for this version.
*
* @return array<string>
*/
protected function buildMiddleware(): array
{
$middleware = ["api.version:{$this->version}"];
if ($this->isDeprecated && $this->sunsetDate) {
$middleware[] = "api.sunset:{$this->sunsetDate}";
}
return array_merge($middleware, $this->middleware);
}
}

78
src/Front/Api/config.php Normal file
View file

@ -0,0 +1,78 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
/**
* API Configuration.
*
* Settings for the REST API infrastructure including versioning,
* rate limiting, and deprecation handling.
*/
return [
/*
|--------------------------------------------------------------------------
| API Versioning
|--------------------------------------------------------------------------
|
| Configure how API versions are handled. The middleware supports:
| - URL path versioning: /api/v1/users
| - Header versioning: Accept-Version: v1
| - Accept header: application/vnd.hosthub.v1+json
|
| Version Strategy:
| - Add new fields to existing versions (backwards compatible)
| - Use new version for breaking changes (removing/renaming fields)
| - Deprecate old versions with sunset dates before removal
|
*/
'versioning' => [
// Default version when no version specified in request
// Clients should always specify version explicitly
'default' => (int) env('API_VERSION_DEFAULT', 1),
// Current/latest API version
// Used in deprecation warnings to suggest upgrade path
'current' => (int) env('API_VERSION_CURRENT', 1),
// Supported API versions (all still functional)
// Remove versions from this list to disable them entirely
'supported' => array_map('intval', array_filter(
explode(',', env('API_VERSIONS_SUPPORTED', '1'))
)),
// Deprecated versions (still work but warn clients)
// Responses include Deprecation: true header
'deprecated' => array_map('intval', array_filter(
explode(',', env('API_VERSIONS_DEPRECATED', ''))
)),
// Sunset dates for deprecated versions
// Format: [version => 'YYYY-MM-DD']
// After this date, version should be removed from 'supported'
'sunset' => [
// Example: 1 => '2025-12-31',
],
],
/*
|--------------------------------------------------------------------------
| Response Headers
|--------------------------------------------------------------------------
|
| Standard headers added to API responses.
|
*/
'headers' => [
// Add X-API-Version header to all responses
'include_version' => true,
// Add deprecation warnings for old versions
'include_deprecation' => true,
],
];