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
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,50 @@ This is equivalent to the more verbose:
)]
```

### `JsonRequestBody`

A shorthand for JSON request bodies that reference a schema. Reduces nesting by wrapping the ref/type 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).

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

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

#[OAT\Schema(schema: 'LoginRequest', title: 'Login credentials')]
class LoginRequest
{
#[OAT\Property(property: 'email', type: 'string')]
public string $email;

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

class AuthController
{
#[OAT\Post(path: '/auth/login', operationId: 'login')]
#[OAX\JsonRequestBody(ref: LoginRequest::class, required: true)]
public function login(): mixed
{
// description auto-derived as "Login credentials" from schema title
return '...';
}
}
```

This is equivalent to the more verbose:

```php
#[OAT\RequestBody(
description: 'Login credentials',
required: true,
content: new OAT\JsonContent(ref: LoginRequest::class)
)]
```


## License

Expand Down
33 changes: 33 additions & 0 deletions src/Annotations/JsonRequestBody.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php declare(strict_types=1);

namespace Radebatz\OpenApi\Extras\Annotations;

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.
*
* @Annotation
*/
class JsonRequestBody extends OA\RequestBody
{
use JsonContentTrait;

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

$resolved = $this->resolveSource($ref, Generator::isDefault($type) ? null : $type);

if ($resolved['ref'] !== null || $resolved['type'] !== null) {
$jsonContent = new OA\JsonContent(array_filter($resolved));
$properties['value'] = array_merge($properties['value'] ?? [], [$jsonContent]);
}

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

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

/**
* Shorthand for a JSON response with a schema ref or type.
Expand All @@ -13,7 +13,7 @@
*/
class JsonResponse extends OA\Response
{
use JsonResponseTrait;
use JsonContentTrait;

public function __construct(array $properties)
{
Expand Down
44 changes: 44 additions & 0 deletions src/Attributes/JsonRequestBody.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php declare(strict_types=1);

namespace Radebatz\OpenApi\Extras\Attributes;

use OpenApi\Attributes as OAT;
use OpenApi\Generator;
use Radebatz\OpenApi\Extras\JsonContentTrait;

#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class JsonRequestBody extends OAT\RequestBody
{
use JsonContentTrait;

/**
* @param string|class-string $ref
* @param string|class-string|null $type
* @param array<string,mixed>|null $x
* @param OAT\Attachable[]|null $attachables
*/
public function __construct(
string|object $ref = Generator::UNDEFINED,
string|null $type = null,
?string $request = null,
?string $description = null,
?bool $required = true,
?array $x = null,
?array $attachables = null,
) {
$resolved = $this->resolveSource($ref, $type);

$jsonContent = ($resolved['ref'] !== null || $resolved['type'] !== null)
? new OAT\JsonContent(ref: $resolved['ref'], type: $resolved['type'])
: null;

parent::__construct(
request: $request,
description: $description ?? Generator::UNDEFINED,
required: $required,
content: $jsonContent,
x: $x,
attachables: $attachables,
);
}
}
4 changes: 2 additions & 2 deletions src/Attributes/JsonResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

use OpenApi\Attributes as OAT;
use OpenApi\Generator;
use Radebatz\OpenApi\Extras\JsonResponseTrait;
use Radebatz\OpenApi\Extras\JsonContentTrait;

#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class JsonResponse extends OAT\Response
{
use JsonResponseTrait;
use JsonContentTrait;

/**
* @param string|class-string $ref
Expand Down
2 changes: 1 addition & 1 deletion src/JsonResponseTrait.php → src/JsonContentTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use OpenApi\Generator;

trait JsonResponseTrait
trait JsonContentTrait
{
/** @var string|class-string */
public $source = Generator::UNDEFINED;
Expand Down
4 changes: 3 additions & 1 deletion src/OpenApiBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use OpenApi\Processors\BuildPaths;
use OpenApi\Processors\ExpandEnums;
use Psr\Log\LoggerInterface;
use Radebatz\OpenApi\Extras\Processors\AugmentJsonRequestBody;
use Radebatz\OpenApi\Extras\Processors\AugmentJsonResponse;
use Radebatz\OpenApi\Extras\Processors\Customizers;
use Radebatz\OpenApi\Extras\Processors\EnumDescription;
Expand Down Expand Up @@ -196,7 +197,8 @@ public function build(?LoggerInterface $logger = null): Generator

$generator->getProcessorPipeline()
->insert(new MergeControllerDefaults(), BuildPaths::class)
->insert(new AugmentJsonResponse(), BuildPaths::class);
->insert(new AugmentJsonResponse(), BuildPaths::class)
->insert(new AugmentJsonRequestBody(), BuildPaths::class);

$customizers = $this->customizers;
$customizers[OA\Operation::class][] = static function (OA\Operation $operation): void {
Expand Down
48 changes: 48 additions & 0 deletions src/Processors/AugmentJsonRequestBody.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php declare(strict_types=1);

namespace Radebatz\OpenApi\Extras\Processors;

use OpenApi\Analysis;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
use Radebatz\OpenApi\Extras\Annotations as OAX;
use Radebatz\OpenApi\Extras\Attributes as OAXT;

class AugmentJsonRequestBody
{
public function __invoke(Analysis $analysis): void
{
$requestBodies = $analysis->getAnnotationsOfType([OAX\JsonRequestBody::class, OAXT\JsonRequestBody::class]);

foreach ($requestBodies as $requestBody) {
if (!Generator::isDefault($requestBody->description)) {
continue;
}

if (!Generator::isDefault($requestBody->source)) {
$requestBody->description = $this->resolveDescription($requestBody->source, $analysis);
}
}
}

protected function resolveDescription(string $source, Analysis $analysis): string
{
$schema = $analysis->getAnnotationForSource($source, OA\Schema::class);

if ($schema instanceof OA\Schema) {
if (!Generator::isDefault($schema->title)) {
return $schema->title;
}
if (!Generator::isDefault($schema->description)) {
return $schema->description;
}
if (!Generator::isDefault($schema->schema)) {
return $schema->schema;
}
}

$pos = strrpos($source, '\\');

return $pos !== false ? substr($source, $pos + 1) : $source;
}
}
124 changes: 124 additions & 0 deletions tests/JsonRequestBodyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php declare(strict_types=1);

namespace Radebatz\OpenApi\Extras\Tests;

use OpenApi\Analysis;
use OpenApi\Annotations as OA;
use OpenApi\Context;
use OpenApi\Generator;
use PHPUnit\Framework\TestCase;
use Radebatz\OpenApi\Extras\Annotations\JsonRequestBody as JsonRequestBodyAnnotation;
use Radebatz\OpenApi\Extras\Attributes\JsonRequestBody;
use Radebatz\OpenApi\Extras\Processors\AugmentJsonRequestBody;
use Radebatz\OpenApi\Extras\Tests\Fixtures\Models\TokenPairResource;

class JsonRequestBodyTest extends TestCase
{
public function testAttributeWithRef(): void
{
$requestBody = new JsonRequestBody(ref: TokenPairResource::class);

$this->assertNotEmpty($requestBody->_unmerged);
$this->assertInstanceOf(OA\JsonContent::class, $requestBody->_unmerged[0]);
}

public function testAttributeExplicitDescription(): void
{
$requestBody = new JsonRequestBody(ref: TokenPairResource::class, description: 'Custom desc');

$this->assertEquals('Custom desc', $requestBody->description);
}

public function testAttributeNoRef(): void
{
$requestBody = new JsonRequestBody();

$this->assertEquals(Generator::UNDEFINED, $requestBody->description);
}

public function testAttributeRequired(): void
{
$requestBody = new JsonRequestBody(ref: TokenPairResource::class, required: true);

$this->assertTrue($requestBody->required);
}

public function testAnnotationWithRef(): void
{
$requestBody = new JsonRequestBodyAnnotation([
'ref' => TokenPairResource::class,
]);

$this->assertNotEmpty($requestBody->_unmerged);
$this->assertInstanceOf(OA\JsonContent::class, $requestBody->_unmerged[0]);
}

public function testProcessorResolvesDescriptionFromTitle(): void
{
$analysis = $this->createAnalysisWithSchema(TokenPairResource::class, 'Token pair', Generator::UNDEFINED);

$requestBody = new JsonRequestBody(ref: TokenPairResource::class);
$analysis->addAnnotation($requestBody, new Context([]));

(new AugmentJsonRequestBody())($analysis);

$this->assertEquals('Token pair', $requestBody->description);
}

public function testProcessorResolvesDescriptionFromSchemaDescription(): void
{
$analysis = $this->createAnalysisWithSchema(TokenPairResource::class, Generator::UNDEFINED, 'A token pair request');

$requestBody = new JsonRequestBody(ref: TokenPairResource::class);
$analysis->addAnnotation($requestBody, new Context([]));

(new AugmentJsonRequestBody())($analysis);

$this->assertEquals('A token pair request', $requestBody->description);
}

public function testProcessorFallsBackToClassName(): void
{
$analysis = new Analysis([], new Context([]));

$requestBody = new JsonRequestBody(ref: 'App\\Models\\SomeUnknownClass');
$analysis->addAnnotation($requestBody, new Context([]));

(new AugmentJsonRequestBody())($analysis);

$this->assertEquals('SomeUnknownClass', $requestBody->description);
}

public function testProcessorSkipsExplicitDescription(): void
{
$analysis = $this->createAnalysisWithSchema(TokenPairResource::class, 'Token pair', Generator::UNDEFINED);

$requestBody = new JsonRequestBody(ref: TokenPairResource::class, description: 'My desc');
$analysis->addAnnotation($requestBody, new Context([]));

(new AugmentJsonRequestBody())($analysis);

$this->assertEquals('My desc', $requestBody->description);
}

protected function createAnalysisWithSchema(string $class, string $title, string $description): Analysis
{
$shortName = substr($class, strrpos($class, '\\') + 1);
$namespace = substr($class, 0, strrpos($class, '\\'));

$context = new Context(['namespace' => $namespace, 'class' => $shortName]);
$schema = new OA\Schema([
'schema' => $shortName,
'title' => $title,
'description' => $description,
'_context' => $context,
]);
$context->annotations = [$schema];

$analysis = new Analysis([], new Context([]));
$analysis->addClassDefinition(['class' => $shortName, 'context' => $context]);
$analysis->addAnnotation($schema, $context);

return $analysis;
}
}