Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
9 changes: 9 additions & 0 deletions src/Http/Serializer/HasStatusCode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace WonderNetwork\SlimKernel\Http\Serializer;

interface HasStatusCode {
public function getStatusCode(): int;
}
11 changes: 11 additions & 0 deletions src/Http/Serializer/Json.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace WonderNetwork\SlimKernel\Http\Serializer;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
final readonly class Json {
}
26 changes: 26 additions & 0 deletions src/Http/Serializer/JsonResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace WonderNetwork\SlimKernel\Http\Serializer;

use Fig\Http\Message\StatusCodeInterface;
use JsonSerializable;

#[Json]
final readonly class JsonResponse implements HasStatusCode, JsonSerializable {
public static function of(mixed $data, int $statusCode = StatusCodeInterface::STATUS_OK): self {
return new self($data, $statusCode);
}

private function __construct(private mixed $data, private int $statusCode) {
}

public function jsonSerialize(): mixed {
return $this->data;
}

public function getStatusCode(): int {
return $this->statusCode;
}
}
87 changes: 87 additions & 0 deletions src/Http/Serializer/JsonSerializingInvoker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

namespace WonderNetwork\SlimKernel\Http\Serializer;

use Fig\Http\Message\StatusCodeInterface;
use Invoker\Exception\InvocationException;
use Invoker\Exception\NotCallableException;
use Invoker\Exception\NotEnoughParametersException;
use Invoker\InvokerInterface;
use JsonException;
use JsonSerializable;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use ReflectionClass;

final readonly class JsonSerializingInvoker implements InvokerInterface {
public function __construct(
private InvokerInterface $inner,
private JsonSerializingInvokerOptions $options,
private ResponseFactoryInterface $responseFactory,
private StreamFactoryInterface $streamFactory,
) {
}

/**
* @param callable $callable
* @param array<mixed> $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;
}
}
38 changes: 38 additions & 0 deletions src/Http/Serializer/JsonSerializingInvokerOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace WonderNetwork\SlimKernel\Http\Serializer;

final readonly class JsonSerializingInvokerOptions {
public static function simpleTypes(): self {
return new self(
serializeSimpleTypes: true,
serializeJsonSerializable: false,
serializeObjects: false,
);
}

public static function onlyExplicitlyMarked(): self {
return new self(
serializeSimpleTypes: false,
serializeJsonSerializable: false,
serializeObjects: false,
);
}

public static function all(): self {
return new self(
serializeSimpleTypes: true,
serializeJsonSerializable: true,
serializeObjects: true,
);
}

public function __construct(
public bool $serializeSimpleTypes = true,
public bool $serializeJsonSerializable = true,
public bool $serializeObjects = true,
) {
}
}
55 changes: 43 additions & 12 deletions src/ServiceFactory/SlimServiceFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,21 @@
use DI\Bridge\Slim\Bridge;
use DI\Bridge\Slim\ControllerInvoker;
use Invoker\Invoker;
use Invoker\InvokerInterface;
use Invoker\ParameterResolver\AssociativeArrayResolver;
use Invoker\ParameterResolver\Container\TypeHintContainerResolver;
use Invoker\ParameterResolver\DefaultValueResolver;
use Invoker\ParameterResolver\ResolverChain;
use Invoker\ParameterResolver\TypeHintResolver;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use RuntimeException;
use Slim\App;
use Slim\Interfaces\CallableResolverInterface;
use Slim\Middleware\ErrorMiddleware;
use Slim\Psr7\Factory\ResponseFactory;
use Slim\Psr7\Factory\StreamFactory;
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
Expand All @@ -30,13 +33,17 @@
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\SerializerInterface;
use WonderNetwork\SlimKernel\Http\Serializer\DeserializeParameterResolver;
use WonderNetwork\SlimKernel\Http\Serializer\JsonSerializingInvoker;
use WonderNetwork\SlimKernel\Http\Serializer\JsonSerializingInvokerOptions;
use WonderNetwork\SlimKernel\ServiceFactory;
use WonderNetwork\SlimKernel\ServicesBuilder;
use WonderNetwork\SlimKernel\SlimExtension\ErrorMiddlewareConfiguration;
use function DI\autowire;
use function DI\get;

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

public function __invoke(ServicesBuilder $builder): iterable {
yield Serializer::class => static fn () => new Serializer([
Expand All @@ -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) {
Expand All @@ -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 (
Expand Down
11 changes: 2 additions & 9 deletions tests/Http/Serializer/EchoController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
27 changes: 27 additions & 0 deletions tests/Http/Serializer/JsonSerializingInvokerMother.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace WonderNetwork\SlimKernel\Http\Serializer;

use Invoker\Invoker;
use Slim\Factory\Psr17\SlimPsr17Factory;

final readonly class JsonSerializingInvokerMother {
public static function all(): JsonSerializingInvoker {
return self::withOptions(JsonSerializingInvokerOptions::all());
}

public static function onlyMarked(): JsonSerializingInvoker {
return self::withOptions(JsonSerializingInvokerOptions::onlyExplicitlyMarked());
}

private static function withOptions(JsonSerializingInvokerOptions $options): JsonSerializingInvoker {
return new JsonSerializingInvoker(
inner: new Invoker(),
options: $options,
responseFactory: SlimPsr17Factory::getResponseFactory(),
streamFactory: SlimPsr17Factory::getStreamFactory(),
);
}
}
Loading