From 261bc16cb5d21ccce3f3283f9a0fc90ec5f5153a Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 4 Mar 2026 13:05:14 +0000 Subject: [PATCH] feat: absorb Front\Api frontage from php-framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- composer.json | 7 +- src/Front/Api/ApiVersionService.php | 253 ++++++++++++++++++++++ src/Front/Api/Boot.php | 111 ++++++++++ src/Front/Api/Middleware/ApiSunset.php | 112 ++++++++++ src/Front/Api/Middleware/ApiVersion.php | 246 ++++++++++++++++++++++ src/Front/Api/README.md | 266 ++++++++++++++++++++++++ src/Front/Api/VersionedRoutes.php | 248 ++++++++++++++++++++++ src/Front/Api/config.php | 78 +++++++ 8 files changed, 1319 insertions(+), 2 deletions(-) create mode 100644 src/Front/Api/ApiVersionService.php create mode 100644 src/Front/Api/Boot.php create mode 100644 src/Front/Api/Middleware/ApiSunset.php create mode 100644 src/Front/Api/Middleware/ApiVersion.php create mode 100644 src/Front/Api/README.md create mode 100644 src/Front/Api/VersionedRoutes.php create mode 100644 src/Front/Api/config.php diff --git a/composer.json b/composer.json index f5a2571..551b148 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/src/Front/Api/ApiVersionService.php b/src/Front/Api/ApiVersionService.php new file mode 100644 index 0000000..5b889d2 --- /dev/null +++ b/src/Front/Api/ApiVersionService.php @@ -0,0 +1,253 @@ +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 + */ + public function supportedVersions(): array + { + return config('api.versioning.supported', [1]); + } + + /** + * Get all deprecated versions. + * + * @return array + */ + public function deprecatedVersions(): array + { + return config('api.versioning.deprecated', []); + } + + /** + * Get sunset dates for versions. + * + * @return array + */ + 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 $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 $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; + } +} diff --git a/src/Front/Api/Boot.php b/src/Front/Api/Boot.php new file mode 100644 index 0000000..545bfe6 --- /dev/null +++ b/src/Front/Api/Boot.php @@ -0,0 +1,111 @@ + [ + * '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()); + }); + } +} diff --git a/src/Front/Api/Middleware/ApiSunset.php b/src/Front/Api/Middleware/ApiSunset.php new file mode 100644 index 0000000..c853f9a --- /dev/null +++ b/src/Front/Api/Middleware/ApiSunset.php @@ -0,0 +1,112 @@ +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: + * - Deprecation: true + * - Link: ; 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; + } + } +} diff --git a/src/Front/Api/Middleware/ApiVersion.php b/src/Front/Api/Middleware/ApiVersion.php new file mode 100644 index 0000000..52c659b --- /dev/null +++ b/src/Front/Api/Middleware/ApiVersion.php @@ -0,0 +1,246 @@ +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: (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 $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, + ]); + } +} diff --git a/src/Front/Api/README.md b/src/Front/Api/README.md new file mode 100644 index 0000000..45689c6 --- /dev/null +++ b/src/Front/Api/README.md @@ -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: ; 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'); +``` diff --git a/src/Front/Api/VersionedRoutes.php b/src/Front/Api/VersionedRoutes.php new file mode 100644 index 0000000..5ebe22f --- /dev/null +++ b/src/Front/Api/VersionedRoutes.php @@ -0,0 +1,248 @@ +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 + */ + 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 $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 $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 + */ + 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 + */ + 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); + } +} diff --git a/src/Front/Api/config.php b/src/Front/Api/config.php new file mode 100644 index 0000000..852acc3 --- /dev/null +++ b/src/Front/Api/config.php @@ -0,0 +1,78 @@ + [ + // 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, + ], +];