-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
14 changed files
with
545 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
200 changes: 200 additions & 0 deletions
200
src/HttpKernel/Controller/ArgumentResolver/RequestPayloadArrayResolver.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,200 @@ | ||
<?php | ||
|
||
namespace OpenSolid\OpenApiBundle\HttpKernel\Controller\ArgumentResolver; | ||
|
||
use OpenSolid\OpenApiBundle\Attribute\Body; | ||
use Symfony\Component\EventDispatcher\EventSubscriberInterface; | ||
use Symfony\Component\HttpFoundation\Request; | ||
use Symfony\Component\HttpFoundation\Response; | ||
use Symfony\Component\HttpKernel\Attribute\MapQueryString; | ||
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; | ||
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; | ||
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; | ||
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; | ||
use Symfony\Component\HttpKernel\Exception\HttpException; | ||
use Symfony\Component\HttpKernel\KernelEvents; | ||
use Symfony\Component\Serializer\Exception\NotEncodableValueException; | ||
use Symfony\Component\Serializer\Exception\PartialDenormalizationException; | ||
use Symfony\Component\Serializer\Exception\UnsupportedFormatException; | ||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; | ||
use Symfony\Component\Serializer\SerializerInterface; | ||
use Symfony\Component\Validator\ConstraintViolation; | ||
use Symfony\Component\Validator\ConstraintViolationList; | ||
use Symfony\Component\Validator\Exception\ValidationFailedException; | ||
use Symfony\Component\Validator\Validator\ValidatorInterface; | ||
use Symfony\Contracts\Translation\TranslatorInterface; | ||
|
||
class RequestPayloadArrayResolver implements ValueResolverInterface, EventSubscriberInterface | ||
{ | ||
/** | ||
* @see \Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT | ||
* @see DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS | ||
*/ | ||
private const CONTEXT_DENORMALIZE = [ | ||
'disable_type_enforcement' => true, | ||
'collect_denormalization_errors' => true, | ||
]; | ||
|
||
/** | ||
* @see DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS | ||
*/ | ||
private const CONTEXT_DESERIALIZE = [ | ||
'collect_denormalization_errors' => true, | ||
]; | ||
|
||
public function __construct( | ||
private readonly SerializerInterface&DenormalizerInterface $serializer, | ||
private readonly ?ValidatorInterface $validator = null, | ||
private readonly ?TranslatorInterface $translator = null, | ||
) { | ||
} | ||
|
||
public function resolve(Request $request, ArgumentMetadata $argument): iterable | ||
{ | ||
$attribute = $argument->getAttributesOfType(MapQueryString::class, ArgumentMetadata::IS_INSTANCEOF)[0] | ||
?? $argument->getAttributesOfType(MapRequestPayload::class, ArgumentMetadata::IS_INSTANCEOF)[0] | ||
?? null; | ||
|
||
if (!$attribute) { | ||
return []; | ||
} | ||
|
||
if ($argument->isVariadic()) { | ||
throw new \LogicException(sprintf('Mapping variadic argument "$%s" is not supported.', $argument->getName())); | ||
} | ||
|
||
if ('array' !== $argument->getType()) { | ||
return [$attribute]; | ||
} | ||
|
||
if ($attribute instanceof Body && null !== $attribute->itemsType) { | ||
$attribute->metadata = new ArgumentMetadata( | ||
$argument->getName(), | ||
$attribute->itemsType.'[]', | ||
$argument->isVariadic(), | ||
$argument->hasDefaultValue(), | ||
$argument->hasDefaultValue() ? $argument->getDefaultValue() : null, | ||
$argument->isNullable(), | ||
$argument->getAttributes(), | ||
); | ||
} else { | ||
$attribute->metadata = $argument; | ||
} | ||
|
||
return [$attribute]; | ||
} | ||
|
||
public function onKernelControllerArguments(ControllerArgumentsEvent $event): void | ||
{ | ||
$arguments = $event->getArguments(); | ||
|
||
foreach ($arguments as $i => $argument) { | ||
if ($argument instanceof MapQueryString) { | ||
$payloadMapper = 'mapQueryString'; | ||
$validationFailedCode = $argument->validationFailedStatusCode; | ||
} elseif ($argument instanceof MapRequestPayload) { | ||
$payloadMapper = 'mapRequestPayload'; | ||
$validationFailedCode = $argument->validationFailedStatusCode; | ||
} else { | ||
continue; | ||
} | ||
$request = $event->getRequest(); | ||
|
||
if (!$type = $argument->metadata->getType()) { | ||
throw new \LogicException(sprintf('Could not resolve the "$%s" controller argument: argument should be typed.', $argument->metadata->getName())); | ||
} | ||
|
||
if ($this->validator) { | ||
$violations = new ConstraintViolationList(); | ||
try { | ||
$payload = $this->$payloadMapper($request, $type, $argument); | ||
} catch (PartialDenormalizationException $e) { | ||
$trans = $this->translator ? $this->translator->trans(...) : fn ($m, $p) => strtr($m, $p); | ||
foreach ($e->getErrors() as $error) { | ||
$parameters = ['{{ type }}' => implode('|', $error->getExpectedTypes())]; | ||
if ($error->canUseMessageForUser()) { | ||
$parameters['hint'] = $error->getMessage(); | ||
} | ||
$template = 'This value should be of type {{ type }}.'; | ||
$message = $trans($template, $parameters, 'validators'); | ||
$violations->add(new ConstraintViolation($message, $template, $parameters, null, $error->getPath(), null)); | ||
} | ||
$payload = $e->getData(); | ||
} | ||
|
||
if (null !== $payload && !\count($violations)) { | ||
$violations->addAll($this->validator->validate($payload, null, $argument->validationGroups ?? null)); | ||
} | ||
|
||
if (\count($violations)) { | ||
throw new HttpException($validationFailedCode, implode("\n", array_map(static fn ($e) => $e->getMessage(), iterator_to_array($violations))), new ValidationFailedException($payload, $violations)); | ||
} | ||
} else { | ||
try { | ||
$payload = $this->$payloadMapper($request, $type, $argument); | ||
} catch (PartialDenormalizationException $e) { | ||
throw new HttpException($validationFailedCode, implode("\n", array_map(static fn ($e) => $e->getMessage(), $e->getErrors())), $e); | ||
} | ||
} | ||
|
||
if (null === $payload) { | ||
$payload = match (true) { | ||
$argument->metadata->hasDefaultValue() => $argument->metadata->getDefaultValue(), | ||
$argument->metadata->isNullable() => null, | ||
default => throw new HttpException($validationFailedCode) | ||
}; | ||
} | ||
|
||
$arguments[$i] = $payload; | ||
} | ||
|
||
$event->setArguments($arguments); | ||
} | ||
|
||
public static function getSubscribedEvents(): array | ||
{ | ||
return [ | ||
KernelEvents::CONTROLLER_ARGUMENTS => 'onKernelControllerArguments', | ||
]; | ||
} | ||
|
||
private function mapQueryString(Request $request, string $type, MapQueryString $attribute): ?object | ||
{ | ||
if (!$data = $request->query->all()) { | ||
return null; | ||
} | ||
|
||
return $this->serializer->denormalize($data, $type, null, $attribute->serializationContext + self::CONTEXT_DENORMALIZE); | ||
} | ||
|
||
private function mapRequestPayload(Request $request, string $type, MapRequestPayload $attribute): object|array|null | ||
{ | ||
if (null === $format = $request->getContentTypeFormat()) { | ||
throw new HttpException(Response::HTTP_UNSUPPORTED_MEDIA_TYPE, 'Unsupported format.'); | ||
} | ||
|
||
if ($attribute->acceptFormat && !\in_array($format, (array) $attribute->acceptFormat, true)) { | ||
throw new HttpException(Response::HTTP_UNSUPPORTED_MEDIA_TYPE, sprintf('Unsupported format, expects "%s", but "%s" given.', implode('", "', (array) $attribute->acceptFormat), $format)); | ||
} | ||
|
||
if ($data = $request->request->all()) { | ||
return $this->serializer->denormalize($data, $type, null, $attribute->serializationContext + self::CONTEXT_DENORMALIZE); | ||
} | ||
|
||
if ('' === $data = $request->getContent()) { | ||
return null; | ||
} | ||
|
||
if ('form' === $format) { | ||
throw new HttpException(Response::HTTP_BAD_REQUEST, 'Request payload contains invalid "form" data.'); | ||
} | ||
|
||
try { | ||
return $this->serializer->deserialize($data, $type, $format, self::CONTEXT_DESERIALIZE + $attribute->serializationContext); | ||
} catch (UnsupportedFormatException $e) { | ||
throw new HttpException(Response::HTTP_UNSUPPORTED_MEDIA_TYPE, sprintf('Unsupported format: "%s".', $format), $e); | ||
} catch (NotEncodableValueException $e) { | ||
throw new HttpException(Response::HTTP_BAD_REQUEST, sprintf('Request payload contains invalid "%s" data.', $format), $e); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -27,16 +27,22 @@ public function guess(\Reflector $reflector, AbstractAnnotation $annotation, Con | |
} | ||
|
||
foreach ($reflector->getParameters() as $rp) { | ||
foreach ($rp->getAttributes(Body::class, \ReflectionAttribute::IS_INSTANCEOF) as $_) { | ||
foreach ($rp->getAttributes(Body::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) { | ||
$type = (($rnt = $rp->getType()) && $rnt instanceof \ReflectionNamedType) ? $rnt->getName() : null; | ||
|
||
if (null === $type) { | ||
continue; | ||
} | ||
|
||
/** @var Body $bodyAttribute */ | ||
Check failure on line 37 in src/OpenApi/Analyser/Guesser/Operation/OperationRequestBodyGuesser.php GitHub Actions / PsalmUnnecessaryVarAnnotation
|
||
$bodyAttribute = $attribute->newInstance(); | ||
|
||
$annotation->requestBody = new OA\RequestBody(required: !$rnt->allowsNull()); | ||
$annotation->requestBody->_context = new Context(['nested' => $annotation], $context); | ||
$jsonContent = new OA\JsonContent(type: $type); | ||
if ('array' === $type && null !== $bodyAttribute->itemsType) { | ||
$jsonContent->items = new OA\Items(type: $bodyAttribute->itemsType); | ||
} | ||
$jsonContent->_context = new Context(['nested' => $annotation->requestBody], $context); | ||
$annotation->requestBody->merge([$jsonContent]); | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,6 +9,7 @@ | |
background-color: white; | ||
} | ||
</style> | ||
<title>Swagger API Doc</title> | ||
</head> | ||
<body> | ||
<?php | ||
|
17 changes: 17 additions & 0 deletions
17
tests/Functional/App/PostResourcesAction/Controller/PostResourceBody.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
<?php | ||
|
||
namespace OpenSolid\Tests\OpenApiBundle\Functional\App\PostResourcesAction\Controller; | ||
|
||
use OpenApi\Attributes\Schema; | ||
use OpenSolid\OpenApiBundle\Attribute\Property; | ||
use OpenSolid\Tests\OpenApiBundle\Functional\App\PostResourceAction\Model\ResourceStatus; | ||
|
||
#[Schema(writeOnly: true)] | ||
class PostResourceBody | ||
{ | ||
#[Property(minLength: 3)] | ||
public string $name; | ||
|
||
#[Property(enum: ResourceStatus::class)] | ||
public string $status; | ||
} |
16 changes: 16 additions & 0 deletions
16
tests/Functional/App/PostResourcesAction/Controller/PostResourcesAction.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
<?php | ||
|
||
namespace OpenSolid\Tests\OpenApiBundle\Functional\App\PostResourcesAction\Controller; | ||
|
||
use OpenSolid\OpenApiBundle\Attribute\Body; | ||
use OpenSolid\OpenApiBundle\Routing\Attribute\Post; | ||
use OpenSolid\Tests\OpenApiBundle\Functional\App\PostResourcesAction\Model\ResourceView; | ||
|
||
class PostResourcesAction | ||
{ | ||
#[Post('/resources')] | ||
public function __invoke(#[Body(itemsType: PostResourceBody::class)] array $body): ResourceView | ||
{ | ||
return ResourceView::from('4f09d694-446a-4769-9929-dad96a071cad', $body[0]->name); | ||
} | ||
} |
9 changes: 9 additions & 0 deletions
9
tests/Functional/App/PostResourcesAction/Model/ResourceStatus.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
<?php | ||
|
||
namespace OpenSolid\Tests\OpenApiBundle\Functional\App\PostResourcesAction\Model; | ||
|
||
enum ResourceStatus: string | ||
{ | ||
case DRAFT = 'draft'; | ||
case PUBLISHED = 'published'; | ||
} |
25 changes: 25 additions & 0 deletions
25
tests/Functional/App/PostResourcesAction/Model/ResourceView.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
<?php | ||
|
||
namespace OpenSolid\Tests\OpenApiBundle\Functional\App\PostResourcesAction\Model; | ||
|
||
use OpenApi\Attributes\Schema; | ||
use OpenSolid\OpenApiBundle\Attribute\Property; | ||
|
||
#[Schema] | ||
readonly class ResourceView | ||
{ | ||
#[Property(format: 'uuid')] | ||
public string $id; | ||
|
||
#[Property] | ||
public string $name; | ||
|
||
public static function from(string $id, string $name): self | ||
{ | ||
$self = new self(); | ||
$self->id = $id; | ||
$self->name = $name; | ||
|
||
return $self; | ||
} | ||
} |
Oops, something went wrong.