Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 35 additions & 61 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,57 +177,6 @@ class UserController extends BaseController
}
```

### `DataSchema`

The `DataSchema` annotation/attribute wraps properties inside a `data` object envelope, reducing boilerplate for APIs that use a standard wrapper pattern.

Properties with `nullable: false` are automatically added to the `data` object's `required` list.
You can also explicitly pass a `required` list in the constructor.

```php
<?php declare(strict_types=1);

use OpenApi\Attributes as OAT;
use Radebatz\OpenApi\Extras\Attributes as OAX;

#[OAX\DataSchema(schema: 'UserResource')]
class UserResource
{
#[OAT\Property(property: 'id', type: 'integer', nullable: false)]
public int $id;

#[OAT\Property(property: 'name', type: 'string', nullable: false)]
public string $name;

#[OAT\Property(property: 'email', type: 'string')]
public string $email;
}
```

This generates a schema equivalent to:

```yaml
UserResource:
required:
- data
properties:
data:
required:
- id
- name
properties:
id:
type: integer
nullable: false
name:
type: string
nullable: false
email:
type: string
type: object
type: object
```

### `Middleware`

`Middleware` annotations allow to attach a list of middleware names either individually or across all operations (via the `Controller` annotation).
Expand Down Expand Up @@ -311,10 +260,14 @@ The `customizers()` method returns the same mapping format as the `Customizers`

### `JsonResponse`

A shorthand for JSON responses that reference a schema. Reduces nesting by wrapping the ref/type in a `JsonContent` automatically.
A shorthand for JSON responses that reference a schema. Wraps the referenced schema in an envelope property (default `"data"`, matching Laravel's `JsonResource::$wrap`). For unwrapped responses, use the regular `OAT\Response` annotation.

If no `description` is provided, it is derived from the referenced schema (fallback order: title > description > schema name > class short name).

| Parameter | Default | Effect |
|---------------|----------|-----------------------------------|
| `wrap` | `'data'` | Property name for the envelope |

```php
<?php declare(strict_types=1);

Expand All @@ -334,29 +287,50 @@ class TokenPairResource
class AuthController
{
#[OAT\Post(path: '/auth/login', operationId: 'login')]
// Wrapped (default): {"data": {$ref: TokenPairResource}}
#[OAX\JsonResponse(response: 200, ref: TokenPairResource::class)]
#[OAX\JsonResponse(response: 401, description: 'Invalid credentials')]
public function login(): mixed
{
// response 200 description auto-derived as "Token pair" from schema title
return '...';
}

#[OAT\Get(path: '/auth/session', operationId: 'session')]
// Custom wrap key: {"result": {$ref: TokenPairResource}}
#[OAX\JsonResponse(response: 200, ref: TokenPairResource::class, wrap: 'result')]
public function session(): mixed
{
return '...';
}

#[OAT\Get(path: '/auth/tokens', operationId: 'tokens')]
// List: {"data": [{$ref: TokenPairResource}, ...]}
#[OAX\JsonResponse(
response: 200,
content: new OAT\JsonContent(type: 'array', items: new OAT\Items(ref: TokenPairResource::class)),
)]
public function tokens(): mixed
{
return '...';
}
}
```

This is equivalent to the more verbose:
This generates:

```php
#[OAT\Response(
response: 200,
description: 'Token pair',
content: new OAT\JsonContent(ref: TokenPairResource::class)
)]
```yaml
content:
application/json:
schema:
required: [data]
properties:
data:
$ref: '#/components/schemas/TokenPairResource'
```

### `JsonRequestBody`

A shorthand for JSON request bodies that reference a schema. Reduces nesting by wrapping the ref/type in a `JsonContent` automatically.
A shorthand for JSON request bodies that reference a schema. Reduces nesting by wrapping the ref in a `JsonContent` automatically.

If no `description` is provided, it is derived from the referenced schema (fallback order: title > description > schema name > class short name).

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"php": ">=8.1",
"psr/log": "^2.0 || ^3.0",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0",
"zircote/swagger-php": "^4.11.1 || ^5.0 || ^6.0.3"
"zircote/swagger-php": "^5.0 || ^6.0.3"
},
"require-dev": {
"composer/package-versions-deprecated": "^1.11",
Expand Down
19 changes: 13 additions & 6 deletions docs/adr/002-processor-pipeline-ordering.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Accepted

## Context

The swagger-php `Generator` runs annotations through a pipeline of processors in a fixed order. This library adds three custom processors that must be inserted at specific positions relative to the default pipeline to function correctly.
The swagger-php `Generator` runs annotations through a pipeline of processors in a fixed order. This library adds custom processors that must be inserted at specific positions relative to the default pipeline to function correctly.

## Decision

Expand All @@ -24,14 +24,21 @@ This processor generates human-readable descriptions from PHP enum cases. It mus

- `ExpandEnums` replaces enum class references with their scalar values. After it runs, the link back to the `ReflectionEnum` (needed to enumerate case names) is lost.

### `MiddlewareCustomizers` — inserted before `AugmentParameters`
### `AugmentJsonResponse` / `AugmentJsonRequestBody` — inserted before `BuildPaths`

This processor applies scoped customizers from middleware classes implementing `ProvidesCustomizersInterface`. It runs **after** `BuildPaths` because:
These processors create `JsonContent` from `source` (the `ref` parameter) when no explicit content is provided, and resolve descriptions from the referenced schema. They must run **before** `BuildPaths` because:

- Operations must be fully assembled with their merged middleware attachables (done by `MergeControllerDefaults` before `BuildPaths`).
- It needs to see the final operation structure to apply mutations like `security`.
- `BuildPaths` needs the response/request body structure to be in place.
- The generated `JsonContent` must be available for `MergeJsonContent` (which runs later) to convert into `MediaType`.

It runs **before** `AugmentParameters` so that any parameters or references added by middleware customizers are still processed by the standard augmentation pipeline.
### `WrapJsonResponseContent` — inserted before `OperationId` (after `MergeJsonContent`)

This processor wraps the resolved schema of `JsonResponse` instances inside an envelope property. It must run **after** `MergeJsonContent` because:

- `MergeJsonContent` converts `JsonContent` annotations into `MediaType` with a resolved `schema`. The wrapper needs that fully resolved schema to nest it inside the envelope property.
- Running earlier would mean the schema hasn't been assembled yet.

It pairs with `AugmentJsonResponse` (which runs early to create `JsonContent` from `ref`/`source` and resolve descriptions). The two together form a create-then-wrap pipeline.

### `Customizers` — appended at the end

Expand Down
25 changes: 25 additions & 0 deletions docs/adr/004-response-wrapping-as-transport-concern.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# ADR-004: Response Wrapping as a Transport Concern

## Status

Accepted

## Context

APIs commonly wrap resource payloads in an envelope (e.g. `{"data": ...}`). Many frameworks follow this convention — for example Laravel's `JsonResource` defaults to `$wrap = 'data'` — but the pattern is framework-agnostic.

The question is where this wrapping should be expressed in the OpenAPI spec: on the schema definition (data shape) or on the response annotation (transport layer).

## Decision

Wrapping belongs on the response, not the schema.

`JsonResponse` accepts a `wrap` parameter (default `'data'`) that tells the `WrapJsonResponseContent` processor to wrap the resolved schema inside an inline envelope with the wrap key as a required property.

Schemas remain pure data shapes — they describe the resource, not how it's delivered.

## Consequences

- A single schema can be referenced by responses with different envelope conventions (or no envelope at all via a regular `OAT\Response`).
- The processor generates the wrapper inline per-response, so there's no shared "envelope" schema polluting components.
- If additional top-level properties are needed alongside the wrap key (pagination, links), the processor can be extended without changing schema definitions.
7 changes: 6 additions & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
parameters:
ignoreErrors: []
ignoreErrors:
-
message: '#^Call to function method_exists\(\) with OpenApi\\Analysis and ''removeAnnotation'' will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Processors/WrapJsonResponseContent.php
79 changes: 0 additions & 79 deletions src/Annotations/DataSchema.php

This file was deleted.

15 changes: 7 additions & 8 deletions src/Annotations/JsonRequestBody.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,23 @@

use OpenApi\Annotations as OA;
use OpenApi\Generator;
use Radebatz\OpenApi\Extras\JsonContentTrait;

/**
* Shorthand for a JSON request body with a schema ref or type.
* Shorthand for a JSON request body with a schema ref.
*
* @Annotation
*/
class JsonRequestBody extends OA\RequestBody
{
use JsonContentTrait;
/** @var string|class-string */
public string|object $source = Generator::UNDEFINED;

public static $_blacklist = ['_context', '_unmerged', '_analysis', 'attachables', 'source'];

public function __construct(array $properties)
{
$ref = $properties['ref'] ?? Generator::UNDEFINED;
$type = $properties['type'] ?? Generator::UNDEFINED;
unset($properties['ref'], $properties['type']);

$this->resolveSource($ref, Generator::isDefault($type) ? null : $type);
$this->source = $properties['ref'] ?? Generator::UNDEFINED;
unset($properties['ref']);

parent::__construct($properties);
}
Expand Down
18 changes: 10 additions & 8 deletions src/Annotations/JsonResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,26 @@

use OpenApi\Annotations as OA;
use OpenApi\Generator;
use Radebatz\OpenApi\Extras\JsonContentTrait;

/**
* Shorthand for a JSON response with a schema ref or type.
* Shorthand for a JSON response with a schema ref.
*
* @Annotation
*/
class JsonResponse extends OA\Response
{
use JsonContentTrait;
/** @var string|class-string */
public string|object $source = Generator::UNDEFINED;

public string $wrap = 'data';

public static $_blacklist = ['_context', '_unmerged', '_analysis', 'attachables', 'source', 'wrap'];

public function __construct(array $properties)
{
$ref = $properties['ref'] ?? Generator::UNDEFINED;
$type = $properties['type'] ?? Generator::UNDEFINED;
unset($properties['ref'], $properties['type']);

$this->resolveSource($ref, Generator::isDefault($type) ? null : $type);
$this->source = $properties['ref'] ?? Generator::UNDEFINED;
$this->wrap = $properties['wrap'] ?? 'data';
unset($properties['ref'], $properties['wrap']);

parent::__construct($properties);
}
Expand Down
Loading