diff --git a/src/php/src/Api/Controllers/Api/UnifiedPixelController.php b/src/php/src/Api/Controllers/Api/UnifiedPixelController.php index d7e51c2..3b16ffe 100644 --- a/src/php/src/Api/Controllers/Api/UnifiedPixelController.php +++ b/src/php/src/Api/Controllers/Api/UnifiedPixelController.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Core\Api\Controllers\Api; +use Core\Api\Documentation\Attributes\ApiResponse; +use Core\Api\Documentation\Attributes\ApiTag; use Core\Api\RateLimit\RateLimit; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -15,6 +17,7 @@ use Illuminate\Routing\Controller; * GET /api/pixel/{pixelKey} returns a transparent 1x1 GIF for image embeds. * POST /api/pixel/{pixelKey} returns 204 No Content for fetch-based tracking. */ +#[ApiTag('Pixel', 'Unified tracking pixel endpoint')] class UnifiedPixelController extends Controller { /** @@ -28,6 +31,17 @@ class UnifiedPixelController extends Controller * GET /api/pixel/abc12345 -> transparent GIF * POST /api/pixel/abc12345 -> 204 No Content */ + #[ApiResponse( + 200, + null, + 'Transparent 1x1 GIF pixel response', + contentType: 'image/gif', + schema: [ + 'type' => 'string', + 'format' => 'binary', + ], + )] + #[ApiResponse(204, null, 'Accepted without a response body')] #[RateLimit(limit: 10000, window: 60)] public function track(Request $request, string $pixelKey): Response { diff --git a/src/php/src/Api/Documentation/Attributes/ApiResponse.php b/src/php/src/Api/Documentation/Attributes/ApiResponse.php index ee35835..b96ca76 100644 --- a/src/php/src/Api/Documentation/Attributes/ApiResponse.php +++ b/src/php/src/Api/Documentation/Attributes/ApiResponse.php @@ -27,6 +27,19 @@ use Attribute; * { * return UserResource::collection(User::paginate()); * } + * + * // For non-JSON or binary responses + * #[ApiResponse( + * 200, + * null, + * 'Transparent tracking pixel', + * contentType: 'image/gif', + * schema: ['type' => 'string', 'format' => 'binary'] + * )] + * public function pixel() + * { + * return response($gif, 200)->header('Content-Type', 'image/gif'); + * } */ #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] readonly class ApiResponse @@ -37,6 +50,8 @@ readonly class ApiResponse * @param string|null $description Description of the response * @param bool $paginated Whether this is a paginated collection response * @param array $headers Additional response headers to document + * @param string|null $contentType Explicit response media type for non-JSON responses + * @param array|null $schema Explicit response schema when the body is not inferred from a resource */ public function __construct( public int $status, @@ -44,6 +59,8 @@ readonly class ApiResponse public ?string $description = null, public bool $paginated = false, public array $headers = [], + public ?string $contentType = null, + public ?array $schema = null, ) {} /** diff --git a/src/php/src/Api/Documentation/OpenApiBuilder.php b/src/php/src/Api/Documentation/OpenApiBuilder.php index 209b13a..4770720 100644 --- a/src/php/src/Api/Documentation/OpenApiBuilder.php +++ b/src/php/src/Api/Documentation/OpenApiBuilder.php @@ -612,15 +612,23 @@ class OpenApiBuilder 'description' => $response->getDescription(), ]; - if ($response->resource !== null && class_exists($response->resource)) { + $schema = null; + + if (is_array($response->schema) && ! empty($response->schema)) { + $schema = $response->schema; + } elseif ($response->resource !== null && class_exists($response->resource)) { $schema = $this->extractResourceSchema($response->resource); if ($response->paginated) { $schema = $this->wrapPaginatedSchema($schema); } + } + + if ($schema !== null) { + $contentType = $response->contentType ?: 'application/json'; $result['content'] = [ - 'application/json' => [ + $contentType => [ 'schema' => $schema, ], ]; diff --git a/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php b/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php index ae33d5c..6ad6188 100644 --- a/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php +++ b/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php @@ -214,6 +214,26 @@ describe('Application Endpoint Parameter Docs', function () { expect($urlParam['schema']['format'])->toBe('uri'); }); + it('documents the pixel endpoint as binary for GET and no-content for POST', function () { + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $getOperation = $spec['paths']['/api/pixel/{pixelKey}']['get']; + $getResponse = $getOperation['responses']['200'] ?? []; + $getContent = $getResponse['content']['image/gif']['schema'] ?? null; + + expect($getContent)->toBe([ + 'type' => 'string', + 'format' => 'binary', + ]); + + $postOperation = $spec['paths']['/api/pixel/{pixelKey}']['post']; + $postResponse = $postOperation['responses']['204'] ?? []; + + expect($postResponse['description'] ?? null)->toBe('Accepted without a response body'); + expect($postResponse)->not->toHaveKey('content'); + }); + it('documents MCP list query parameters', function () { $builder = new OpenApiBuilder; $spec = $builder->build();