diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 932769c..6cbc1b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,8 +31,8 @@ jobs: - name: Install dependencies run: composer install - name: Code Style - run: vendor/bin/php-cs-fixer check + run: composer cs - name: PHPStan - run: vendor/bin/phpstan + run: composer lint - name: PHPUnit - run: vendor/bin/phpunit + run: composer test diff --git a/composer.json b/composer.json index 515c648..cd0f6b4 100644 --- a/composer.json +++ b/composer.json @@ -45,5 +45,15 @@ "phpunit/phpunit": "^11", "bnf/phpstan-psr-container": "^1.0", "friendsofphp/php-cs-fixer": "^3.87" + }, + "scripts": { + "cs": "@phpcs:check", + "phpcs": "@phpcs:check", + "phpcs:check": "vendor/bin/php-cs-fixer check --diff --ansi", + "phpcs:fix": "vendor/bin/php-cs-fixer fix", + "lint": "@phpstan", + "phpstan": "vendor/bin/phpstan", + "test": "@phpunit", + "phpunit": "vendor/bin/phpunit" } } diff --git a/src/Http/Serializer/HasStatusCode.php b/src/Http/Serializer/HasStatusCode.php new file mode 100644 index 0000000..7cb7690 --- /dev/null +++ b/src/Http/Serializer/HasStatusCode.php @@ -0,0 +1,9 @@ +data; + } + + public function getStatusCode(): int { + return $this->statusCode; + } +} diff --git a/src/Http/Serializer/JsonSerializingInvoker.php b/src/Http/Serializer/JsonSerializingInvoker.php new file mode 100644 index 0000000..55df474 --- /dev/null +++ b/src/Http/Serializer/JsonSerializingInvoker.php @@ -0,0 +1,87 @@ + $parameters + * @throws InvocationException + * @throws NotCallableException + * @throws NotEnoughParametersException + * @throws JsonException + */ + public function call($callable, array $parameters = []): mixed { + $result = $this->inner->call($callable, $parameters); + + if ($result instanceof ResponseInterface) { + return $result; + } + + if (false === $this->shouldConvert($result)) { + return $result; + } + + $statusCode = StatusCodeInterface::STATUS_OK; + + if ($result instanceof HasStatusCode) { + $statusCode = $result->getStatusCode(); + } + + return $this->responseFactory->createResponse($statusCode) + ->withHeader('Content-Type', 'application/json') + ->withBody( + $this->streamFactory->createStream( + json_encode($result, JSON_THROW_ON_ERROR), + ), + ); + } + + private function shouldConvert(mixed $result): bool { + if (is_resource($result)) { + return false; + } + + if (false === is_object($result)) { + return $this->options->serializeSimpleTypes; + } + + if ($this->options->serializeObjects) { + return true; + } + + $reflection = new ReflectionClass($result); + + if (0 !== count($reflection->getAttributes(Json::class))) { + return true; + } + + if ($result instanceof JsonSerializable) { + return $this->options->serializeJsonSerializable; + } + + return false; + } +} diff --git a/src/Http/Serializer/JsonSerializingInvokerOptions.php b/src/Http/Serializer/JsonSerializingInvokerOptions.php new file mode 100644 index 0000000..f551756 --- /dev/null +++ b/src/Http/Serializer/JsonSerializingInvokerOptions.php @@ -0,0 +1,38 @@ + static fn () => new Serializer([ @@ -56,7 +63,7 @@ public function __invoke(ServicesBuilder $builder): iterable { yield SerializerInterface::class => get(Serializer::class); yield self::INPUT_DENORMALIZER => get(DenormalizerInterface::class); - yield ControllerInvoker::class => static function (ContainerInterface $container) { + yield Invoker::class => static function (ContainerInterface $container) { $serializer = $container->get(SlimServiceFactory::INPUT_DENORMALIZER); if (false === $serializer instanceof DenormalizerInterface) { @@ -70,21 +77,45 @@ public function __invoke(ServicesBuilder $builder): iterable { ); } - return new ControllerInvoker( - new Invoker( - new ResolverChain([ - new TypeHintResolver(), - new AssociativeArrayResolver(), - new DeserializeParameterResolver($serializer), - new TypeHintContainerResolver($container), - new DefaultValueResolver(), - ]), - $container, - ), + return new Invoker( + new ResolverChain([ + new TypeHintResolver(), + new AssociativeArrayResolver(), + new DeserializeParameterResolver($serializer), + new TypeHintContainerResolver($container), + new DefaultValueResolver(), + ]), + $container, ); }; + yield JsonSerializingInvoker::class => autowire()->constructor( + get(Invoker::class), + ); + + yield self::INVOKER => get(JsonSerializingInvoker::class); + + yield ControllerInvoker::class => static function (ContainerInterface $container) { + $invoker = $container->get(SlimServiceFactory::INVOKER); + + if (false === $invoker instanceof InvokerInterface) { + throw new RuntimeException( + sprintf( + 'Service registered under %s key is expected to implement %s interface, %s given', + SlimServiceFactory::INVOKER, + InvokerInterface::class, + get_debug_type($invoker), + ), + ); + } + + return new ControllerInvoker($invoker); + }; + + yield JsonSerializingInvokerOptions::class => static fn () => JsonSerializingInvokerOptions::onlyExplicitlyMarked(); + yield ResponseFactoryInterface::class => static fn () => new ResponseFactory(); + yield StreamFactoryInterface::class => static fn () => new StreamFactory(); yield ErrorMiddlewareConfiguration::class => static fn () => ErrorMiddlewareConfiguration::silent(); yield ErrorMiddleware::class => static fn ( diff --git a/tests/Http/Serializer/EchoController.php b/tests/Http/Serializer/EchoController.php index 7577e69..59de82c 100644 --- a/tests/Http/Serializer/EchoController.php +++ b/tests/Http/Serializer/EchoController.php @@ -4,20 +4,13 @@ namespace WonderNetwork\SlimKernel\Http\Serializer; -use Psr\Http\Message\ResponseInterface; -use Slim\Psr7\Factory\StreamFactory; - final readonly class EchoController { public function __invoke( #[Payload] SamplePostInput $post, #[Payload(source: PayloadSource::Get)] SampleGetInput $get, - ResponseInterface $response, - ): ResponseInterface { - $streamFactory = new StreamFactory(); + ): JsonResponse { $payload = compact('post', 'get'); - $json = json_encode($payload, JSON_THROW_ON_ERROR); - $body = $streamFactory->createStream($json); - return $response->withBody($body); + return JsonResponse::of($payload); } } diff --git a/tests/Http/Serializer/JsonSerializingInvokerMother.php b/tests/Http/Serializer/JsonSerializingInvokerMother.php new file mode 100644 index 0000000..a4d4f00 --- /dev/null +++ b/tests/Http/Serializer/JsonSerializingInvokerMother.php @@ -0,0 +1,27 @@ + + */ + public static function dataSimpleTypes(): iterable { + yield [null]; + yield [1]; + yield [1.5]; + yield ["hello"]; + yield [['message' => 'Success']]; + } + + #[DataProvider('dataSimpleTypes')] + public function testSerializesSimpleTypes(mixed $result): void { + $sut = JsonSerializingInvokerMother::all(); + $response = $sut->call(fn () => $result); + self::assertInstanceOf(ResponseInterface::class, $response); + } + + #[DataProvider('dataSimpleTypes')] + public function testSkipsWhenSimpleTypesDisabled(mixed $result): void { + $sut = JsonSerializingInvokerMother::onlyMarked(); + + $response = $sut->call(fn () => $result); + self::assertSame($result, $response); + } + + public function testSerializesJsonSerializable(): void { + $sut = JsonSerializingInvokerMother::all(); + + $object = new class () implements JsonSerializable { + public function jsonSerialize(): null { + return null; + } + }; + + $response = $sut->call(fn () => $object); + + self::assertInstanceOf(ResponseInterface::class, $response); + } + + public function testSkipsWhenJsonSerializableDisabled(): void { + $sut = JsonSerializingInvokerMother::onlyMarked(); + + $object = new class () implements JsonSerializable { + public function jsonSerialize(): null { + return null; + } + }; + $response = $sut->call(fn () => $object); + + self::assertSame($object, $response); + } + + public function testSerializesObjects(): void { + $sut = JsonSerializingInvokerMother::all(); + + $response = $sut->call(fn () => new stdClass()); + + self::assertInstanceOf(ResponseInterface::class, $response); + } + + public function testSkipsWhenObjectsDisabled(): void { + $sut = JsonSerializingInvokerMother::onlyMarked(); + + $object = new stdClass(); + $response = $sut->call(fn () => $object); + + self::assertSame($object, $response); + } + + public function testSkipsOnResources(): void { + $sut = JsonSerializingInvokerMother::all(); + + $resource = fopen('php://temp', 'rb+'); + $response = $sut->call(fn () => $resource); + + self::assertSame($resource, $response); + } + + public function testSerializesObjectsMarkedWithAttribute(): void { + $sut = JsonSerializingInvokerMother::onlyMarked(); + + $response = $sut->call(fn () => JsonResponse::of('whatever')); + + self::assertInstanceOf(ResponseInterface::class, $response); + } + + public function testSetsCustomStatusCode(): void { + $sut = JsonSerializingInvokerMother::all(); + + $response = $sut->call(fn () => JsonResponse::of(null, StatusCodeInterface::STATUS_ACCEPTED)); + + self::assertInstanceOf(ResponseInterface::class, $response); + self::assertSame(202, $response->getStatusCode()); + } +} diff --git a/tests/Resources/App/app/services/services.php b/tests/Resources/App/app/services/services.php index 17755d3..d838db3 100644 --- a/tests/Resources/App/app/services/services.php +++ b/tests/Resources/App/app/services/services.php @@ -3,11 +3,8 @@ declare(strict_types=1); use Acme\HelloWorldController; -use Psr\Http\Message\StreamFactoryInterface; -use Slim\Psr7\Factory\StreamFactory; use function DI\autowire; return [ HelloWorldController::class => autowire(), - StreamFactoryInterface::class => autowire(StreamFactory::class), ]; diff --git a/tests/Resources/App/src/HelloWorldController.php b/tests/Resources/App/src/HelloWorldController.php index 6c33356..007b61f 100644 --- a/tests/Resources/App/src/HelloWorldController.php +++ b/tests/Resources/App/src/HelloWorldController.php @@ -8,11 +8,8 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; -final class HelloWorldController { - private StreamFactoryInterface $streamFactory; - - public function __construct(StreamFactoryInterface $streamFactory) { - $this->streamFactory = $streamFactory; +final readonly class HelloWorldController { + public function __construct(private StreamFactoryInterface $streamFactory) { } public function __invoke( diff --git a/tests/Resources/ErrorMiddleware/src/CustomErrorMiddleware.php b/tests/Resources/ErrorMiddleware/src/CustomErrorMiddleware.php index 7ff106b..5c77013 100644 --- a/tests/Resources/ErrorMiddleware/src/CustomErrorMiddleware.php +++ b/tests/Resources/ErrorMiddleware/src/CustomErrorMiddleware.php @@ -10,11 +10,8 @@ use Psr\Http\Server\RequestHandlerInterface; use Throwable; -final class CustomErrorMiddleware implements MiddlewareInterface { - private ErrorHandlingSpy $spy; - - public function __construct(ErrorHandlingSpy $spy) { - $this->spy = $spy; +final readonly class CustomErrorMiddleware implements MiddlewareInterface { + public function __construct(private ErrorHandlingSpy $spy) { } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {