diff --git a/ArgumentResolver/RequestDataExtractor/DefaultRequestDataExtractor.php b/ArgumentResolver/RequestDataExtractor/DefaultRequestDataExtractor.php new file mode 100644 index 0000000..ea36a95 --- /dev/null +++ b/ArgumentResolver/RequestDataExtractor/DefaultRequestDataExtractor.php @@ -0,0 +1,64 @@ +decoder = $decoder; + } + + public function supports(EndpointInterface $endpoint): bool + { + return true; + } + + public function extract(Request $request, EndpointInterface $endpoint): array + { + $decoded = []; + + $body = $request->getContent(); + if ('' !== $body) { + $decoded = $this->decoder->decode($body, $endpoint->getRequestFormat()); + + // A scalar body (e.g. JSON `0` or `"foo"`) decodes without error but cannot feed + // array_merge(); UnexpectedValueException is a Serializer ExceptionInterface, so + // ServiceRequestResolver converts it to a 400 instead of a TypeError-driven 500. + if (!is_array($decoded)) { + throw new UnexpectedValueException( + sprintf('Request body must decode to an array, got "%s".', get_debug_type($decoded)) + ); + } + } + + return array_merge( + $decoded, + $request->attributes->all(), + $request->query->all() + ); + } +} diff --git a/ArgumentResolver/RequestDataExtractor/MultipartRequestDataExtractor.php b/ArgumentResolver/RequestDataExtractor/MultipartRequestDataExtractor.php new file mode 100644 index 0000000..ff4860c --- /dev/null +++ b/ArgumentResolver/RequestDataExtractor/MultipartRequestDataExtractor.php @@ -0,0 +1,141 @@ +streamFactory = $streamFactory; + } + + public function supports(EndpointInterface $endpoint): bool + { + return EndpointInterface::FORMAT_MULTIPART === $endpoint->getRequestFormat(); + } + + public function extract(Request $request, EndpointInterface $endpoint): array + { + // PHP populates $_POST / $_FILES only when the wire method is POST; method overrides + // (_method / X-HTTP-METHOD-OVERRIDE) are applied after parsing, so the real method + // decides whether the body was parsed — an overridden wire POST is fine. + if (Request::METHOD_POST !== $request->getRealMethod()) { + throw new BadRequestHttpException( + sprintf( + 'multipart/form-data endpoints must be sent as POST, got "%s".', + $request->getRealMethod() + ) + ); + } + + // Media types are case-insensitive (RFC 9110) and may carry parameters (boundary). + $contentType = (string) $request->headers->get('CONTENT_TYPE'); + $mimeTypeParts = explode(';', $contentType, 2); + $mimeType = strtolower(trim($mimeTypeParts[0])); + if (!in_array($mimeType, self::FORM_MIME_TYPES, true)) { + throw new BadRequestHttpException( + sprintf( + 'Expected "%s" content type, got "%s".', + self::MIME_TYPE_MULTIPART, + $contentType + ) + ); + } + + // A body PHP failed to parse (e.g. post_max_size exceeded) leaves both bags empty + // while the body itself is non-empty — reject it instead of letting the request + // through with a silently empty payload. + if (0 === $request->request->count() + && 0 === $request->files->count() + && 0 < (int) $request->headers->get('CONTENT_LENGTH') + ) { + throw new BadRequestHttpException( + 'Form body could not be parsed — the "post_max_size" limit may be exceeded.' + ); + } + + return array_merge( + $request->request->all(), + $this->wrapFiles($request->files->all()), + $request->attributes->all(), + $request->query->all() + ); + } + + private function wrapFiles(array $files): array + { + $wrapped = []; + foreach ($files as $key => $value) { + if (is_array($value)) { + $wrapped[$key] = $this->wrapFiles($value); + continue; + } + + // An unfilled optional file input arrives as null and is intentionally + // omitted, so the request DTO keeps its default value. + if (!$value instanceof UploadedFile) { + continue; + } + + if (!$value->isValid()) { + throw new BadRequestHttpException( + sprintf('Upload failed for field "%s": %s', $key, $value->getErrorMessage()) + ); + } + + if (null === $this->streamFactory) { + throw new LogicException( + sprintf( + 'A PSR-17 "%s" must be wired to handle multipart/form-data file uploads. ' + . 'Install a PSR-7 implementation (e.g. guzzlehttp/psr7, nyholm/psr7) ' + . 'and register its stream factory.', + StreamFactoryInterface::class + ) + ); + } + + $stream = $this->streamFactory->createStreamFromFile($value->getPathname(), 'r'); + $wrapped[$key] = new UploadedFileStream($stream, $value); + } + + return $wrapped; + } +} \ No newline at end of file diff --git a/ArgumentResolver/RequestDataExtractor/RequestDataExtractorInterface.php b/ArgumentResolver/RequestDataExtractor/RequestDataExtractorInterface.php new file mode 100644 index 0000000..5a08fbd --- /dev/null +++ b/ArgumentResolver/RequestDataExtractor/RequestDataExtractorInterface.php @@ -0,0 +1,28 @@ + */ - private $serviceResponseListener; + private iterable $requestDataExtractors; /** - * ServiceRequestResolver constructor. - * - * @param SerializerInterface $serializer - * @param EndpointRegistryInterface $endpointRegistry - * @param ServiceResponseListener $serviceResponseListener + * @param iterable $requestDataExtractors */ public function __construct( - SerializerInterface $serializer, + DenormalizerInterface $denormalizer, EndpointRegistryInterface $endpointRegistry, - ServiceResponseListener $serviceResponseListener + ServiceResponseListener $serviceResponseListener, + iterable $requestDataExtractors ) { - $this->serializer = $serializer; + $this->denormalizer = $denormalizer; $this->endpointRegistry = $endpointRegistry; $this->serviceResponseListener = $serviceResponseListener; + $this->requestDataExtractors = $requestDataExtractors; } /** - * {@inheritdoc} + * {@inheritdoc} */ public function resolve(Request $request, ArgumentMetadata $argument): iterable { @@ -70,48 +69,65 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable return []; } - $endpoint = $this->endpointRegistry->getEndpoint( - (new \ReflectionClass($argument->getType()))->newInstanceWithoutConstructor() - ); + $endpoint = $this->endpointRegistry + ->getEndpoint( + (new ReflectionClass($argument->getType())) + ->newInstanceWithoutConstructor() + ) + ; if ($endpoint->getRequestClass() !== $argument->getType()) { - throw new \LogicException('Incorrect resolving'); + throw new LogicException('Incorrect resolving'); } try { - $requestVars = array_merge( - !empty($request->getContent()) - ? $this->serializer->decode( - $request->getContent(), - $endpoint->getRequestFormat() - ) - : [] - , - $request->attributes->all(), - $request->query->all() - ); + $requestVars = $this->resolveExtractor($endpoint)->extract($request, $endpoint); $this->serviceResponseListener->addExpectedRequestEndpoint($request, $endpoint); - yield $this->serializer->denormalize( - $requestVars, - $endpoint->getRequestClass(), - $endpoint->getRequestFormat(), - [ - AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => true, - ] - ); + yield $this->denormalizer + ->denormalize( + $requestVars, + $endpoint->getRequestClass(), + $endpoint->getRequestFormat(), + [ + AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => true, + ] + ) + ; } catch (ExceptionInterface $exception) { - $this->getLogger()->warning( - 'Request deserialization exception', - [ - 'exception_message' => $exception->getMessage(), - ] - ); + $this->getLogger() + ->warning( + sprintf( + 'Request deserialization exception: %s', + $exception->getMessage() + ), + [ + 'exception' => $exception, + ] + ) + ; + throw new BadRequestHttpException('Request deserialization error'); } } + private function resolveExtractor(EndpointInterface $endpoint): RequestDataExtractorInterface + { + foreach ($this->requestDataExtractors as $extractor) { + if ($extractor->supports($endpoint)) { + return $extractor; + } + } + + throw new LogicException( + sprintf( + 'No request data extractor supports endpoint with format "%s".', + $endpoint->getRequestFormat() + ) + ); + } + private function supports(ArgumentMetadata $argument): bool { return is_subclass_of($argument->getType(), ServiceRequestInterface::class, true); diff --git a/Auto1ServiceAPIHandlerBundle.php b/Auto1ServiceAPIHandlerBundle.php index 1da4ea9..eefae9e 100644 --- a/Auto1ServiceAPIHandlerBundle.php +++ b/Auto1ServiceAPIHandlerBundle.php @@ -12,6 +12,8 @@ namespace Auto1\ServiceAPIHandlerBundle; use Auto1\ServiceAPIHandlerBundle\DependencyInjection\CompilerPass\EndpointRouterCompilerPass; +use Auto1\ServiceAPIHandlerBundle\DependencyInjection\CompilerPass\MultipartStreamFactoryCompilerPass; +use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -22,5 +24,13 @@ public function build(ContainerBuilder $container): void parent::build($container); $container->addCompilerPass(new EndpointRouterCompilerPass()); + + // After the components-bundle EndpointProviderCompilerPass (default priority 0), so + // the endpoints are already baked into the registry definition when the guard runs. + $container->addCompilerPass( + new MultipartStreamFactoryCompilerPass(), + PassConfig::TYPE_BEFORE_OPTIMIZATION, + -10 + ); } } diff --git a/DependencyInjection/Auto1ServiceAPIHandlerExtension.php b/DependencyInjection/Auto1ServiceAPIHandlerExtension.php index a513e21..c734de7 100644 --- a/DependencyInjection/Auto1ServiceAPIHandlerExtension.php +++ b/DependencyInjection/Auto1ServiceAPIHandlerExtension.php @@ -11,6 +11,7 @@ namespace Auto1\ServiceAPIHandlerBundle\DependencyInjection; +use Auto1\ServiceAPIHandlerBundle\ArgumentResolver\RequestDataExtractor\RequestDataExtractorInterface; use Nelmio\ApiDocBundle\NelmioApiDocBundle; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -35,6 +36,11 @@ public function load(array $configs, ContainerBuilder $container) //Load config files $loader->load('services.yml'); + $container + ->registerForAutoconfiguration(RequestDataExtractorInterface::class) + ->addTag('auto1.api_handler.request_data_extractor') + ; + if (!class_exists('EXSyst\Component\Swagger\Swagger') || class_exists('OpenApi\Annotations\OpenApi')) { $container->removeDefinition('auto1.route_describers.route_metadata'); } diff --git a/DependencyInjection/CompilerPass/MultipartStreamFactoryCompilerPass.php b/DependencyInjection/CompilerPass/MultipartStreamFactoryCompilerPass.php new file mode 100644 index 0000000..966aa71 --- /dev/null +++ b/DependencyInjection/CompilerPass/MultipartStreamFactoryCompilerPass.php @@ -0,0 +1,123 @@ +has(StreamFactoryInterface::class)) { + return; + } + + if (!$container->hasParameter(self::CONTROLLER_MAPPING_PARAMETER)) { + return; + } + + $handledRequestClasses = $container->getParameter(self::CONTROLLER_MAPPING_PARAMETER); + + $multipartRequestClasses = []; + foreach ($this->getEndpoints($container) as $endpoint) { + if (EndpointInterface::FORMAT_MULTIPART !== $endpoint->getRequestFormat()) { + continue; + } + + $requestClass = $endpoint->getRequestClass(); + if (!in_array($requestClass, $handledRequestClasses, true)) { + continue; + } + + $multipartRequestClasses[$requestClass] = $requestClass; + } + + if ([] === $multipartRequestClasses) { + return; + } + + throw new ConfigurationException( + sprintf( + 'A PSR-17 "%s" must be wired to handle multipart/form-data endpoints: %s. ' + . 'Install a PSR-7 implementation (e.g. guzzlehttp/psr7, nyholm/psr7) ' + . 'and register its stream factory.', + StreamFactoryInterface::class, + implode(', ', $multipartRequestClasses) + ) + ); + } + + /** + * Reads the endpoints the components-bundle EndpointProviderCompilerPass has already baked + * into the registry definition — instead of instantiating every tagged provider (and its + * constructor dependency graph) a second time on each compile. + * + * @return EndpointInterface[] + */ + private function getEndpoints(ContainerBuilder $container): array + { + if (!$container->hasDefinition(EndpointProviderCompilerPass::SERVICE_ENDPOINT_REGISTRY)) { + return []; + } + + $registryDefinition = $container->getDefinition( + EndpointProviderCompilerPass::SERVICE_ENDPOINT_REGISTRY + ); + + $endpoints = []; + foreach ($registryDefinition->getMethodCalls() as [$methodName, $arguments]) { + if (EndpointProviderCompilerPass::METHOD_REGISTER_ENDPOINT !== $methodName) { + continue; + } + + $endpointDefinition = $arguments[0] ?? null; + if (!$endpointDefinition instanceof Definition + || EndpointImmutable::class !== $endpointDefinition->getClass() + ) { + continue; + } + + // Scalar constructor args only — no services resolved, no provider constructors + // run; an arg-order change in the vendor pass would fail loudly here. + $endpoints[] = new EndpointImmutable(...$endpointDefinition->getArguments()); + } + + return $endpoints; + } +} diff --git a/README.md b/README.md index c4214be..342bd71 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,65 @@ class MyController { } ``` +## multipart/form-data endpoints +Endpoints declared with `requestFormat: 'multipart'` accept `multipart/form-data` requests. +Text fields, attributes and query parameters are merged into the request DTO as usual; +uploaded files are exposed as `Auto1\ServiceAPIComponentsBundle\Multipart\UploadedFileStream` properties. + +Constraints: +* Only `POST` is supported — PHP does not parse multipart bodies for other HTTP methods; any other method is rejected with `400 Bad Request`. + The *wire* method is what counts: a wire `POST` carrying a `_method`/`X-HTTP-METHOD-OVERRIDE` override is accepted, since PHP has already parsed its body. +* The `Content-Type` mime type is matched case-insensitively; `application/x-www-form-urlencoded` is also accepted + (PHP parses both form mime types identically, and `Request::create()`/BrowserKit default POST bodies to urlencoded even when files are attached). +* A non-empty form body that PHP could not parse (e.g. `post_max_size` exceeded) is rejected with `400 Bad Request` instead of arriving as a silently empty payload. +* A PSR-17 `Psr\Http\Message\StreamFactoryInterface` service must be registered in the container. + Install a PSR-7 implementation (e.g. `nyholm/psr7` or `guzzlehttp/psr7`) and register its stream factory. + This is enforced at container compile time: if the application serves a multipart endpoint + and no stream factory is available, the container fails to build with a `ConfigurationException`. + Compile-time enforcement covers endpoints wired through the generated `endpoints.yaml` and the + `*Controller::*Action` convention; manually-routed handlers (e.g. `__invoke` controllers) are the + integrator's responsibility and fail at runtime with a descriptive `LogicException` instead. + +```yaml +uploadDocument: + method: 'POST' + baseUrl: '%auto1.api.url%' + path: '/v1/document' + requestFormat: 'multipart' + requestClass: 'App\Request\UploadDocumentRequest' + responseClass: 'App\Response\Document' +``` + +## Custom request data extractors +The request payload is built by the first `RequestDataExtractorInterface` implementation +whose `supports()` returns `true` for the endpoint, evaluated in descending priority. + +Implementations are autoconfigured: any service implementing +`Auto1\ServiceAPIHandlerBundle\ArgumentResolver\RequestDataExtractor\RequestDataExtractorInterface` +is tagged with `auto1.api_handler.request_data_extractor` automatically at the default priority `0`. + +The bundle registers two extractors of its own: +* `multipart` (priority `100`) — handles `requestFormat: 'multipart'` endpoints. +* `default` (priority `-100`) — always-true fallback that decodes the raw body. + +To control the order explicitly, tag the service manually: +```yaml +App\Request\CsvRequestDataExtractor: + tags: + - { name: 'auto1.api_handler.request_data_extractor', priority: 50 } +``` +A priority at or below `-100` is unreachable — the fallback wins first. Verify the effective +order with: +```bash +bin/console debug:container --tag=auto1.api_handler.request_data_extractor +``` + +## Upgrade note +`ServiceRequestResolver::__construct()` signature changed: the first argument is now a +`DenormalizerInterface` (was `SerializerInterface`) and a required 4th argument (the tagged +iterator of request data extractors) was added. Applications decorating or overriding the +`auto1.api_handler.argument_resolver.service_request` service definition must be updated. + ## Swagger generation For `symfony:>=6.0` and `nelmio/api-doc-bundle:>=4.0` swagger json file is generated in OpenApi v3 format `"openapi": "3.0.0"`. For previous versions of `symfony` and `nelmio/api-doc-bundle` swagger json file is generated in Swagger V2 format `"swagger": "2.0"`. diff --git a/Resources/config/services.yml b/Resources/config/services.yml index fc9165c..9d72ec0 100644 --- a/Resources/config/services.yml +++ b/Resources/config/services.yml @@ -18,6 +18,21 @@ services: tags: - {name: 'nelmio_api_doc.route_describer', priority: -400} + # Request data extractors + auto1.api_handler.request_data_extractor.multipart: + class: Auto1\ServiceAPIHandlerBundle\ArgumentResolver\RequestDataExtractor\MultipartRequestDataExtractor + arguments: + - '@?Psr\Http\Message\StreamFactoryInterface' + tags: + - { name: 'auto1.api_handler.request_data_extractor', priority: 100 } + + auto1.api_handler.request_data_extractor.default: + class: Auto1\ServiceAPIHandlerBundle\ArgumentResolver\RequestDataExtractor\DefaultRequestDataExtractor + arguments: + - '@auto1.api.request.serializer' + tags: + - { name: 'auto1.api_handler.request_data_extractor', priority: -100 } + # Argument resolver auto1.api_handler.argument_resolver.service_request: class: Auto1\ServiceAPIHandlerBundle\ArgumentResolver\ServiceRequestResolver @@ -25,6 +40,7 @@ services: - '@auto1.api.request.serializer' - '@auto1.api.endpoint.registry' - '@auto1.api_handler.response_listener.service_response' + - !tagged_iterator auto1.api_handler.request_data_extractor tags: - { name: 'controller.argument_value_resolver', priority: 150 } diff --git a/Tests/ArgumentResolver/DecodeDenormalizeAwareSerializerInterface.php b/Tests/ArgumentResolver/DecodeDenormalizeAwareSerializerInterface.php deleted file mode 100644 index c3ef7f0..0000000 --- a/Tests/ArgumentResolver/DecodeDenormalizeAwareSerializerInterface.php +++ /dev/null @@ -1,22 +0,0 @@ -decoder = $this->createMock(DecoderInterface::class); + $this->endpoint = new Endpoint(); + } + + private function getCut(): DefaultRequestDataExtractor + { + return new DefaultRequestDataExtractor($this->decoder); + } + + public function testSupportsIsAlwaysTrue(): void + { + $target = $this->getCut(); + + $result = $target->supports($this->endpoint); + + self::assertTrue($result); + } + + public function testExtractMergesDecodedBodyAttributesAndQuery(): void + { + $targetBody = 'foobar'; + $targetQuery = ['targetQueryKey' => 'targetQueryValue']; + $targetAttributes = ['targetAttributeKey' => 'targetAttributeValue']; + $targetDecoded = ['targetBodyKey' => 'targetBodyValue']; + + $request = new Request( + $targetQuery, + [], + $targetAttributes, + [], + [], + [], + $targetBody + ); + + $this->endpoint->setRequestFormat(self::TARGET_FORMAT); + + $this->decoder + ->expects(self::once()) + ->method('decode') + ->with($targetBody, self::TARGET_FORMAT) + ->willReturn($targetDecoded) + ; + + $target = $this->getCut(); + + $result = $target->extract($request, $this->endpoint); + + self::assertSame( + array_merge($targetDecoded, $targetAttributes, $targetQuery), + $result + ); + } + + public function testExtractSkipsDecodeWhenBodyIsEmpty(): void + { + $targetQuery = ['targetQueryKey' => 'targetQueryValue']; + $targetAttributes = ['targetAttributeKey' => 'targetAttributeValue']; + + $request = new Request($targetQuery, [], $targetAttributes); + + $this->decoder + ->expects(self::never()) + ->method('decode') + ; + + $target = $this->getCut(); + + $result = $target->extract($request, $this->endpoint); + + self::assertSame(array_merge($targetAttributes, $targetQuery), $result); + } + + public function testExtractDecodesZeroStringBody(): void + { + $targetBody = '0'; + $targetDecoded = ['targetBodyKey' => 'targetBodyValue']; + + $request = new Request([], [], [], [], [], [], $targetBody); + + $this->endpoint->setRequestFormat(self::TARGET_FORMAT); + + $this->decoder + ->expects(self::once()) + ->method('decode') + ->with($targetBody, self::TARGET_FORMAT) + ->willReturn($targetDecoded) + ; + + $target = $this->getCut(); + + $result = $target->extract($request, $this->endpoint); + + self::assertSame($targetDecoded, $result); + } + + public function testExtractRejectsNonArrayDecodedBody(): void + { + $targetBody = '0'; + $targetDecoded = 0; + + $request = new Request([], [], [], [], [], [], $targetBody); + + $this->endpoint->setRequestFormat(self::TARGET_FORMAT); + + $this->decoder + ->method('decode') + ->with($targetBody, self::TARGET_FORMAT) + ->willReturn($targetDecoded) + ; + + $target = $this->getCut(); + + $this->expectException(UnexpectedValueException::class); + $target->extract($request, $this->endpoint); + } + + public function testExtractPropagatesDecodeException(): void + { + $targetBody = 'not-a-valid-payload'; + $targetException = new NotEncodableValueException(); + + $request = new Request([], [], [], [], [], [], $targetBody); + + $this->endpoint->setRequestFormat(self::TARGET_FORMAT); + + $this->decoder + ->method('decode') + ->with($targetBody, self::TARGET_FORMAT) + ->willThrowException($targetException) + ; + + $target = $this->getCut(); + + $this->expectException(NotEncodableValueException::class); + $target->extract($request, $this->endpoint); + } +} diff --git a/Tests/ArgumentResolver/RequestDataExtractor/MultipartRequestDataExtractorTest.php b/Tests/ArgumentResolver/RequestDataExtractor/MultipartRequestDataExtractorTest.php new file mode 100644 index 0000000..b1a2364 --- /dev/null +++ b/Tests/ArgumentResolver/RequestDataExtractor/MultipartRequestDataExtractorTest.php @@ -0,0 +1,387 @@ + 'POST', + 'CONTENT_TYPE' => 'multipart/form-data; boundary=test', + ]; + + /** + * @var (StreamFactoryInterface&MockObject)|null + */ + private ?StreamFactoryInterface $streamFactory; + + private Endpoint $endpoint; + + /** + * @var string[] + */ + private array $tmpFiles = []; + + protected function setUp(): void + { + $this->streamFactory = $this->createMock(StreamFactoryInterface::class); + $this->endpoint = new Endpoint(); + } + + protected function tearDown(): void + { + foreach ($this->tmpFiles as $tmpFile) { + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + } + $this->tmpFiles = []; + } + + private function getCut(): MultipartRequestDataExtractor + { + return new MultipartRequestDataExtractor($this->streamFactory); + } + + private function createTmpFile(string $content): string + { + $tmpDir = sys_get_temp_dir(); + $tmpFile = tempnam($tmpDir, self::TARGET_TMP_PREFIX); + file_put_contents($tmpFile, $content); + $this->tmpFiles[] = $tmpFile; + + return $tmpFile; + } + + public function testSupportsMultipartFormat(): void + { + $this->endpoint->setRequestFormat(self::TARGET_FORMAT); + + $target = $this->getCut(); + + $result = $target->supports($this->endpoint); + + self::assertTrue($result); + } + + public function testDoesNotSupportOtherFormats(): void + { + $targetOtherFormat = 'json'; + + $this->endpoint->setRequestFormat($targetOtherFormat); + + $target = $this->getCut(); + + $result = $target->supports($this->endpoint); + + self::assertFalse($result); + } + + public function testExtractMergesTextFieldsFilesAttributesAndQuery(): void + { + $targetFileName = 'avatar.png'; + $targetMimeType = 'image/png'; + $targetFileFieldKey = 'avatar'; + $targetTextFields = ['targetTextFieldKey' => 'targetTextFieldValue']; + $targetQuery = ['targetQueryKey' => 'targetQueryValue']; + $targetAttributes = ['targetAttributeKey' => 'targetAttributeValue']; + $targetFileContent = 'hello'; + + $targetTmp = $this->createTmpFile($targetFileContent); + $targetUploadedFile = new UploadedFile($targetTmp, $targetFileName, $targetMimeType, null, true); + + $request = new Request( + $targetQuery, + $targetTextFields, + $targetAttributes, + [], + [$targetFileFieldKey => $targetUploadedFile], + self::TARGET_POST_SERVER + ); + + $targetStreamReadMode = 'r'; + $targetStream = $this->createMock(StreamInterface::class); + $this->streamFactory + ->expects(self::once()) + ->method('createStreamFromFile') + ->with($targetTmp, $targetStreamReadMode) + ->willReturn($targetStream) + ; + + $target = $this->getCut(); + + $result = $target->extract($request, $this->endpoint); + + self::assertSame($targetTextFields['targetTextFieldKey'], $result['targetTextFieldKey']); + self::assertSame($targetQuery['targetQueryKey'], $result['targetQueryKey']); + self::assertSame($targetAttributes['targetAttributeKey'], $result['targetAttributeKey']); + self::assertInstanceOf(UploadedFileStream::class, $result[$targetFileFieldKey]); + } + + public function testExtractWrapsNestedFileArrays(): void + { + $targetFileName = 'doc.pdf'; + $targetMimeType = 'application/pdf'; + $targetFilesFieldKey = 'docs'; + $targetFileContent = 'content'; + + $targetFirstTmp = $this->createTmpFile($targetFileContent); + $targetSecondTmp = $this->createTmpFile($targetFileContent); + $targetFirstFile = new UploadedFile($targetFirstTmp, $targetFileName, $targetMimeType, null, true); + $targetSecondFile = new UploadedFile($targetSecondTmp, $targetFileName, $targetMimeType, null, true); + + $request = new Request( + [], + [], + [], + [], + [$targetFilesFieldKey => [$targetFirstFile, $targetSecondFile]], + self::TARGET_POST_SERVER + ); + + $targetStream = $this->createMock(StreamInterface::class); + $this->streamFactory + ->expects(self::exactly(2)) + ->method('createStreamFromFile') + ->willReturn($targetStream) + ; + + $target = $this->getCut(); + + $result = $target->extract($request, $this->endpoint); + + self::assertCount(2, $result[$targetFilesFieldKey]); + self::assertInstanceOf(UploadedFileStream::class, $result[$targetFilesFieldKey][0]); + self::assertInstanceOf(UploadedFileStream::class, $result[$targetFilesFieldKey][1]); + } + + public function testExtractOmitsUnfilledOptionalFileInput(): void + { + $targetFileFieldKey = 'optional'; + $targetNoFileUpload = [ + 'name' => '', + 'type' => '', + 'tmp_name' => '', + 'error' => UPLOAD_ERR_NO_FILE, + 'size' => 0, + ]; + + $request = new Request( + [], + [], + [], + [], + [$targetFileFieldKey => $targetNoFileUpload], + self::TARGET_POST_SERVER + ); + + $this->streamFactory + ->expects(self::never()) + ->method('createStreamFromFile') + ; + + $target = $this->getCut(); + + $result = $target->extract($request, $this->endpoint); + + self::assertArrayNotHasKey($targetFileFieldKey, $result); + } + + public function testExtractThrowsOnInvalidUpload(): void + { + $targetFileName = 'too-big.png'; + $targetFileFieldKey = 'attachment'; + $targetFileContent = 'partial'; + + $targetTmp = $this->createTmpFile($targetFileContent); + $targetUploadedFile = new UploadedFile($targetTmp, $targetFileName, null, UPLOAD_ERR_PARTIAL, true); + + $request = new Request( + [], + [], + [], + [], + [$targetFileFieldKey => $targetUploadedFile], + self::TARGET_POST_SERVER + ); + + $this->streamFactory + ->expects(self::never()) + ->method('createStreamFromFile') + ; + + $target = $this->getCut(); + + $this->expectException(BadRequestHttpException::class); + $target->extract($request, $this->endpoint); + } + + public function testExtractThrowsWhenStreamFactoryMissingForFileUpload(): void + { + $targetFileName = 'avatar.png'; + $targetMimeType = 'image/png'; + $targetFileFieldKey = 'avatar'; + $targetFileContent = 'hello'; + + $targetTmp = $this->createTmpFile($targetFileContent); + $targetUploadedFile = new UploadedFile($targetTmp, $targetFileName, $targetMimeType, null, true); + + $this->streamFactory = null; + $request = new Request( + [], + [], + [], + [], + [$targetFileFieldKey => $targetUploadedFile], + self::TARGET_POST_SERVER + ); + + $target = $this->getCut(); + + $this->expectException(LogicException::class); + $target->extract($request, $this->endpoint); + } + + public function testExtractDoesNotRequireFactoryWhenNoFilesPresent(): void + { + $targetTextFields = ['targetTextFieldKey' => 'targetTextFieldValue']; + + $this->streamFactory = null; + $request = new Request( + [], + $targetTextFields, + [], + [], + [], + self::TARGET_POST_SERVER + ); + + $target = $this->getCut(); + + $result = $target->extract($request, $this->endpoint); + + self::assertSame($targetTextFields, $result); + } + + public function testExtractRejectsNonPostRequest(): void + { + $targetPutServer = [ + 'REQUEST_METHOD' => 'PUT', + 'CONTENT_TYPE' => 'multipart/form-data; boundary=test', + ]; + + $request = new Request([], [], [], [], [], $targetPutServer); + + $target = $this->getCut(); + + $this->expectException(BadRequestHttpException::class); + $target->extract($request, $this->endpoint); + } + + public function testExtractRejectsNonMultipartContentType(): void + { + $targetJsonServer = [ + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'application/json', + ]; + + $request = new Request([], [], [], [], [], $targetJsonServer); + + $target = $this->getCut(); + + $this->expectException(BadRequestHttpException::class); + $target->extract($request, $this->endpoint); + } + + public function testExtractAllowsMethodOverrideOnWirePostRequest(): void + { + $targetTextFields = ['targetTextFieldKey' => 'targetTextFieldValue']; + $targetOverriddenServer = [ + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'multipart/form-data; boundary=test', + 'HTTP_X_HTTP_METHOD_OVERRIDE' => 'PUT', + ]; + + $request = new Request([], $targetTextFields, [], [], [], $targetOverriddenServer); + + $target = $this->getCut(); + + $result = $target->extract($request, $this->endpoint); + + self::assertSame($targetTextFields, $result); + } + + public function testExtractAcceptsCaseVariantContentType(): void + { + $targetTextFields = ['targetTextFieldKey' => 'targetTextFieldValue']; + $targetCaseVariantServer = [ + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'Multipart/Form-Data; boundary=test', + ]; + + $request = new Request([], $targetTextFields, [], [], [], $targetCaseVariantServer); + + $target = $this->getCut(); + + $result = $target->extract($request, $this->endpoint); + + self::assertSame($targetTextFields, $result); + } + + public function testExtractAcceptsUrlEncodedContentType(): void + { + $targetTextFields = ['targetTextFieldKey' => 'targetTextFieldValue']; + $targetUrlEncodedServer = [ + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'application/x-www-form-urlencoded', + ]; + + $request = new Request([], $targetTextFields, [], [], [], $targetUrlEncodedServer); + + $target = $this->getCut(); + + $result = $target->extract($request, $this->endpoint); + + self::assertSame($targetTextFields, $result); + } + + public function testExtractRejectsUnparsedFormBody(): void + { + $targetUnparsedServer = [ + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'multipart/form-data; boundary=test', + 'CONTENT_LENGTH' => '1048576', + ]; + + $request = new Request([], [], [], [], [], $targetUnparsedServer); + + $target = $this->getCut(); + + $this->expectException(BadRequestHttpException::class); + $target->extract($request, $this->endpoint); + } +} diff --git a/Tests/ArgumentResolver/ServiceRequestResolverTest.php b/Tests/ArgumentResolver/ServiceRequestResolverTest.php index 365ce81..3c2416f 100644 --- a/Tests/ArgumentResolver/ServiceRequestResolverTest.php +++ b/Tests/ArgumentResolver/ServiceRequestResolverTest.php @@ -13,181 +13,286 @@ namespace Tests\Auto1\ServiceAPIHandlerBundle\ArgumentResolver; -use Auto1\ServiceAPIComponentsBundle\Service\Endpoint\EndpointInterface; +use Auto1\ServiceAPIComponentsBundle\Service\Endpoint\Endpoint; use Auto1\ServiceAPIComponentsBundle\Service\Endpoint\EndpointRegistryInterface; +use Auto1\ServiceAPIHandlerBundle\ArgumentResolver\RequestDataExtractor\RequestDataExtractorInterface; use Auto1\ServiceAPIHandlerBundle\ArgumentResolver\ServiceRequestResolver; use Auto1\ServiceAPIHandlerBundle\EventListener\ServiceResponseListener; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\Prophecy\ObjectProphecy; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; -use Symfony\Component\Serializer\Encoder\DecoderInterface; +use Symfony\Component\Serializer\Exception\NotEncodableValueException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; -use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; -use Symfony\Component\Serializer\SerializerInterface; class ServiceRequestResolverTest extends TestCase { - /** - * @var SerializerInterface|DecoderInterface|DenormalizerInterface|ObjectProphecy - */ - private $serializerProphecy; - - /** - * @var EndpointRegistryInterface|ObjectProphecy - */ - private $endpointRegistryProphecy; - - /** - * @var ServiceResponseListener|ObjectProphecy - */ - private $serviceResponseListenerProphecy; - - /** - * @var ServiceRequestResolver - */ - private $serviceRequestResolver; - - /** - * {@inheritDoc} - */ + private const TARGET_FORMAT = 'json'; + private const TARGET_MULTIPART_FORMAT = 'multipart'; + + /** @var DenormalizerInterface&MockObject */ + private DenormalizerInterface $denormalizer; + + /** @var EndpointRegistryInterface&MockObject */ + private EndpointRegistryInterface $endpointRegistry; + + /** @var ServiceResponseListener&MockObject */ + private ServiceResponseListener $serviceResponseListener; + + /** @var RequestDataExtractorInterface&MockObject */ + private RequestDataExtractorInterface $extractor; + + /** @var iterable */ + private iterable $extractors; + protected function setUp(): void { - $this->serializerProphecy = $this->prophesize(DecodeDenormalizeAwareSerializerInterface::class); - $this->endpointRegistryProphecy = $this->prophesize(EndpointRegistryInterface::class); - $this->serviceResponseListenerProphecy = $this->prophesize(ServiceResponseListener::class); - $this->serviceRequestResolver = new ServiceRequestResolver( - $this->serializerProphecy->reveal(), - $this->endpointRegistryProphecy->reveal(), - $this->serviceResponseListenerProphecy->reveal() + $this->denormalizer = $this->createMock(DenormalizerInterface::class); + $this->endpointRegistry = $this->createMock(EndpointRegistryInterface::class); + $this->serviceResponseListener = $this->createMock(ServiceResponseListener::class); + $this->extractor = $this->createMock(RequestDataExtractorInterface::class); + $this->extractors = [$this->extractor]; + } + + private function getCut(): ServiceRequestResolver + { + return new ServiceRequestResolver( + $this->denormalizer, + $this->endpointRegistry, + $this->serviceResponseListener, + $this->extractors ); } - /** - * @return void - */ + private function createMetadata(): ArgumentMetadata + { + return new ArgumentMetadata('foo', RequestStub::class, false, false, null); + } + + private function createEndpoint(string $requestClass, string $requestFormat): Endpoint + { + $endpoint = new Endpoint(); + $endpoint->setRequestClass($requestClass); + $endpoint->setRequestFormat($requestFormat); + + return $endpoint; + } + public function testResolveWrongRequestClass(): void { - $generator = $this->serviceRequestResolver->resolve( - new Request(), - $this->createMetadata() - ); - $endpointProphecy = $this->prophesize(EndpointInterface::class); - $this->endpointRegistryProphecy->getEndpoint(Argument::type(RequestStub::class)) - ->willReturn($endpointProphecy->reveal()) - ->shouldBeCalled(); - $endpointProphecy->getRequestClass() - ->willReturn(\stdClass::class) - ->shouldBeCalled(); + $endpoint = $this->createEndpoint(\stdClass::class, self::TARGET_FORMAT); + $this->endpointRegistry + ->method('getEndpoint') + ->willReturn($endpoint) + ; + + $request = new Request(); + $metadata = $this->createMetadata(); + + $target = $this->getCut(); + + $generator = $target->resolve($request, $metadata); $this->expectException(\LogicException::class); $generator->current(); } - /** - * @return void - */ - public function testResolveDecodeException(): void + public function testResolveNoSupportingExtractor(): void { - $generator = $this->serviceRequestResolver->resolve( - new Request([], [], ['baz' => 'qux'], [], [], [], 'foobar'), - $this->createMetadata() - ); - $endpointProphecy = $this->prophesize(EndpointInterface::class); - $this->endpointRegistryProphecy->getEndpoint(Argument::type(RequestStub::class)) - ->willReturn($endpointProphecy->reveal()) - ->shouldBeCalled(); - $endpointProphecy->getRequestClass() - ->willReturn(RequestStub::class) - ->shouldBeCalled(); - $endpointProphecy->getRequestFormat() - ->willReturn('json') - ->shouldBeCalled(); - $this->serializerProphecy->decode('foobar', 'json', Argument::cetera()) - ->willThrow(UnexpectedValueException::class) - ->shouldBeCalled(); + $endpoint = $this->createEndpoint(RequestStub::class, self::TARGET_FORMAT); + $this->endpointRegistry + ->method('getEndpoint') + ->willReturn($endpoint) + ; + $this->extractor + ->method('supports') + ->with($endpoint) + ->willReturn(false) + ; - $this->expectException(BadRequestHttpException::class); + $request = new Request(); + $metadata = $this->createMetadata(); + + $target = $this->getCut(); + + $generator = $target->resolve($request, $metadata); + + $this->expectException(\LogicException::class); $generator->current(); } - /** - * @return void - */ - public function testResolveDeserializationException() + public function testResolveDeserializationException(): void { - $generator = $this->serviceRequestResolver->resolve( - new Request([], [], ['baz' => 'qux'], [], [], [], 'foobar'), - $this->createMetadata() - ); - $endpointProphecy = $this->prophesize(EndpointInterface::class); - $this->endpointRegistryProphecy->getEndpoint(Argument::type(RequestStub::class)) - ->willReturn($endpointProphecy->reveal()) - ->shouldBeCalled(); - $endpointProphecy->getRequestClass() - ->willReturn(RequestStub::class) - ->shouldBeCalled(); - $endpointProphecy->getRequestFormat() - ->willReturn('json') - ->shouldBeCalled(); - $this->serializerProphecy->decode('foobar', 'json', Argument::cetera()) - ->willReturn(['foo' => 'bar']) - ->shouldBeCalled(); - $this->serializerProphecy->denormalize( - ['foo' => 'bar', 'baz' => 'qux'], - RequestStub::class, - 'json', - Argument::cetera() - ) - ->willThrow(NotNormalizableValueException::class) - ->shouldBeCalled(); + $targetBody = 'foobar'; + $targetAttributes = ['targetAttributeKey' => 'targetAttributeValue']; + $targetExtracted = ['targetBodyKey' => 'targetBodyValue', 'targetAttributeKey' => 'targetAttributeValue']; + $targetException = new NotNormalizableValueException(); + + $request = new Request([], [], $targetAttributes, [], [], [], $targetBody); + $metadata = $this->createMetadata(); + + $endpoint = $this->createEndpoint(RequestStub::class, self::TARGET_FORMAT); + $this->endpointRegistry + ->method('getEndpoint') + ->willReturn($endpoint) + ; + $this->extractor + ->method('supports') + ->with($endpoint) + ->willReturn(true) + ; + $this->extractor + ->method('extract') + ->with($request, $endpoint) + ->willReturn($targetExtracted) + ; + $this->denormalizer + ->method('denormalize') + ->with($targetExtracted, RequestStub::class, self::TARGET_FORMAT) + ->willThrowException($targetException) + ; + + $target = $this->getCut(); + + $generator = $target->resolve($request, $metadata); $this->expectException(BadRequestHttpException::class); $generator->current(); } - public function testResolve(): void + public function testResolveDecodeException(): void { - $generator = $this->serviceRequestResolver->resolve( - new Request(['asd' => 'dsa'], [], ['baz' => 'qux'], [], [], [], 'foobar'), - $this->createMetadata() - ); - $endpointProphecy = $this->prophesize(EndpointInterface::class); - $this->endpointRegistryProphecy->getEndpoint(Argument::type(RequestStub::class)) - ->willReturn($endpointProphecy->reveal()) - ->shouldBeCalled(); - $endpointProphecy->getRequestClass() - ->willReturn(RequestStub::class) - ->shouldBeCalled(); - $endpointProphecy->getRequestFormat() - ->willReturn('json') - ->shouldBeCalled(); - $this->serializerProphecy->decode('foobar', 'json', Argument::cetera()) - ->willReturn(['foo' => 'bar']) - ->shouldBeCalled(); - $this->serializerProphecy->denormalize( - ['foo' => 'bar', 'baz' => 'qux', 'asd' => 'dsa'], - RequestStub::class, - 'json', - Argument::cetera() - ) - ->willReturn(new RequestStub()) - ->shouldBeCalled(); - - $this->assertInstanceOf(RequestStub::class, $generator->current()); + $targetBody = 'not-a-valid-payload'; + $targetException = new NotEncodableValueException(); + + $request = new Request([], [], [], [], [], [], $targetBody); + $metadata = $this->createMetadata(); + + $endpoint = $this->createEndpoint(RequestStub::class, self::TARGET_FORMAT); + $this->endpointRegistry + ->method('getEndpoint') + ->willReturn($endpoint) + ; + $this->extractor + ->method('supports') + ->with($endpoint) + ->willReturn(true) + ; + $this->extractor + ->method('extract') + ->with($request, $endpoint) + ->willThrowException($targetException) + ; + + $target = $this->getCut(); + + $generator = $target->resolve($request, $metadata); + + $this->expectException(BadRequestHttpException::class); + $generator->current(); } - public static function getDataForTestSupports(): \Generator + public function testResolve(): void { - yield 'supported' => [self::createMetadata(), true]; - yield 'not supported' => [new ArgumentMetadata('bar', \stdClass::class, false, false, null), false]; + $targetBody = 'foobar'; + $targetQuery = ['targetQueryKey' => 'targetQueryValue']; + $targetAttributes = ['targetAttributeKey' => 'targetAttributeValue']; + $targetExtracted = [ + 'targetBodyKey' => 'targetBodyValue', + 'targetAttributeKey' => 'targetAttributeValue', + 'targetQueryKey' => 'targetQueryValue', + ]; + $targetDenormalized = new RequestStub(); + + $request = new Request($targetQuery, [], $targetAttributes, [], [], [], $targetBody); + $metadata = $this->createMetadata(); + + $endpoint = $this->createEndpoint(RequestStub::class, self::TARGET_FORMAT); + $this->endpointRegistry + ->method('getEndpoint') + ->willReturn($endpoint) + ; + $this->extractor + ->method('supports') + ->with($endpoint) + ->willReturn(true) + ; + $this->extractor + ->method('extract') + ->with($request, $endpoint) + ->willReturn($targetExtracted) + ; + $this->denormalizer + ->method('denormalize') + ->with($targetExtracted, RequestStub::class, self::TARGET_FORMAT) + ->willReturn($targetDenormalized) + ; + + $target = $this->getCut(); + + $generator = $target->resolve($request, $metadata); + + $result = $generator->current(); + + self::assertSame($targetDenormalized, $result); } - private static function createMetadata(): ArgumentMetadata + public function testResolvePicksFirstSupportingExtractor(): void { - return new ArgumentMetadata('foo', RequestStub::class, false, false, null); + $targetExtracted = ['targetKey' => 'targetValue']; + $targetDenormalized = new RequestStub(); + + $skippedExtractor = $this->createMock(RequestDataExtractorInterface::class); + $matchingExtractor = $this->createMock(RequestDataExtractorInterface::class); + $this->extractors = [$skippedExtractor, $matchingExtractor]; + + $request = new Request(); + $metadata = $this->createMetadata(); + + $endpoint = $this->createEndpoint(RequestStub::class, self::TARGET_MULTIPART_FORMAT); + $this->endpointRegistry + ->method('getEndpoint') + ->willReturn($endpoint) + ; + + $skippedExtractor + ->expects(self::once()) + ->method('supports') + ->with($endpoint) + ->willReturn(false) + ; + $skippedExtractor + ->expects(self::never()) + ->method('extract') + ; + + $matchingExtractor + ->expects(self::once()) + ->method('supports') + ->with($endpoint) + ->willReturn(true) + ; + $matchingExtractor + ->expects(self::once()) + ->method('extract') + ->with($request, $endpoint) + ->willReturn($targetExtracted) + ; + + $this->denormalizer + ->method('denormalize') + ->with($targetExtracted, RequestStub::class, self::TARGET_MULTIPART_FORMAT) + ->willReturn($targetDenormalized) + ; + + $target = $this->getCut(); + + $generator = $target->resolve($request, $metadata); + + $result = $generator->current(); + + self::assertSame($targetDenormalized, $result); } } diff --git a/Tests/DependencyInjection/CompilerPass/MultipartStreamFactoryCompilerPassTest.php b/Tests/DependencyInjection/CompilerPass/MultipartStreamFactoryCompilerPassTest.php new file mode 100644 index 0000000..2aa6a39 --- /dev/null +++ b/Tests/DependencyInjection/CompilerPass/MultipartStreamFactoryCompilerPassTest.php @@ -0,0 +1,148 @@ +container = new ContainerBuilder(); + } + + private function getCut(): MultipartStreamFactoryCompilerPass + { + return new MultipartStreamFactoryCompilerPass(); + } + + private function registerEndpoint(string $requestClass, string $requestFormat): void + { + $registryId = EndpointProviderCompilerPass::SERVICE_ENDPOINT_REGISTRY; + if (!$this->container->hasDefinition($registryId)) { + $newRegistryDefinition = new Definition(); + $this->container->setDefinition($registryId, $newRegistryDefinition); + } + + $endpointDefinition = new Definition(EndpointImmutable::class); + $endpointDefinition->setArguments([ + self::TARGET_HTTP_METHOD, + null, + self::TARGET_PATH, + $requestFormat, + $requestClass, + self::TARGET_JSON_FORMAT, + null, + null, + ]); + + $registryDefinition = $this->container->getDefinition($registryId); + $registryDefinition->addMethodCall( + EndpointProviderCompilerPass::METHOD_REGISTER_ENDPOINT, + [$endpointDefinition] + ); + } + + public function testProcessThrowsWhenHandledMultipartEndpointAndFactoryMissing(): void + { + $targetMapping = [self::TARGET_CONTROLLER_ACTION => RequestStub::class]; + + $this->container->setParameter(self::TARGET_MAPPING_PARAMETER, $targetMapping); + $this->registerEndpoint(RequestStub::class, self::TARGET_MULTIPART_FORMAT); + + $target = $this->getCut(); + + $this->expectException(ConfigurationException::class); + $target->process($this->container); + } + + public function testProcessSucceedsWhenFactoryRegistered(): void + { + $targetMapping = [self::TARGET_CONTROLLER_ACTION => RequestStub::class]; + + $this->container->setParameter(self::TARGET_MAPPING_PARAMETER, $targetMapping); + $this->registerEndpoint(RequestStub::class, self::TARGET_MULTIPART_FORMAT); + $this->container->register(StreamFactoryInterface::class); + + $target = $this->getCut(); + + $this->expectNotToPerformAssertions(); + $target->process($this->container); + } + + public function testProcessIgnoresClientOnlyMultipartEndpoints(): void + { + $targetMapping = []; + + $this->container->setParameter(self::TARGET_MAPPING_PARAMETER, $targetMapping); + $this->registerEndpoint(RequestStub::class, self::TARGET_MULTIPART_FORMAT); + + $target = $this->getCut(); + + $this->expectNotToPerformAssertions(); + $target->process($this->container); + } + + public function testProcessIgnoresNonMultipartEndpoints(): void + { + $targetMapping = [self::TARGET_CONTROLLER_ACTION => RequestStub::class]; + + $this->container->setParameter(self::TARGET_MAPPING_PARAMETER, $targetMapping); + $this->registerEndpoint(RequestStub::class, self::TARGET_JSON_FORMAT); + + $target = $this->getCut(); + + $this->expectNotToPerformAssertions(); + $target->process($this->container); + } + + public function testProcessSucceedsWhenMappingParameterMissing(): void + { + $this->registerEndpoint(RequestStub::class, self::TARGET_MULTIPART_FORMAT); + + $target = $this->getCut(); + + $this->expectNotToPerformAssertions(); + $target->process($this->container); + } + + public function testProcessSucceedsWhenRegistryDefinitionMissing(): void + { + $targetMapping = [self::TARGET_CONTROLLER_ACTION => RequestStub::class]; + + $this->container->setParameter(self::TARGET_MAPPING_PARAMETER, $targetMapping); + + $target = $this->getCut(); + + $this->expectNotToPerformAssertions(); + $target->process($this->container); + } +} diff --git a/Tests/Integration/ContainerCompilationTest.php b/Tests/Integration/ContainerCompilationTest.php new file mode 100644 index 0000000..971cc97 --- /dev/null +++ b/Tests/Integration/ContainerCompilationTest.php @@ -0,0 +1,88 @@ +varDir = $tmpDir . self::TARGET_VAR_DIR_PREFIX . $uniqueSuffix; + } + + protected function tearDown(): void + { + if (null !== $this->kernel) { + $this->kernel->shutdown(); + $this->kernel = null; + } + + $filesystem = new Filesystem(); + $filesystem->remove($this->varDir); + } + + private function getCut(bool $withStreamFactory): TestKernel + { + $this->kernel = new TestKernel($withStreamFactory, $this->varDir); + + return $this->kernel; + } + + public function testContainerCompilesAndMapsHandledEndpoint(): void + { + $targetWithStreamFactory = true; + $targetControllerAction = UploadController::class . '::uploadAction'; + + $target = $this->getCut($targetWithStreamFactory); + + $target->boot(); + + $container = $target->getContainer(); + + $mapping = $container->getParameter(self::TARGET_MAPPING_PARAMETER); + self::assertArrayHasKey($targetControllerAction, $mapping); + self::assertSame(UploadDocumentRequest::class, $mapping[$targetControllerAction]); + + $testContainer = $container->get('test.service_container'); + $resolver = $testContainer->get(self::TARGET_RESOLVER_SERVICE_ID); + self::assertInstanceOf(ServiceRequestResolver::class, $resolver); + } + + public function testContainerCompilationFailsWithoutStreamFactoryForMultipartEndpoint(): void + { + $targetWithStreamFactory = false; + + $target = $this->getCut($targetWithStreamFactory); + + $this->expectException(ConfigurationException::class); + $target->boot(); + } +} diff --git a/Tests/Integration/Fixtures/App/DocumentEndpointProvider.php b/Tests/Integration/Fixtures/App/DocumentEndpointProvider.php new file mode 100644 index 0000000..a957e64 --- /dev/null +++ b/Tests/Integration/Fixtures/App/DocumentEndpointProvider.php @@ -0,0 +1,34 @@ +setMethod(Request::METHOD_POST); + $endpoint->setPath('/v1/document'); + $endpoint->setRequestFormat(EndpointInterface::FORMAT_MULTIPART); + $endpoint->setRequestClass(UploadDocumentRequest::class); + $endpoint->setResponseFormat(EndpointInterface::FORMAT_JSON); + + return [$endpoint]; + } +} diff --git a/Tests/Integration/Fixtures/App/StreamFactoryStub.php b/Tests/Integration/Fixtures/App/StreamFactoryStub.php new file mode 100644 index 0000000..16a364b --- /dev/null +++ b/Tests/Integration/Fixtures/App/StreamFactoryStub.php @@ -0,0 +1,39 @@ +withStreamFactory = $withStreamFactory; + $this->varDir = $varDir; + + parent::__construct('test', true); + } + + public function registerBundles(): iterable + { + return [ + new FrameworkBundle(), + new Auto1ServiceAPIComponentsBundle(), + new Auto1ServiceAPIHandlerBundle(), + ]; + } + + /* + * EndpointRouterCompilerPass scans /src for controllers, so the fixture + * directory acts as the consumer application root. + */ + public function getProjectDir(): string + { + return __DIR__; + } + + public function getCacheDir(): string + { + return $this->varDir . '/cache'; + } + + public function getLogDir(): string + { + return $this->varDir . '/log'; + } + + private function configureContainer(ContainerConfigurator $container): void + { + $container->extension('framework', [ + 'secret' => 'test', + 'test' => true, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + 'serializer' => ['enabled' => true], + 'property_access' => ['enabled' => true], + 'property_info' => ['enabled' => true], + ]); + + $services = $container->services(); + + $services + ->set(DocumentEndpointProvider::class) + ->tag('auto1.api.endpoint_provider', ['priority' => 0]) + ; + + if ($this->withStreamFactory) { + $services->set(StreamFactoryInterface::class, StreamFactoryStub::class); + } + } + + private function configureRoutes(RoutingConfigurator $routes): void + { + } +} diff --git a/Tests/Integration/Fixtures/src/UploadController.php b/Tests/Integration/Fixtures/src/UploadController.php new file mode 100644 index 0000000..e17e928 --- /dev/null +++ b/Tests/Integration/Fixtures/src/UploadController.php @@ -0,0 +1,26 @@ +/src and loads it with require_once, mirroring how consumer + * applications' controllers are found. + */ +class UploadController +{ + public function uploadAction(UploadDocumentRequest $request): void + { + } +} diff --git a/composer.json b/composer.json index f26b930..e2e5ac7 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,8 @@ "require": { "php": "^8.1", "auto1-oss/service-api-request": "^1.0", - "auto1-oss/service-api-components-bundle": "^1.0", + "auto1-oss/service-api-components-bundle": "dev-support-multiform-data", + "psr/http-factory": "^1.0", "symfony/serializer" : "~6.4|~7.0", "symfony/monolog-bridge": "~6.4|~7.0", "symfony/dependency-injection": "~6.4|~7.0", @@ -27,15 +28,25 @@ "require-dev": { "symfony/console": "~6.4|~7.0", "phpunit/phpunit": "^7.5|^8.0|^9.6", - "phpspec/prophecy": "^1.7.2" + "phpspec/prophecy": "^1.7.2", + "psr/http-message": "^1.1|^2.0", + "symfony/framework-bundle": "~6.4|~7.0" }, "suggest": { - "nelmio/api-doc-bundle": "For Generating API documentations" + "nelmio/api-doc-bundle": "For Generating API documentations", + "nyholm/psr7": "For multipart/form-data endpoints (provides a PSR-17 stream factory)" }, "autoload": { "psr-4": { "Auto1\\ServiceAPIHandlerBundle\\": "" } }, "autoload-dev": { - "psr-4": { "Tests\\Auto1\\ServiceAPIHandlerBundle\\": "Tests"} - } + "psr-4": { "Tests\\Auto1\\ServiceAPIHandlerBundle\\": "Tests"}, + "exclude-from-classmap": ["Tests/Integration/Fixtures/src/"] + }, + "repositories": [ + { + "type": "vcs", + "url": "git@github.com:aviator-ua/service-api-components-bundle.git" + } + ] }