Skip to content

Commit f45a35f

Browse files
authored
Merge pull request #13 from WonderNetwork/feature/serialize-dto-json-response
automatically convert output DTOs to JSON responses
2 parents f693c66 + 897ae88 commit f45a35f

File tree

14 files changed

+370
-37
lines changed

14 files changed

+370
-37
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ jobs:
3131
- name: Install dependencies
3232
run: composer install
3333
- name: Code Style
34-
run: vendor/bin/php-cs-fixer check
34+
run: composer cs
3535
- name: PHPStan
36-
run: vendor/bin/phpstan
36+
run: composer lint
3737
- name: PHPUnit
38-
run: vendor/bin/phpunit
38+
run: composer test

composer.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,15 @@
4545
"phpunit/phpunit": "^11",
4646
"bnf/phpstan-psr-container": "^1.0",
4747
"friendsofphp/php-cs-fixer": "^3.87"
48+
},
49+
"scripts": {
50+
"cs": "@phpcs:check",
51+
"phpcs": "@phpcs:check",
52+
"phpcs:check": "vendor/bin/php-cs-fixer check --diff --ansi",
53+
"phpcs:fix": "vendor/bin/php-cs-fixer fix",
54+
"lint": "@phpstan",
55+
"phpstan": "vendor/bin/phpstan",
56+
"test": "@phpunit",
57+
"phpunit": "vendor/bin/phpunit"
4858
}
4959
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WonderNetwork\SlimKernel\Http\Serializer;
6+
7+
interface HasStatusCode {
8+
public function getStatusCode(): int;
9+
}

src/Http/Serializer/Json.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WonderNetwork\SlimKernel\Http\Serializer;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_CLASS)]
10+
final readonly class Json {
11+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WonderNetwork\SlimKernel\Http\Serializer;
6+
7+
use Fig\Http\Message\StatusCodeInterface;
8+
use JsonSerializable;
9+
10+
#[Json]
11+
final readonly class JsonResponse implements HasStatusCode, JsonSerializable {
12+
public static function of(mixed $data, int $statusCode = StatusCodeInterface::STATUS_OK): self {
13+
return new self($data, $statusCode);
14+
}
15+
16+
private function __construct(private mixed $data, private int $statusCode) {
17+
}
18+
19+
public function jsonSerialize(): mixed {
20+
return $this->data;
21+
}
22+
23+
public function getStatusCode(): int {
24+
return $this->statusCode;
25+
}
26+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WonderNetwork\SlimKernel\Http\Serializer;
6+
7+
use Fig\Http\Message\StatusCodeInterface;
8+
use Invoker\Exception\InvocationException;
9+
use Invoker\Exception\NotCallableException;
10+
use Invoker\Exception\NotEnoughParametersException;
11+
use Invoker\InvokerInterface;
12+
use JsonException;
13+
use JsonSerializable;
14+
use Psr\Http\Message\ResponseFactoryInterface;
15+
use Psr\Http\Message\ResponseInterface;
16+
use Psr\Http\Message\StreamFactoryInterface;
17+
use ReflectionClass;
18+
19+
final readonly class JsonSerializingInvoker implements InvokerInterface {
20+
public function __construct(
21+
private InvokerInterface $inner,
22+
private JsonSerializingInvokerOptions $options,
23+
private ResponseFactoryInterface $responseFactory,
24+
private StreamFactoryInterface $streamFactory,
25+
) {
26+
}
27+
28+
/**
29+
* @param callable $callable
30+
* @param array<mixed> $parameters
31+
* @throws InvocationException
32+
* @throws NotCallableException
33+
* @throws NotEnoughParametersException
34+
* @throws JsonException
35+
*/
36+
public function call($callable, array $parameters = []): mixed {
37+
$result = $this->inner->call($callable, $parameters);
38+
39+
if ($result instanceof ResponseInterface) {
40+
return $result;
41+
}
42+
43+
if (false === $this->shouldConvert($result)) {
44+
return $result;
45+
}
46+
47+
$statusCode = StatusCodeInterface::STATUS_OK;
48+
49+
if ($result instanceof HasStatusCode) {
50+
$statusCode = $result->getStatusCode();
51+
}
52+
53+
return $this->responseFactory->createResponse($statusCode)
54+
->withHeader('Content-Type', 'application/json')
55+
->withBody(
56+
$this->streamFactory->createStream(
57+
json_encode($result, JSON_THROW_ON_ERROR),
58+
),
59+
);
60+
}
61+
62+
private function shouldConvert(mixed $result): bool {
63+
if (is_resource($result)) {
64+
return false;
65+
}
66+
67+
if (false === is_object($result)) {
68+
return $this->options->serializeSimpleTypes;
69+
}
70+
71+
if ($this->options->serializeObjects) {
72+
return true;
73+
}
74+
75+
$reflection = new ReflectionClass($result);
76+
77+
if (0 !== count($reflection->getAttributes(Json::class))) {
78+
return true;
79+
}
80+
81+
if ($result instanceof JsonSerializable) {
82+
return $this->options->serializeJsonSerializable;
83+
}
84+
85+
return false;
86+
}
87+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WonderNetwork\SlimKernel\Http\Serializer;
6+
7+
final readonly class JsonSerializingInvokerOptions {
8+
public static function simpleTypes(): self {
9+
return new self(
10+
serializeSimpleTypes: true,
11+
serializeJsonSerializable: false,
12+
serializeObjects: false,
13+
);
14+
}
15+
16+
public static function onlyExplicitlyMarked(): self {
17+
return new self(
18+
serializeSimpleTypes: false,
19+
serializeJsonSerializable: false,
20+
serializeObjects: false,
21+
);
22+
}
23+
24+
public static function all(): self {
25+
return new self(
26+
serializeSimpleTypes: true,
27+
serializeJsonSerializable: true,
28+
serializeObjects: true,
29+
);
30+
}
31+
32+
public function __construct(
33+
public bool $serializeSimpleTypes = true,
34+
public bool $serializeJsonSerializable = true,
35+
public bool $serializeObjects = true,
36+
) {
37+
}
38+
}

src/ServiceFactory/SlimServiceFactory.php

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,21 @@
77
use DI\Bridge\Slim\Bridge;
88
use DI\Bridge\Slim\ControllerInvoker;
99
use Invoker\Invoker;
10+
use Invoker\InvokerInterface;
1011
use Invoker\ParameterResolver\AssociativeArrayResolver;
1112
use Invoker\ParameterResolver\Container\TypeHintContainerResolver;
1213
use Invoker\ParameterResolver\DefaultValueResolver;
1314
use Invoker\ParameterResolver\ResolverChain;
1415
use Invoker\ParameterResolver\TypeHintResolver;
1516
use Psr\Container\ContainerInterface;
1617
use Psr\Http\Message\ResponseFactoryInterface;
18+
use Psr\Http\Message\StreamFactoryInterface;
1719
use RuntimeException;
1820
use Slim\App;
1921
use Slim\Interfaces\CallableResolverInterface;
2022
use Slim\Middleware\ErrorMiddleware;
2123
use Slim\Psr7\Factory\ResponseFactory;
24+
use Slim\Psr7\Factory\StreamFactory;
2225
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
2326
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
2427
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
@@ -30,13 +33,17 @@
3033
use Symfony\Component\Serializer\Serializer;
3134
use Symfony\Component\Serializer\SerializerInterface;
3235
use WonderNetwork\SlimKernel\Http\Serializer\DeserializeParameterResolver;
36+
use WonderNetwork\SlimKernel\Http\Serializer\JsonSerializingInvoker;
37+
use WonderNetwork\SlimKernel\Http\Serializer\JsonSerializingInvokerOptions;
3338
use WonderNetwork\SlimKernel\ServiceFactory;
3439
use WonderNetwork\SlimKernel\ServicesBuilder;
3540
use WonderNetwork\SlimKernel\SlimExtension\ErrorMiddlewareConfiguration;
41+
use function DI\autowire;
3642
use function DI\get;
3743

3844
final class SlimServiceFactory implements ServiceFactory {
3945
public const string INPUT_DENORMALIZER = self::class.':input-denormalizer';
46+
public const string INVOKER = self::class.':invoker';
4047

4148
public function __invoke(ServicesBuilder $builder): iterable {
4249
yield Serializer::class => static fn () => new Serializer([
@@ -56,7 +63,7 @@ public function __invoke(ServicesBuilder $builder): iterable {
5663
yield SerializerInterface::class => get(Serializer::class);
5764
yield self::INPUT_DENORMALIZER => get(DenormalizerInterface::class);
5865

59-
yield ControllerInvoker::class => static function (ContainerInterface $container) {
66+
yield Invoker::class => static function (ContainerInterface $container) {
6067
$serializer = $container->get(SlimServiceFactory::INPUT_DENORMALIZER);
6168

6269
if (false === $serializer instanceof DenormalizerInterface) {
@@ -70,21 +77,45 @@ public function __invoke(ServicesBuilder $builder): iterable {
7077
);
7178
}
7279

73-
return new ControllerInvoker(
74-
new Invoker(
75-
new ResolverChain([
76-
new TypeHintResolver(),
77-
new AssociativeArrayResolver(),
78-
new DeserializeParameterResolver($serializer),
79-
new TypeHintContainerResolver($container),
80-
new DefaultValueResolver(),
81-
]),
82-
$container,
83-
),
80+
return new Invoker(
81+
new ResolverChain([
82+
new TypeHintResolver(),
83+
new AssociativeArrayResolver(),
84+
new DeserializeParameterResolver($serializer),
85+
new TypeHintContainerResolver($container),
86+
new DefaultValueResolver(),
87+
]),
88+
$container,
8489
);
8590
};
8691

92+
yield JsonSerializingInvoker::class => autowire()->constructor(
93+
get(Invoker::class),
94+
);
95+
96+
yield self::INVOKER => get(JsonSerializingInvoker::class);
97+
98+
yield ControllerInvoker::class => static function (ContainerInterface $container) {
99+
$invoker = $container->get(SlimServiceFactory::INVOKER);
100+
101+
if (false === $invoker instanceof InvokerInterface) {
102+
throw new RuntimeException(
103+
sprintf(
104+
'Service registered under %s key is expected to implement %s interface, %s given',
105+
SlimServiceFactory::INVOKER,
106+
InvokerInterface::class,
107+
get_debug_type($invoker),
108+
),
109+
);
110+
}
111+
112+
return new ControllerInvoker($invoker);
113+
};
114+
115+
yield JsonSerializingInvokerOptions::class => static fn () => JsonSerializingInvokerOptions::onlyExplicitlyMarked();
116+
87117
yield ResponseFactoryInterface::class => static fn () => new ResponseFactory();
118+
yield StreamFactoryInterface::class => static fn () => new StreamFactory();
88119
yield ErrorMiddlewareConfiguration::class => static fn () => ErrorMiddlewareConfiguration::silent();
89120

90121
yield ErrorMiddleware::class => static fn (

tests/Http/Serializer/EchoController.php

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,13 @@
44

55
namespace WonderNetwork\SlimKernel\Http\Serializer;
66

7-
use Psr\Http\Message\ResponseInterface;
8-
use Slim\Psr7\Factory\StreamFactory;
9-
107
final readonly class EchoController {
118
public function __invoke(
129
#[Payload] SamplePostInput $post,
1310
#[Payload(source: PayloadSource::Get)] SampleGetInput $get,
14-
ResponseInterface $response,
15-
): ResponseInterface {
16-
$streamFactory = new StreamFactory();
11+
): JsonResponse {
1712
$payload = compact('post', 'get');
18-
$json = json_encode($payload, JSON_THROW_ON_ERROR);
19-
$body = $streamFactory->createStream($json);
2013

21-
return $response->withBody($body);
14+
return JsonResponse::of($payload);
2215
}
2316
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WonderNetwork\SlimKernel\Http\Serializer;
6+
7+
use Invoker\Invoker;
8+
use Slim\Factory\Psr17\SlimPsr17Factory;
9+
10+
final readonly class JsonSerializingInvokerMother {
11+
public static function all(): JsonSerializingInvoker {
12+
return self::withOptions(JsonSerializingInvokerOptions::all());
13+
}
14+
15+
public static function onlyMarked(): JsonSerializingInvoker {
16+
return self::withOptions(JsonSerializingInvokerOptions::onlyExplicitlyMarked());
17+
}
18+
19+
private static function withOptions(JsonSerializingInvokerOptions $options): JsonSerializingInvoker {
20+
return new JsonSerializingInvoker(
21+
inner: new Invoker(),
22+
options: $options,
23+
responseFactory: SlimPsr17Factory::getResponseFactory(),
24+
streamFactory: SlimPsr17Factory::getStreamFactory(),
25+
);
26+
}
27+
}

0 commit comments

Comments
 (0)