feat(api-docs): document binary pixel responses

This commit is contained in:
Virgil 2026-04-02 02:47:02 +00:00
parent 8e28b0209c
commit 6ea0b26a13
4 changed files with 61 additions and 2 deletions

View file

@ -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
{

View file

@ -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<string> $headers Additional response headers to document
* @param string|null $contentType Explicit response media type for non-JSON responses
* @param array<string, mixed>|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,
) {}
/**

View file

@ -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,
],
];

View file

@ -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();