From f53c208af842527d5a34d1109f94013536e7ad3c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Dec 2025 10:03:02 +0000 Subject: [PATCH 1/3] Add LinkGenerator for URL generation from endpoints Introduces a simple LinkGenerator class that generates URLs from endpoint definitions. Supports two lookup methods: - By controller class and method: Controller::method - By endpoint ID: endpoint.id Path parameters are substituted from provided params array, remaining params are added as query string. --- src/Core/LinkGenerator/LinkGenerator.php | 116 ++++++++++++++++ .../LinkGenerator/LinkGeneratorException.php | 10 ++ .../Core/LinkGenerator/LinkGeneratorTest.php | 128 ++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 src/Core/LinkGenerator/LinkGenerator.php create mode 100644 src/Core/LinkGenerator/LinkGeneratorException.php create mode 100644 tests/Cases/Core/LinkGenerator/LinkGeneratorTest.php diff --git a/src/Core/LinkGenerator/LinkGenerator.php b/src/Core/LinkGenerator/LinkGenerator.php new file mode 100644 index 0000000..1359efa --- /dev/null +++ b/src/Core/LinkGenerator/LinkGenerator.php @@ -0,0 +1,116 @@ + $params Parameters to substitute in the path + * @return string The generated URL path + * @throws LinkGeneratorException + */ + public function link(string $destination, array $params = []): string + { + $endpoint = $this->findEndpoint($destination); + + if ($endpoint === null) { + throw new LinkGeneratorException(sprintf('Endpoint "%s" not found', $destination)); + } + + return $this->buildUrl($endpoint, $params); + } + + private function findEndpoint(string $destination): ?Endpoint + { + // Try to find by Controller::method format + if (str_contains($destination, '::')) { + return $this->findByControllerMethod($destination); + } + + // Try to find by endpoint ID + return $this->findById($destination); + } + + private function findByControllerMethod(string $destination): ?Endpoint + { + [$class, $method] = explode('::', $destination, 2); + + foreach ($this->schema->getEndpoints() as $endpoint) { + $handler = $endpoint->getHandler(); + + if ($handler->getClass() === $class && $handler->getMethod() === $method) { + return $endpoint; + } + } + + return null; + } + + private function findById(string $id): ?Endpoint + { + foreach ($this->schema->getEndpoints() as $endpoint) { + if ($endpoint->getTag(Endpoint::TAG_ID) === $id) { + return $endpoint; + } + } + + return null; + } + + /** + * @param array $params + */ + private function buildUrl(Endpoint $endpoint, array $params): string + { + $mask = $endpoint->getMask(); + + if ($mask === null) { + throw new LinkGeneratorException('Endpoint has no mask defined'); + } + + // Get path parameters + $pathParams = $endpoint->getParametersByIn(EndpointParameter::IN_PATH); + + // Substitute path parameters + $url = $mask; + foreach ($pathParams as $param) { + $name = $param->getName(); + $placeholder = '{' . $name . '}'; + + if (!array_key_exists($name, $params)) { + if ($param->isRequired()) { + throw new LinkGeneratorException(sprintf('Missing required parameter "%s"', $name)); + } + + continue; + } + + /** @var scalar $value */ + $value = $params[$name]; + $url = str_replace($placeholder, (string) $value, $url); + unset($params[$name]); + } + + // Add remaining params as query string + if ($params !== []) { + $url .= '?' . http_build_query($params); + } + + return $url; + } + +} diff --git a/src/Core/LinkGenerator/LinkGeneratorException.php b/src/Core/LinkGenerator/LinkGeneratorException.php new file mode 100644 index 0000000..19b8081 --- /dev/null +++ b/src/Core/LinkGenerator/LinkGeneratorException.php @@ -0,0 +1,10 @@ +createSchema(); + $linkGenerator = new LinkGenerator($schema); + + $link = $linkGenerator->link('App\Controllers\UsersController::list'); + Assert::same('/api/users', $link); + } + + public function testLinkByControllerMethodWithParams(): void + { + $schema = $this->createSchema(); + $linkGenerator = new LinkGenerator($schema); + + $link = $linkGenerator->link('App\Controllers\UsersController::detail', ['id' => 123]); + Assert::same('/api/users/123', $link); + } + + public function testLinkById(): void + { + $schema = $this->createSchema(); + $linkGenerator = new LinkGenerator($schema); + + $link = $linkGenerator->link('users.list'); + Assert::same('/api/users', $link); + } + + public function testLinkByIdWithParams(): void + { + $schema = $this->createSchema(); + $linkGenerator = new LinkGenerator($schema); + + $link = $linkGenerator->link('users.detail', ['id' => 456]); + Assert::same('/api/users/456', $link); + } + + public function testLinkWithQueryParams(): void + { + $schema = $this->createSchema(); + $linkGenerator = new LinkGenerator($schema); + + $link = $linkGenerator->link('users.list', ['page' => 2, 'limit' => 10]); + Assert::same('/api/users?page=2&limit=10', $link); + } + + public function testLinkWithPathAndQueryParams(): void + { + $schema = $this->createSchema(); + $linkGenerator = new LinkGenerator($schema); + + $link = $linkGenerator->link('users.detail', ['id' => 789, 'include' => 'posts']); + Assert::same('/api/users/789?include=posts', $link); + } + + public function testLinkNotFound(): void + { + $schema = $this->createSchema(); + $linkGenerator = new LinkGenerator($schema); + + Assert::exception( + fn () => $linkGenerator->link('nonexistent'), + LinkGeneratorException::class, + 'Endpoint "nonexistent" not found' + ); + } + + public function testLinkMissingRequiredParam(): void + { + $schema = $this->createSchema(); + $linkGenerator = new LinkGenerator($schema); + + Assert::exception( + fn () => $linkGenerator->link('users.detail'), + LinkGeneratorException::class, + 'Missing required parameter "id"' + ); + } + + private function createSchema(): Schema + { + $schema = new Schema(); + + // Users list endpoint + $handler1 = new EndpointHandler('App\Controllers\UsersController', 'list'); + $endpoint1 = new Endpoint($handler1); + $endpoint1->setMethods([Endpoint::METHOD_GET]); + $endpoint1->setMask('/api/users'); + $endpoint1->addTag(Endpoint::TAG_ID, 'users.list'); + $schema->addEndpoint($endpoint1); + + // Users detail endpoint with path parameter + $handler2 = new EndpointHandler('App\Controllers\UsersController', 'detail'); + $endpoint2 = new Endpoint($handler2); + $endpoint2->setMethods([Endpoint::METHOD_GET]); + $endpoint2->setMask('/api/users/{id}'); + $endpoint2->addTag(Endpoint::TAG_ID, 'users.detail'); + + $idParam = new EndpointParameter('id', EndpointParameter::TYPE_INTEGER); + $idParam->setIn(EndpointParameter::IN_PATH); + $idParam->setRequired(true); + $endpoint2->addParameter($idParam); + + $schema->addEndpoint($endpoint2); + + return $schema; + } + +} + +(new LinkGeneratorTest())->run(); From b61464a8fdda9285c493e0f019e1d38bbdf59c41 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Dec 2025 10:21:36 +0000 Subject: [PATCH 2/3] Register LinkGenerator to DI and add documentation - Register LinkGenerator as api.core.linkGenerator service - Add DI integration tests with real endpoints - Add UsersController fixture for testing path parameters - Add link-generator.md documentation - Update README.md with link to documentation --- .docs/README.md | 1 + .docs/link-generator.md | 98 +++++++++ src/Core/DI/Plugin/CoreServicesPlugin.php | 4 + .../Core/LinkGenerator/LinkGenerator.phpt | 191 ++++++++++++++++++ .../Fixtures/Controllers/UsersController.php | 31 +++ 5 files changed, 325 insertions(+) create mode 100644 .docs/link-generator.md create mode 100644 tests/Cases/Core/LinkGenerator/LinkGenerator.phpt create mode 100644 tests/Fixtures/Controllers/UsersController.php diff --git a/.docs/README.md b/.docs/README.md index 2d3620b..4d5d5cc 100644 --- a/.docs/README.md +++ b/.docs/README.md @@ -20,6 +20,7 @@ This knowledge could make your life easier - [Decorators](decorators.md) - [Dispatcher](dispatcher.md) - [Errors](errors.md) +- [Link Generator](link-generator.md) - [Request and response](request-and-response.md) - [Router](router.md) - [Schema](schema.md) diff --git a/.docs/link-generator.md b/.docs/link-generator.md new file mode 100644 index 0000000..ad9d350 --- /dev/null +++ b/.docs/link-generator.md @@ -0,0 +1,98 @@ +# Link Generator + +Generates URLs to [endpoints](endpoints.md) from [schema](schema.md). + +## Usage + +LinkGenerator is registered as a service `api.core.linkGenerator` and can be autowired. + +```php +use Apitte\Core\LinkGenerator\LinkGenerator; + +class MyService +{ + public function __construct( + private LinkGenerator $linkGenerator, + ) {} + + public function generateLinks(): void + { + // Generate link by Controller::method + $url = $this->linkGenerator->link(UsersController::class . '::detail', ['id' => 123]); + // Result: /api/v1/users/123 + + // Generate link by endpoint ID + $url = $this->linkGenerator->link('api.users.detail', ['id' => 123]); + // Result: /api/v1/users/123 + } +} +``` + +## Lookup methods + +### By Controller::method + +```php +$linkGenerator->link(UsersController::class . '::list'); +$linkGenerator->link(UsersController::class . '::detail', ['id' => 123]); +``` + +The controller class name and method name are used to find the endpoint. + +### By endpoint ID + +```php +$linkGenerator->link('api.users.list'); +$linkGenerator->link('api.users.detail', ['id' => 123]); +``` + +The endpoint ID is defined using the `#[Id]` attribute on controllers and methods. IDs are hierarchical, joined by dots. + +## Parameters + +### Path parameters + +Path parameters (defined in the endpoint path like `/{id}`) are substituted from the params array: + +```php +// Endpoint: /users/{id} +$linkGenerator->link(UsersController::class . '::detail', ['id' => 123]); +// Result: /users/123 +``` + +### Query parameters + +Extra parameters that are not path parameters are added as query string: + +```php +// Endpoint: /users +$linkGenerator->link(UsersController::class . '::list', ['page' => 2, 'limit' => 10]); +// Result: /users?page=2&limit=10 +``` + +### Mixed parameters + +Path and query parameters can be combined: + +```php +// Endpoint: /users/{id} +$linkGenerator->link(UsersController::class . '::detail', ['id' => 123, 'include' => 'posts']); +// Result: /users/123?include=posts +``` + +## Exceptions + +`LinkGeneratorException` is thrown when: + +- Endpoint is not found +- Required path parameter is missing + +```php +use Apitte\Core\LinkGenerator\LinkGeneratorException; + +try { + $linkGenerator->link('nonexistent'); +} catch (LinkGeneratorException $e) { + // Handle error +} +``` diff --git a/src/Core/DI/Plugin/CoreServicesPlugin.php b/src/Core/DI/Plugin/CoreServicesPlugin.php index a2436df..db23ad9 100644 --- a/src/Core/DI/Plugin/CoreServicesPlugin.php +++ b/src/Core/DI/Plugin/CoreServicesPlugin.php @@ -11,6 +11,7 @@ use Apitte\Core\ErrorHandler\SimpleErrorHandler; use Apitte\Core\Handler\IHandler; use Apitte\Core\Handler\ServiceHandler; +use Apitte\Core\LinkGenerator\LinkGenerator; use Apitte\Core\Router\IRouter; use Apitte\Core\Router\SimpleRouter; use Apitte\Core\Schema\Schema; @@ -61,6 +62,9 @@ public function loadPluginConfiguration(): void $builder->addDefinition($this->prefix('schema')) ->setFactory(Schema::class); + + $builder->addDefinition($this->prefix('linkGenerator')) + ->setFactory(LinkGenerator::class); } public function beforePluginCompile(): void diff --git a/tests/Cases/Core/LinkGenerator/LinkGenerator.phpt b/tests/Cases/Core/LinkGenerator/LinkGenerator.phpt new file mode 100644 index 0000000..fa61ea6 --- /dev/null +++ b/tests/Cases/Core/LinkGenerator/LinkGenerator.phpt @@ -0,0 +1,191 @@ +load(function (Compiler $compiler): void { + $compiler->addExtension('api', new ApiExtension()); + $compiler->addConfig([ + 'parameters' => [ + 'debugMode' => true, + ], + ]); + }, 'linkGenerator1'); + + /** @var Container $container */ + $container = new $class(); + + Assert::type(LinkGenerator::class, $container->getService('api.core.linkGenerator')); +}); + +// LinkGenerator - link by Controller::method +Toolkit::test(function (): void { + $loader = new ContainerLoader(Environment::getTestDir(), true); + $class = $loader->load(function (Compiler $compiler): void { + $compiler->addExtension('api', new ApiExtension()); + $compiler->addConfig([ + 'parameters' => [ + 'debugMode' => true, + ], + 'services' => [ + AnnotationFoobarController::class, + ], + ]); + }, 'linkGenerator2'); + + /** @var Container $container */ + $container = new $class(); + + /** @var LinkGenerator $linkGenerator */ + $linkGenerator = $container->getService('api.core.linkGenerator'); + + Assert::same('/api/v1/foobar/baz1', $linkGenerator->link(AnnotationFoobarController::class . '::baz1')); + Assert::same('/api/v1/foobar/baz2', $linkGenerator->link(AnnotationFoobarController::class . '::baz2')); +}); + +// LinkGenerator - link by ID +Toolkit::test(function (): void { + $loader = new ContainerLoader(Environment::getTestDir(), true); + $class = $loader->load(function (Compiler $compiler): void { + $compiler->addExtension('api', new ApiExtension()); + $compiler->addConfig([ + 'parameters' => [ + 'debugMode' => true, + ], + 'services' => [ + AnnotationFoobarController::class, + ], + ]); + }, 'linkGenerator3'); + + /** @var Container $container */ + $container = new $class(); + + /** @var LinkGenerator $linkGenerator */ + $linkGenerator = $container->getService('api.core.linkGenerator'); + + // ID hierarchy: testapi (AbstractController) . foobar (AnnotationFoobarController) . baz1 (method) + Assert::same('/api/v1/foobar/baz1', $linkGenerator->link('testapi.foobar.baz1')); +}); + +// LinkGenerator - link with path parameters +Toolkit::test(function (): void { + $loader = new ContainerLoader(Environment::getTestDir(), true); + $class = $loader->load(function (Compiler $compiler): void { + $compiler->addExtension('api', new ApiExtension()); + $compiler->addConfig([ + 'parameters' => [ + 'debugMode' => true, + ], + 'services' => [ + UsersController::class, + ], + ]); + }, 'linkGenerator4'); + + /** @var Container $container */ + $container = new $class(); + + /** @var LinkGenerator $linkGenerator */ + $linkGenerator = $container->getService('api.core.linkGenerator'); + + // By controller::method + Assert::same('/api/v1/users', $linkGenerator->link(UsersController::class . '::list')); + Assert::same('/api/v1/users/123', $linkGenerator->link(UsersController::class . '::detail', ['id' => 123])); + + // By ID (testapi from AbstractController, users from UsersController) + Assert::same('/api/v1/users', $linkGenerator->link('testapi.users.list')); + Assert::same('/api/v1/users/456', $linkGenerator->link('testapi.users.detail', ['id' => 456])); +}); + +// LinkGenerator - link with query parameters +Toolkit::test(function (): void { + $loader = new ContainerLoader(Environment::getTestDir(), true); + $class = $loader->load(function (Compiler $compiler): void { + $compiler->addExtension('api', new ApiExtension()); + $compiler->addConfig([ + 'parameters' => [ + 'debugMode' => true, + ], + 'services' => [ + UsersController::class, + ], + ]); + }, 'linkGenerator5'); + + /** @var Container $container */ + $container = new $class(); + + /** @var LinkGenerator $linkGenerator */ + $linkGenerator = $container->getService('api.core.linkGenerator'); + + Assert::same('/api/v1/users?page=2&limit=10', $linkGenerator->link(UsersController::class . '::list', ['page' => 2, 'limit' => 10])); + Assert::same('/api/v1/users/123?include=posts', $linkGenerator->link(UsersController::class . '::detail', ['id' => 123, 'include' => 'posts'])); +}); + +// LinkGenerator - endpoint not found +Toolkit::test(function (): void { + $loader = new ContainerLoader(Environment::getTestDir(), true); + $class = $loader->load(function (Compiler $compiler): void { + $compiler->addExtension('api', new ApiExtension()); + $compiler->addConfig([ + 'parameters' => [ + 'debugMode' => true, + ], + ]); + }, 'linkGenerator6'); + + /** @var Container $container */ + $container = new $class(); + + /** @var LinkGenerator $linkGenerator */ + $linkGenerator = $container->getService('api.core.linkGenerator'); + + Assert::exception( + fn () => $linkGenerator->link('nonexistent'), + LinkGeneratorException::class, + 'Endpoint "nonexistent" not found' + ); +}); + +// LinkGenerator - missing required parameter +Toolkit::test(function (): void { + $loader = new ContainerLoader(Environment::getTestDir(), true); + $class = $loader->load(function (Compiler $compiler): void { + $compiler->addExtension('api', new ApiExtension()); + $compiler->addConfig([ + 'parameters' => [ + 'debugMode' => true, + ], + 'services' => [ + UsersController::class, + ], + ]); + }, 'linkGenerator7'); + + /** @var Container $container */ + $container = new $class(); + + /** @var LinkGenerator $linkGenerator */ + $linkGenerator = $container->getService('api.core.linkGenerator'); + + Assert::exception( + fn () => $linkGenerator->link(UsersController::class . '::detail'), + LinkGeneratorException::class, + 'Missing required parameter "id"' + ); +}); diff --git a/tests/Fixtures/Controllers/UsersController.php b/tests/Fixtures/Controllers/UsersController.php new file mode 100644 index 0000000..e1480e7 --- /dev/null +++ b/tests/Fixtures/Controllers/UsersController.php @@ -0,0 +1,31 @@ + Date: Mon, 15 Dec 2025 11:35:06 +0000 Subject: [PATCH 3/3] Fix PHP 8.4 deprecation and improve type safety - Replace deprecated isSingle() with isSimple() in EntityAdapter - Remove deprecated phpstan.neon checkGenericClassInNonGenericObjectType option - Add proper type hints to fix ~40 PHPStan errors: - Helpers.php: Add is_array check for tag priority - ApiRequest.php: Add type assertions for getAttribute - ApiResponse.php: Add instanceof checks for getEntity/getEndpoint - ValidationException.php: Fix sprintf with mixed values - RequestParameterMapping.php: Add type assertion for requestParameters - DecoratedDispatcher.php: Use instanceof for endpoint check - Plugin.php: Add type assertion for config processing - DateTimeTypeMapper.php: Add is_string check for value - Regex.php: Add proper return type hints - Endpoint.php: Use is_string check for pattern attribute - ArrayHydrator.php: Add detailed phpstan-param shape - ArraySerializator.php: Add type assertion for mask - BasicEntity.php: Fix return types and add type assertions --- phpstan.neon | 12 ++++------- src/Core/DI/Helpers.php | 10 +++------- src/Core/DI/Plugin/Plugin.php | 4 +++- src/Core/Dispatcher/DecoratedDispatcher.php | 3 ++- .../Exception/Api/ValidationException.php | 4 ++-- src/Core/Http/ApiRequest.php | 20 +++++++++++++++---- src/Core/Http/ApiResponse.php | 8 ++++++-- .../Mapping/Parameter/DateTimeTypeMapper.php | 10 +++++++--- src/Core/Mapping/Request/BasicEntity.php | 3 ++- src/Core/Mapping/RequestParameterMapping.php | 1 + src/Core/Schema/Endpoint.php | 2 +- .../Schema/Serialization/ArrayHydrator.php | 20 +++++++++++++------ .../Serialization/ArraySerializator.php | 1 + src/Core/Utils/Regex.php | 8 ++++++-- .../SchemaDefinition/Entity/EntityAdapter.php | 4 ++-- 15 files changed, 70 insertions(+), 40 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index e067269..f8d289e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -10,9 +10,12 @@ parameters: - .docs reportMaybesInPropertyPhpDocTypes: false - checkGenericClassInNonGenericObjectType: false ignoreErrors: + # Ignore missing generic typehints + - + identifier: missingType.generics + # Intended property access - required for reflection - '#^Variable property access on (\$this|static)\(Apitte\\Core\\Mapping\\(Request|Response)\\BasicEntity\)\.$#' - '#^Variable property access on Apitte\\Core\\Mapping\\Request\\BasicEntity\.$#' @@ -48,13 +51,6 @@ parameters: # This should not happen because null is returned on error - '#Method Apitte\\Core\\Utils\\Helpers::slashless\(\) should return string but returns string\|null\.#' - # To pass php8.0 tests where library doesn't have isSingle() - - message: """ - #^Call to deprecated method isSingle\\(\\) of class Nette\\\\Utils\\\\Type\\: - use isSimple\\(\\)$# - """ - path: %currentWorkingDirectory%/src/OpenApi/SchemaDefinition/Entity/EntityAdapter.php - # Nette changed return typehint - message: "#^Method Apitte\\\\OpenApi\\\\SchemaDefinition\\\\Entity\\\\EntityAdapter\\:\\:getNativePropertyType\\(\\) should return string but returns array\\\\|string\\.$#" path: %currentWorkingDirectory%/src/OpenApi/SchemaDefinition/Entity/EntityAdapter.php diff --git a/src/Core/DI/Helpers.php b/src/Core/DI/Helpers.php index 84e1a3b..ed9c7a8 100644 --- a/src/Core/DI/Helpers.php +++ b/src/Core/DI/Helpers.php @@ -16,16 +16,12 @@ public static function sortByPriorityInTag(string $tagname, array $definitions, // Sort by priority uasort($definitions, static function (Definition $a, Definition $b) use ($tagname, $default): int { $tag1 = $a->getTag($tagname); - $p1 = $tag1 !== null && isset($tag1['priority']) ? $tag1['priority'] : $default; + $p1 = is_array($tag1) && isset($tag1['priority']) ? (int) $tag1['priority'] : $default; $tag2 = $b->getTag($tagname); - $p2 = $tag2 !== null && isset($tag2['priority']) ? $tag2['priority'] : $default; + $p2 = is_array($tag2) && isset($tag2['priority']) ? (int) $tag2['priority'] : $default; - if ($p1 === $p2) { - return 0; - } - - return ($p1 < $p2) ? -1 : 1; + return $p1 <=> $p2; }); return $definitions; diff --git a/src/Core/DI/Plugin/Plugin.php b/src/Core/DI/Plugin/Plugin.php index d80ad07..30cc52c 100644 --- a/src/Core/DI/Plugin/Plugin.php +++ b/src/Core/DI/Plugin/Plugin.php @@ -68,7 +68,9 @@ protected function setupConfig(Schema $schema, array $config, string $name): voi $context->path = [$name]; }; try { - $this->config = $processor->process($schema, $config); + /** @var stdClass|mixed[] $processedConfig */ + $processedConfig = $processor->process($schema, $config); + $this->config = $processedConfig; } catch (ValidationException $exception) { throw new InvalidConfigurationException($exception->getMessage()); } diff --git a/src/Core/Dispatcher/DecoratedDispatcher.php b/src/Core/Dispatcher/DecoratedDispatcher.php index 41df0c4..ae80e8e 100644 --- a/src/Core/Dispatcher/DecoratedDispatcher.php +++ b/src/Core/Dispatcher/DecoratedDispatcher.php @@ -14,6 +14,7 @@ use Apitte\Core\Http\RequestAttributes; use Apitte\Core\Mapping\Response\IResponseEntity; use Apitte\Core\Router\IRouter; +use Apitte\Core\Schema\Endpoint; use Apitte\Negotiation\Http\ArrayEntity; use Apitte\Negotiation\Http\MappingEntity; use Apitte\Negotiation\Http\ScalarEntity; @@ -67,7 +68,7 @@ protected function handle(ApiRequest $request, ApiResponse $response): ApiRespon { // Pass endpoint to response $endpoint = $request->getAttribute(RequestAttributes::ATTR_ENDPOINT, null); - if ($endpoint !== null) { + if ($endpoint instanceof Endpoint) { $response = $response->withEndpoint($endpoint); } diff --git a/src/Core/Exception/Api/ValidationException.php b/src/Core/Exception/Api/ValidationException.php index 47e8292..f54c8e6 100644 --- a/src/Core/Exception/Api/ValidationException.php +++ b/src/Core/Exception/Api/ValidationException.php @@ -33,11 +33,11 @@ public function withFormFields(array $fields): static { foreach ($fields as $key => $value) { if (is_numeric($key)) { - throw new InvalidArgumentException(sprintf('Field key must be string "%s" give.', $key)); + throw new InvalidArgumentException(sprintf('Field key must be string "%s" given.', (string) $key)); } if (!is_array($value)) { - throw new InvalidArgumentException(sprintf('Field values must be array "%s" give.', $value)); + throw new InvalidArgumentException(sprintf('Field values must be array, %s given.', get_debug_type($value))); } } diff --git a/src/Core/Http/ApiRequest.php b/src/Core/Http/ApiRequest.php index 7e4c034..c985682 100644 --- a/src/Core/Http/ApiRequest.php +++ b/src/Core/Http/ApiRequest.php @@ -13,17 +13,29 @@ class ApiRequest extends ProxyRequest public function hasParameter(string $name): bool { - return array_key_exists($name, $this->getAttribute(RequestAttributes::ATTR_PARAMETERS, [])); + /** @var array $params */ + $params = $this->getAttribute(RequestAttributes::ATTR_PARAMETERS, []); + + return array_key_exists($name, $params); } public function getParameter(string $name, mixed $default = null): mixed { - return $this->getAttribute(RequestAttributes::ATTR_PARAMETERS, [])[$name] ?? $default; + /** @var array $params */ + $params = $this->getAttribute(RequestAttributes::ATTR_PARAMETERS, []); + + return $params[$name] ?? $default; } - public function getParameters(): mixed + /** + * @return array + */ + public function getParameters(): array { - return $this->getAttribute(RequestAttributes::ATTR_PARAMETERS, []); + /** @var array $params */ + $params = $this->getAttribute(RequestAttributes::ATTR_PARAMETERS, []); + + return $params; } public function getEntity(mixed $default = null): mixed diff --git a/src/Core/Http/ApiResponse.php b/src/Core/Http/ApiResponse.php index 83b61e9..ecc8536 100644 --- a/src/Core/Http/ApiResponse.php +++ b/src/Core/Http/ApiResponse.php @@ -116,7 +116,9 @@ public function withAttribute(string $name, mixed $value): self public function getEntity(): ?AbstractEntity { - return $this->getAttribute(ResponseAttributes::ATTR_ENTITY, null); + $entity = $this->getAttribute(ResponseAttributes::ATTR_ENTITY, null); + + return $entity instanceof AbstractEntity ? $entity : null; } /** @@ -129,7 +131,9 @@ public function withEntity(AbstractEntity $entity): self public function getEndpoint(): ?Endpoint { - return $this->getAttribute(ResponseAttributes::ATTR_ENDPOINT, null); + $endpoint = $this->getAttribute(ResponseAttributes::ATTR_ENDPOINT, null); + + return $endpoint instanceof Endpoint ? $endpoint : null; } /** diff --git a/src/Core/Mapping/Parameter/DateTimeTypeMapper.php b/src/Core/Mapping/Parameter/DateTimeTypeMapper.php index 89170ab..d7677fe 100644 --- a/src/Core/Mapping/Parameter/DateTimeTypeMapper.php +++ b/src/Core/Mapping/Parameter/DateTimeTypeMapper.php @@ -14,14 +14,18 @@ class DateTimeTypeMapper implements ITypeMapper */ public function normalize(mixed $value, array $options = []): ?DateTimeImmutable { + if (!is_string($value)) { + throw new InvalidArgumentTypeException(InvalidArgumentTypeException::TYPE_DATETIME); + } + try { - $value = DateTimeImmutable::createFromFormat(DATE_ATOM, $value); + $result = DateTimeImmutable::createFromFormat(DATE_ATOM, $value); } catch (TypeError $e) { throw new InvalidArgumentTypeException(InvalidArgumentTypeException::TYPE_DATETIME); } - if ($value !== false) { - return $value; + if ($result !== false) { + return $result; } throw new InvalidArgumentTypeException(InvalidArgumentTypeException::TYPE_DATETIME); diff --git a/src/Core/Mapping/Request/BasicEntity.php b/src/Core/Mapping/Request/BasicEntity.php index c7074ed..62f8889 100644 --- a/src/Core/Mapping/Request/BasicEntity.php +++ b/src/Core/Mapping/Request/BasicEntity.php @@ -15,7 +15,7 @@ abstract class BasicEntity extends AbstractEntity use TReflectionProperties; /** - * @return mixed[] + * @return array */ public function getRequestProperties(): array { @@ -82,6 +82,7 @@ protected function normalize(string $property, mixed $value): mixed protected function fromBodyRequest(ApiRequest $request): self { try { + /** @var array $body */ $body = (array) $request->getJsonBodyCopy(true); } catch (JsonException $ex) { throw new ClientErrorException('Invalid json data', 400, $ex); diff --git a/src/Core/Mapping/RequestParameterMapping.php b/src/Core/Mapping/RequestParameterMapping.php index 340978b..bafda92 100644 --- a/src/Core/Mapping/RequestParameterMapping.php +++ b/src/Core/Mapping/RequestParameterMapping.php @@ -60,6 +60,7 @@ public function map(ApiRequest $request, ApiResponse $response): ApiRequest $headerParameters = array_change_key_case($request->getHeaders(), CASE_LOWER); $cookieParams = $request->getCookieParams(); // Get request parameters from attribute + /** @var array $requestParameters */ $requestParameters = $request->getAttribute(RequestAttributes::ATTR_PARAMETERS); // Iterate over all parameters diff --git a/src/Core/Schema/Endpoint.php b/src/Core/Schema/Endpoint.php index 70c165e..e1467c8 100644 --- a/src/Core/Schema/Endpoint.php +++ b/src/Core/Schema/Endpoint.php @@ -276,7 +276,7 @@ private function generatePattern(): string { $rawPattern = $this->getAttribute('pattern'); - if ($rawPattern === null) { + if (!is_string($rawPattern)) { throw new InvalidStateException('Pattern attribute is required'); } diff --git a/src/Core/Schema/Serialization/ArrayHydrator.php b/src/Core/Schema/Serialization/ArrayHydrator.php index 786a5ce..e46d1dd 100644 --- a/src/Core/Schema/Serialization/ArrayHydrator.php +++ b/src/Core/Schema/Serialization/ArrayHydrator.php @@ -3,7 +3,6 @@ namespace Apitte\Core\Schema\Serialization; use Apitte\Core\Exception\Logical\InvalidArgumentException; -use Apitte\Core\Exception\Logical\InvalidStateException; use Apitte\Core\Schema\Endpoint; use Apitte\Core\Schema\EndpointHandler; use Apitte\Core\Schema\EndpointNegotiation; @@ -32,14 +31,23 @@ public function hydrate(mixed $data): Schema } /** - * @param mixed[] $data + * @param array $data + * @phpstan-param array{ + * handler: array{class: class-string, method: string}, + * methods: string[], + * mask: string, + * id?: string, + * tags?: array, + * attributes?: array{pattern?: string}, + * parameters?: array|null}>, + * requestBody?: array{description: ?string, entity: ?string, required: bool, validation: bool}, + * responses?: array, + * openApi?: mixed[], + * negotiations?: array + * } $data */ private function hydrateEndpoint(array $data): Endpoint { - if (!isset($data['handler'])) { - throw new InvalidStateException("Schema route 'handler' is required"); - } - $handler = new EndpointHandler( $data['handler']['class'], $data['handler']['method'] diff --git a/src/Core/Schema/Serialization/ArraySerializator.php b/src/Core/Schema/Serialization/ArraySerializator.php index af983f0..7c9e44b 100644 --- a/src/Core/Schema/Serialization/ArraySerializator.php +++ b/src/Core/Schema/Serialization/ArraySerializator.php @@ -112,6 +112,7 @@ private function serializeInit(Controller $controller, Method $method): array */ private function serializePattern(array &$endpoint, Controller $controller, Method $method): void { + /** @var string $mask */ $mask = $endpoint['mask']; $maskParameters = []; diff --git a/src/Core/Utils/Regex.php b/src/Core/Utils/Regex.php index 285cab5..857294d 100644 --- a/src/Core/Utils/Regex.php +++ b/src/Core/Utils/Regex.php @@ -7,15 +7,19 @@ final class Regex /** * @param 0|256|512|768 $flags + * @return array|null */ - public static function match(string $subject, string $pattern, int $flags = 0): mixed + public static function match(string $subject, string $pattern, int $flags = 0): ?array { $ret = preg_match($pattern, $subject, $m, $flags); return $ret === 1 ? $m : null; } - public static function matchAll(string $subject, string $pattern, int $flags = PREG_SET_ORDER): mixed + /** + * @return array>|null + */ + public static function matchAll(string $subject, string $pattern, int $flags = PREG_SET_ORDER): ?array { $ret = preg_match_all($pattern, $subject, $m, $flags); diff --git a/src/OpenApi/SchemaDefinition/Entity/EntityAdapter.php b/src/OpenApi/SchemaDefinition/Entity/EntityAdapter.php index bcb2e83..2fd1847 100644 --- a/src/OpenApi/SchemaDefinition/Entity/EntityAdapter.php +++ b/src/OpenApi/SchemaDefinition/Entity/EntityAdapter.php @@ -267,11 +267,11 @@ private function parseAnnotation(Reflector $ref, string $name): ?string private function getNativePropertyType(Type $type, ReflectionProperty $property): string { - if ($type->isSingle() && count($type->getNames()) === 1) { + if ($type->isSimple() && count($type->getNames()) === 1) { return $type->getNames()[0]; } - if ($type->isUnion() || ($type->isSingle() && count($type->getNames()) === 2) // nullable type is single but returns name of type and null in names + if ($type->isUnion() || ($type->isSimple() && count($type->getNames()) === 2) // nullable type is simple but returns name of type and null in names ) { return implode('|', $type->getNames()); }