php-api/src/Mod/Api/Documentation/OpenApiBuilder.php
2026-01-26 20:57:08 +00:00

819 lines
24 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mod\Api\Documentation;
use Core\Mod\Api\Documentation\Attributes\ApiHidden;
use Core\Mod\Api\Documentation\Attributes\ApiParameter;
use Core\Mod\Api\Documentation\Attributes\ApiResponse;
use Core\Mod\Api\Documentation\Attributes\ApiSecurity;
use Core\Mod\Api\Documentation\Attributes\ApiTag;
use Core\Mod\Api\Documentation\Extensions\ApiKeyAuthExtension;
use Core\Mod\Api\Documentation\Extensions\RateLimitExtension;
use Core\Mod\Api\Documentation\Extensions\WorkspaceHeaderExtension;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route as RouteFacade;
use Illuminate\Support\Str;
use ReflectionAttribute;
use ReflectionClass;
/**
* Enhanced OpenAPI Specification Builder.
*
* Builds comprehensive OpenAPI 3.1 specification from Laravel routes,
* with support for custom attributes, module discovery, and extensions.
*/
class OpenApiBuilder
{
/**
* Registered extensions.
*
* @var array<Extension>
*/
protected array $extensions = [];
/**
* Discovered tags from modules.
*
* @var array<string, array>
*/
protected array $discoveredTags = [];
/**
* Create a new builder instance.
*/
public function __construct()
{
$this->registerDefaultExtensions();
}
/**
* Register default extensions.
*/
protected function registerDefaultExtensions(): void
{
$this->extensions = [
new WorkspaceHeaderExtension,
new RateLimitExtension,
new ApiKeyAuthExtension,
];
}
/**
* Add a custom extension.
*/
public function addExtension(Extension $extension): static
{
$this->extensions[] = $extension;
return $this;
}
/**
* Generate the complete OpenAPI specification.
*/
public function build(): array
{
$config = config('api-docs', []);
if ($this->shouldCache($config)) {
$cacheKey = $config['cache']['key'] ?? 'api-docs:openapi';
$cacheTtl = $config['cache']['ttl'] ?? 3600;
return Cache::remember($cacheKey, $cacheTtl, fn () => $this->buildSpec($config));
}
return $this->buildSpec($config);
}
/**
* Clear the cached specification.
*/
public function clearCache(): void
{
$cacheKey = config('api-docs.cache.key', 'api-docs:openapi');
Cache::forget($cacheKey);
}
/**
* Check if caching should be enabled.
*/
protected function shouldCache(array $config): bool
{
if (! ($config['cache']['enabled'] ?? true)) {
return false;
}
$disabledEnvs = $config['cache']['disabled_environments'] ?? ['local', 'testing'];
return ! in_array(app()->environment(), $disabledEnvs, true);
}
/**
* Build the full OpenAPI specification.
*/
protected function buildSpec(array $config): array
{
$spec = [
'openapi' => '3.1.0',
'info' => $this->buildInfo($config),
'servers' => $this->buildServers($config),
'tags' => [],
'paths' => [],
'components' => $this->buildComponents($config),
];
// Build paths and collect tags
$spec['paths'] = $this->buildPaths($config);
$spec['tags'] = $this->buildTags($config);
// Apply extensions to spec
foreach ($this->extensions as $extension) {
$spec = $extension->extend($spec, $config);
}
return $spec;
}
/**
* Build API info section.
*/
protected function buildInfo(array $config): array
{
$info = $config['info'] ?? [];
$result = [
'title' => $info['title'] ?? config('app.name', 'API').' API',
'version' => $info['version'] ?? config('api.version', '1.0.0'),
];
if (! empty($info['description'])) {
$result['description'] = $info['description'];
}
if (! empty($info['contact'])) {
$contact = array_filter($info['contact']);
if (! empty($contact)) {
$result['contact'] = $contact;
}
}
if (! empty($info['license']['name'])) {
$result['license'] = array_filter($info['license']);
}
return $result;
}
/**
* Build servers section.
*/
protected function buildServers(array $config): array
{
$servers = $config['servers'] ?? [];
if (empty($servers)) {
return [
[
'url' => config('app.url', 'http://localhost'),
'description' => 'Current Environment',
],
];
}
return array_map(fn ($server) => array_filter($server), $servers);
}
/**
* Build tags section from discovered modules and config.
*/
protected function buildTags(array $config): array
{
$configTags = $config['tags'] ?? [];
$tags = [];
// Add discovered tags first
foreach ($this->discoveredTags as $name => $data) {
$tags[$name] = [
'name' => $name,
'description' => $data['description'] ?? null,
];
}
// Merge with configured tags (config takes precedence)
foreach ($configTags as $key => $tagConfig) {
$tagName = $tagConfig['name'] ?? $key;
$tags[$tagName] = [
'name' => $tagName,
'description' => $tagConfig['description'] ?? null,
];
}
// Clean up null descriptions and sort
$result = [];
foreach ($tags as $tag) {
$result[] = array_filter($tag);
}
usort($result, fn ($a, $b) => strcasecmp($a['name'], $b['name']));
return $result;
}
/**
* Build paths section from routes.
*/
protected function buildPaths(array $config): array
{
$paths = [];
$includePatterns = $config['routes']['include'] ?? ['api/*'];
$excludePatterns = $config['routes']['exclude'] ?? [];
foreach (RouteFacade::getRoutes() as $route) {
/** @var Route $route */
if (! $this->shouldIncludeRoute($route, $includePatterns, $excludePatterns)) {
continue;
}
$path = $this->normalizePath($route->uri());
$methods = array_filter($route->methods(), fn ($m) => $m !== 'HEAD');
foreach ($methods as $method) {
$method = strtolower($method);
$operation = $this->buildOperation($route, $method, $config);
if ($operation !== null) {
$paths[$path][$method] = $operation;
}
}
}
ksort($paths);
return $paths;
}
/**
* Check if a route should be included in documentation.
*/
protected function shouldIncludeRoute(Route $route, array $include, array $exclude): bool
{
$uri = $route->uri();
// Check exclusions first
foreach ($exclude as $pattern) {
if (fnmatch($pattern, $uri)) {
return false;
}
}
// Check inclusions
foreach ($include as $pattern) {
if (fnmatch($pattern, $uri)) {
return true;
}
}
return false;
}
/**
* Normalize route path to OpenAPI format.
*/
protected function normalizePath(string $uri): string
{
// Prepend slash if missing
$path = '/'.ltrim($uri, '/');
// Convert Laravel parameters to OpenAPI format: {param?} -> {param}
$path = preg_replace('/\{([^}?]+)\?\}/', '{$1}', $path);
return $path === '/' ? '/' : rtrim($path, '/');
}
/**
* Build operation for a specific route and method.
*/
protected function buildOperation(Route $route, string $method, array $config): ?array
{
$controller = $route->getController();
$action = $route->getActionMethod();
// Check for ApiHidden attribute
if ($this->isHidden($controller, $action)) {
return null;
}
$operation = [
'summary' => $this->buildSummary($route, $method),
'operationId' => $this->buildOperationId($route, $method),
'tags' => $this->buildOperationTags($route, $controller, $action),
'responses' => $this->buildResponses($controller, $action),
];
// Add description from PHPDoc if available
$description = $this->extractDescription($controller, $action);
if ($description) {
$operation['description'] = $description;
}
// Add parameters
$parameters = $this->buildParameters($route, $controller, $action, $config);
if (! empty($parameters)) {
$operation['parameters'] = $parameters;
}
// Add request body for POST/PUT/PATCH
if (in_array($method, ['post', 'put', 'patch'])) {
$operation['requestBody'] = $this->buildRequestBody($controller, $action);
}
// Add security requirements
$security = $this->buildSecurity($route, $controller, $action);
if ($security !== null) {
$operation['security'] = $security;
}
// Apply extensions to operation
foreach ($this->extensions as $extension) {
$operation = $extension->extendOperation($operation, $route, $method, $config);
}
return $operation;
}
/**
* Check if controller/method is hidden from docs.
*/
protected function isHidden(?object $controller, string $action): bool
{
if ($controller === null) {
return false;
}
$reflection = new ReflectionClass($controller);
// Check class-level attribute
$classAttrs = $reflection->getAttributes(ApiHidden::class);
if (! empty($classAttrs)) {
return true;
}
// Check method-level attribute
if ($reflection->hasMethod($action)) {
$method = $reflection->getMethod($action);
$methodAttrs = $method->getAttributes(ApiHidden::class);
if (! empty($methodAttrs)) {
return true;
}
}
return false;
}
/**
* Build operation summary.
*/
protected function buildSummary(Route $route, string $method): string
{
$name = $route->getName();
if ($name) {
// Convert route name to human-readable summary
$parts = explode('.', $name);
$action = array_pop($parts);
return Str::title(str_replace(['-', '_'], ' ', $action));
}
// Generate from URI and method
$uri = Str::afterLast($route->uri(), '/');
return Str::title($method.' '.str_replace(['-', '_'], ' ', $uri));
}
/**
* Build operation ID from route name.
*/
protected function buildOperationId(Route $route, string $method): string
{
$name = $route->getName();
if ($name) {
return Str::camel(str_replace(['.', '-'], '_', $name));
}
return Str::camel($method.'_'.str_replace(['/', '-', '.'], '_', $route->uri()));
}
/**
* Build tags for an operation.
*/
protected function buildOperationTags(Route $route, ?object $controller, string $action): array
{
// Check for ApiTag attribute
if ($controller !== null) {
$tagAttr = $this->getAttribute($controller, $action, ApiTag::class);
if ($tagAttr !== null) {
$tag = $tagAttr->newInstance();
$this->discoveredTags[$tag->name] = ['description' => $tag->description];
return [$tag->name];
}
}
// Infer tag from route
return [$this->inferTag($route)];
}
/**
* Infer tag from route.
*/
protected function inferTag(Route $route): string
{
$uri = $route->uri();
$name = $route->getName() ?? '';
// Common tag mappings by route prefix
$tagMap = [
'api/bio' => 'Bio Links',
'api/blocks' => 'Bio Links',
'api/shortlinks' => 'Bio Links',
'api/qr' => 'Bio Links',
'api/commerce' => 'Commerce',
'api/provisioning' => 'Commerce',
'api/workspaces' => 'Workspaces',
'api/analytics' => 'Analytics',
'api/social' => 'Social',
'api/notify' => 'Notifications',
'api/support' => 'Support',
'api/pixel' => 'Pixel',
'api/seo' => 'SEO',
'api/mcp' => 'MCP',
'api/content' => 'Content',
'api/trust' => 'Trust',
'api/webhooks' => 'Webhooks',
'api/entitlements' => 'Entitlements',
];
foreach ($tagMap as $prefix => $tag) {
if (str_starts_with($uri, $prefix)) {
$this->discoveredTags[$tag] = $this->discoveredTags[$tag] ?? [];
return $tag;
}
}
$this->discoveredTags['General'] = $this->discoveredTags['General'] ?? [];
return 'General';
}
/**
* Extract description from PHPDoc.
*/
protected function extractDescription(?object $controller, string $action): ?string
{
if ($controller === null) {
return null;
}
$reflection = new ReflectionClass($controller);
if (! $reflection->hasMethod($action)) {
return null;
}
$method = $reflection->getMethod($action);
$doc = $method->getDocComment();
if (! $doc) {
return null;
}
// Extract description from PHPDoc (first paragraph before @tags)
preg_match('/\/\*\*\s*\n\s*\*\s*(.+?)(?:\n\s*\*\s*\n|\n\s*\*\s*@)/s', $doc, $matches);
if (! empty($matches[1])) {
$description = preg_replace('/\n\s*\*\s*/', ' ', $matches[1]);
return trim($description);
}
return null;
}
/**
* Build parameters for operation.
*/
protected function buildParameters(Route $route, ?object $controller, string $action, array $config): array
{
$parameters = [];
// Add path parameters
preg_match_all('/\{([^}?]+)\??}/', $route->uri(), $matches);
foreach ($matches[1] as $param) {
$parameters[] = [
'name' => $param,
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
];
}
// Add parameters from ApiParameter attributes
if ($controller !== null) {
$reflection = new ReflectionClass($controller);
if ($reflection->hasMethod($action)) {
$method = $reflection->getMethod($action);
$paramAttrs = $method->getAttributes(ApiParameter::class, ReflectionAttribute::IS_INSTANCEOF);
foreach ($paramAttrs as $attr) {
$param = $attr->newInstance();
$parameters[] = $param->toOpenApi();
}
}
}
return $parameters;
}
/**
* Build responses section.
*/
protected function buildResponses(?object $controller, string $action): array
{
$responses = [];
// Get ApiResponse attributes
if ($controller !== null) {
$reflection = new ReflectionClass($controller);
if ($reflection->hasMethod($action)) {
$method = $reflection->getMethod($action);
$responseAttrs = $method->getAttributes(ApiResponse::class, ReflectionAttribute::IS_INSTANCEOF);
foreach ($responseAttrs as $attr) {
$response = $attr->newInstance();
$responses[(string) $response->status] = $this->buildResponseSchema($response);
}
}
}
// Default 200 response if none specified
if (empty($responses)) {
$responses['200'] = ['description' => 'Successful response'];
}
return $responses;
}
/**
* Build response schema from ApiResponse attribute.
*/
protected function buildResponseSchema(ApiResponse $response): array
{
$result = [
'description' => $response->getDescription(),
];
if ($response->resource !== null && class_exists($response->resource)) {
$schema = $this->extractResourceSchema($response->resource);
if ($response->paginated) {
$schema = $this->wrapPaginatedSchema($schema);
}
$result['content'] = [
'application/json' => [
'schema' => $schema,
],
];
}
if (! empty($response->headers)) {
$result['headers'] = [];
foreach ($response->headers as $header => $description) {
$result['headers'][$header] = [
'description' => $description,
'schema' => ['type' => 'string'],
];
}
}
return $result;
}
/**
* Extract schema from JsonResource class.
*/
protected function extractResourceSchema(string $resourceClass): array
{
if (! is_subclass_of($resourceClass, JsonResource::class)) {
return ['type' => 'object'];
}
// For now, return a generic object schema
// A more sophisticated implementation would analyze the resource's toArray method
return [
'type' => 'object',
'additionalProperties' => true,
];
}
/**
* Wrap schema in pagination structure.
*/
protected function wrapPaginatedSchema(array $itemSchema): array
{
return [
'type' => 'object',
'properties' => [
'data' => [
'type' => 'array',
'items' => $itemSchema,
],
'links' => [
'type' => 'object',
'properties' => [
'first' => ['type' => 'string', 'format' => 'uri'],
'last' => ['type' => 'string', 'format' => 'uri'],
'prev' => ['type' => 'string', 'format' => 'uri', 'nullable' => true],
'next' => ['type' => 'string', 'format' => 'uri', 'nullable' => true],
],
],
'meta' => [
'type' => 'object',
'properties' => [
'current_page' => ['type' => 'integer'],
'from' => ['type' => 'integer', 'nullable' => true],
'last_page' => ['type' => 'integer'],
'per_page' => ['type' => 'integer'],
'to' => ['type' => 'integer', 'nullable' => true],
'total' => ['type' => 'integer'],
],
],
],
];
}
/**
* Build request body schema.
*/
protected function buildRequestBody(?object $controller, string $action): array
{
return [
'required' => true,
'content' => [
'application/json' => [
'schema' => ['type' => 'object'],
],
],
];
}
/**
* Build security requirements.
*/
protected function buildSecurity(Route $route, ?object $controller, string $action): ?array
{
// Check for ApiSecurity attribute
if ($controller !== null) {
$securityAttr = $this->getAttribute($controller, $action, ApiSecurity::class);
if ($securityAttr !== null) {
$security = $securityAttr->newInstance();
if ($security->isPublic()) {
return []; // Empty array means no auth required
}
return [[$security->scheme => $security->scopes]];
}
}
// Infer from route middleware
$middleware = $route->middleware();
if (in_array('auth:sanctum', $middleware) || in_array('auth', $middleware)) {
return [['bearerAuth' => []]];
}
if (in_array('api.auth', $middleware) || in_array('auth.api', $middleware)) {
return [['apiKeyAuth' => []]];
}
foreach ($middleware as $m) {
if (str_contains($m, 'ApiKeyAuth') || str_contains($m, 'AuthenticateApiKey')) {
return [['apiKeyAuth' => []]];
}
}
return null;
}
/**
* Build components section.
*/
protected function buildComponents(array $config): array
{
$components = [
'securitySchemes' => [],
'schemas' => $this->buildCommonSchemas(),
];
// Add API Key security scheme
$apiKeyConfig = $config['auth']['api_key'] ?? [];
if ($apiKeyConfig['enabled'] ?? true) {
$components['securitySchemes']['apiKeyAuth'] = [
'type' => 'apiKey',
'in' => $apiKeyConfig['in'] ?? 'header',
'name' => $apiKeyConfig['name'] ?? 'X-API-Key',
'description' => $apiKeyConfig['description'] ?? 'API key for authentication',
];
}
// Add Bearer token security scheme
$bearerConfig = $config['auth']['bearer'] ?? [];
if ($bearerConfig['enabled'] ?? true) {
$components['securitySchemes']['bearerAuth'] = [
'type' => 'http',
'scheme' => $bearerConfig['scheme'] ?? 'bearer',
'bearerFormat' => $bearerConfig['format'] ?? 'JWT',
'description' => $bearerConfig['description'] ?? 'Bearer token authentication',
];
}
// Add OAuth2 security scheme
$oauth2Config = $config['auth']['oauth2'] ?? [];
if ($oauth2Config['enabled'] ?? false) {
$components['securitySchemes']['oauth2'] = [
'type' => 'oauth2',
'flows' => $oauth2Config['flows'] ?? [],
];
}
return $components;
}
/**
* Build common reusable schemas.
*/
protected function buildCommonSchemas(): array
{
return [
'Error' => [
'type' => 'object',
'required' => ['message'],
'properties' => [
'message' => ['type' => 'string', 'description' => 'Error message'],
'errors' => [
'type' => 'object',
'description' => 'Validation errors (field => messages)',
'additionalProperties' => [
'type' => 'array',
'items' => ['type' => 'string'],
],
],
],
],
'Pagination' => [
'type' => 'object',
'properties' => [
'current_page' => ['type' => 'integer'],
'from' => ['type' => 'integer', 'nullable' => true],
'last_page' => ['type' => 'integer'],
'per_page' => ['type' => 'integer'],
'to' => ['type' => 'integer', 'nullable' => true],
'total' => ['type' => 'integer'],
],
],
];
}
/**
* Get attribute from controller class or method.
*
* @template T
*
* @param class-string<T> $attributeClass
* @return ReflectionAttribute<T>|null
*/
protected function getAttribute(object $controller, string $action, string $attributeClass): ?ReflectionAttribute
{
$reflection = new ReflectionClass($controller);
// Check method first (method takes precedence)
if ($reflection->hasMethod($action)) {
$method = $reflection->getMethod($action);
$attrs = $method->getAttributes($attributeClass);
if (! empty($attrs)) {
return $attrs[0];
}
}
// Fall back to class
$attrs = $reflection->getAttributes($attributeClass);
return $attrs[0] ?? null;
}
}