diff --git a/phpstan.neon b/phpstan.neon index 98b5a4b7..245c2393 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -22,12 +22,12 @@ parameters: - '#^Call to static method from\(\) on an unknown class Apitte\\Negotiation\\Http\\#' - '#^Class Apitte\\Negotiation\\Http\\AbstractEntity not found\.$#' - # Phpstan bug + # Phpstan bugs - '#^Parameter \#1 \$function of function call_user_func_array expects callable\(\)\: mixed, array\(object, string\) given\.#' - - # Phpstan don't understand complex try-catch constructs - message: '#^Strict comparison using !== between null and null will always evaluate to false\.$#' path: %currentWorkingDirectory%/src/Router/SimpleRouter.php + - message: '#^Strict comparison using !== between array and array\(\) will always evaluate to true\.$#' + path: %currentWorkingDirectory%/src/LinkGenerator/StrictLinkGenerator.php # Maybe later - complicated to fix - '#^.*should be contravariant with parameter.*$#' @@ -37,3 +37,17 @@ parameters: # Ignore bad php internal functions behavior - '#^Parameter \#1 \$haystack of static method Nette\\Utils\\Strings\:\:contains\(\) expects string, string\|false given\.$#' + + # Should be false error + - message: '#^Ternary operator condition is always true\.$#' + path: %currentWorkingDirectory%/src/LinkGenerator/ControllerMapper.php + - message: '#^Binary operation "\." between string and array\|string\|null results in an error\.$#' + path: %currentWorkingDirectory%/src/LinkGenerator/ControllerMapper.php + + # Ignore completely until LaxLinkGenerator is implemented + - message: '#^.*$#' + path: %currentWorkingDirectory%/src/LinkGenerator/LaxLinkGenerator.php + + # Not implemented branch + - message: '#^Elseif condition is always false\.$#' + path: %currentWorkingDirectory%/src/LinkGenerator/StrictLinkGenerator.php diff --git a/src/Application/Application.php b/src/Application/Application.php index 7d6034f3..e4e5511b 100644 --- a/src/Application/Application.php +++ b/src/Application/Application.php @@ -3,8 +3,10 @@ namespace Apitte\Core\Application; use Apitte\Core\Dispatcher\IDispatcher; +use Apitte\Core\Http\RequestScopeStorage; use Contributte\Psr7\Psr7Response; use Contributte\Psr7\Psr7ServerRequestFactory; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; class Application implements IApplication @@ -13,9 +15,13 @@ class Application implements IApplication /** @var IDispatcher */ private $dispatcher; - public function __construct(IDispatcher $dispatcher) + /** @var RequestScopeStorage */ + private $requestScopeStorage; + + public function __construct(IDispatcher $dispatcher, RequestScopeStorage $requestScopeStorage) { $this->dispatcher = $dispatcher; + $this->requestScopeStorage = $requestScopeStorage; } public function run(): void @@ -26,10 +32,16 @@ public function run(): void public function runWith(ServerRequestInterface $request): void { - $response = new Psr7Response(); + $this->requestScopeStorage->save('uri', $request->getUri()); + + $response = $this->dispatcher->dispatch($request, new Psr7Response()); + $this->sendResponse($response); - $response = $this->dispatcher->dispatch($request, $response); + $this->requestScopeStorage->clear(); + } + protected function sendResponse(ResponseInterface $response): void + { $httpHeader = sprintf( 'HTTP/%s %s %s', $response->getProtocolVersion(), diff --git a/src/DI/Plugin/CoreMappingPlugin.php b/src/DI/Plugin/CoreMappingPlugin.php index 905395ff..0c11ac62 100644 --- a/src/DI/Plugin/CoreMappingPlugin.php +++ b/src/DI/Plugin/CoreMappingPlugin.php @@ -5,6 +5,7 @@ use Apitte\Core\Decorator\RequestEntityDecorator; use Apitte\Core\Decorator\RequestParametersDecorator; use Apitte\Core\DI\ApiExtension; +use Apitte\Core\LinkGenerator\StrictLinkGenerator; use Apitte\Core\Mapping\Parameter\BooleanTypeMapper; use Apitte\Core\Mapping\Parameter\DateTimeTypeMapper; use Apitte\Core\Mapping\Parameter\FloatTypeMapper; @@ -70,6 +71,9 @@ public function loadPluginConfiguration(): void $builder->addDefinition($this->prefix('request.entity.mapping')) ->setFactory(RequestEntityMapping::class) ->addSetup('setValidator', ['@' . $this->prefix('request.entity.mapping.validator')]); + + $builder->getDefinition($this->prefix('linkGenerator')) + ->setFactory(StrictLinkGenerator::class); } } diff --git a/src/DI/Plugin/CoreServicesPlugin.php b/src/DI/Plugin/CoreServicesPlugin.php index 73d03792..d90015bb 100644 --- a/src/DI/Plugin/CoreServicesPlugin.php +++ b/src/DI/Plugin/CoreServicesPlugin.php @@ -11,6 +11,10 @@ use Apitte\Core\ErrorHandler\SimpleErrorHandler; use Apitte\Core\Handler\IHandler; use Apitte\Core\Handler\ServiceHandler; +use Apitte\Core\Http\RequestScopeStorage; +use Apitte\Core\LinkGenerator\ControllerMapper; +use Apitte\Core\LinkGenerator\LaxLinkGenerator; +use Apitte\Core\LinkGenerator\LinkGenerator; use Apitte\Core\Router\IRouter; use Apitte\Core\Router\SimpleRouter; use Apitte\Core\Schema\Schema; @@ -70,6 +74,16 @@ public function loadPluginConfiguration(): void $builder->addDefinition($this->prefix('schema')) ->setFactory(Schema::class); + + $builder->addDefinition($this->prefix('requestScopeStorage')) + ->setFactory(RequestScopeStorage::class); + + $builder->addDefinition($this->prefix('controllerMapper')) + ->setFactory(ControllerMapper::class); + + $builder->addDefinition($this->prefix('linkGenerator')) + ->setFactory(LaxLinkGenerator::class) + ->setType(LinkGenerator::class); } } diff --git a/src/Exception/Logical/InvalidControllerException.php b/src/Exception/Logical/InvalidControllerException.php new file mode 100644 index 00000000..7728d3d1 --- /dev/null +++ b/src/Exception/Logical/InvalidControllerException.php @@ -0,0 +1,10 @@ +data[$key] = $data; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->data); + } + + /** + * @param mixed $default + * @return mixed + */ + public function load(string $key, $default = null) + { + if ($this->has($key)) { + return $this->data[$key]; + } + + return $default; + } + + /** + * @internal + */ + public function clear(): void + { + $this->data = []; + } + +} diff --git a/src/LinkGenerator/BaseLinkGenerator.php b/src/LinkGenerator/BaseLinkGenerator.php new file mode 100644 index 00000000..c5ccda92 --- /dev/null +++ b/src/LinkGenerator/BaseLinkGenerator.php @@ -0,0 +1,95 @@ +schema = $schema; + $this->requestScopeStorage = $requestScopeStorage; + $this->controllerMapper = $mapper; + } + + /** + * @param string $destination "[[module:]controller:action]" + * @param mixed[] $parameters + */ + public function link(string $destination, array $parameters = []): string + { + if (!preg_match('~^([\w:]+):(\w*+)(#.*)?()\z~', $destination, $m)) { + throw new InvalidLinkException(sprintf('Invalid link destination "%s".', $destination)); + } + + [, $controller, $method, $fragment] = $m; + + $class = $this->controllerMapper->getControllerClass($controller); + $endpoint = $this->findEndpointByClassAndMethod($class, $method); + + return $this->buildUrl($endpoint, $parameters, $fragment); + } + + /** + * @param mixed[] $parameters + */ + abstract protected function buildUrl(Endpoint $endpoint, array $parameters, string $fragment): string; + + protected function getBaseUri(): string + { + /** @var UriInterface|null $uri */ + $uri = $this->requestScopeStorage->load('uri'); + + if ($uri === null) { + return ''; + } + + return $uri->getHost(); + } + + private function findEndpointByClassAndMethod(string $class, string $method): Endpoint + { + if (isset($this->endpointCache[$class][$method])) { + return $this->endpointCache[$class][$method]; + } + + $classFound = false; + + foreach ($this->schema->getEndpoints() as $endpoint) { + $handler = $endpoint->getHandler(); + if ($handler->getClass() === $class && $handler->getMethod() === $method) { + $this->endpointCache[$class][$method] = $endpoint; + return $endpoint; + } + if ($handler->getClass() === $class) { + $classFound = true; + } + } + + if ($classFound) { + throw new InvalidStateException(sprintf('Controllers "%s" method "%s" is missing in schema.', $class, $method)); + } + + throw new InvalidStateException(sprintf('Controller "%s" is missing in schema.', $class)); + } + +} diff --git a/src/LinkGenerator/ControllerMapper.php b/src/LinkGenerator/ControllerMapper.php new file mode 100644 index 00000000..770fe550 --- /dev/null +++ b/src/LinkGenerator/ControllerMapper.php @@ -0,0 +1,106 @@ + splitted mask */ + private $mapping = [ + '*' => ['', '*\\', '*Controller'], + ]; + + /** @var string[] */ + private $classCache = []; + + /** + * Sets mapping as pairs [module => mask] + * + * @param mixed[] $mapping + * @return static + */ + public function setMapping(array $mapping): self + { + foreach ($mapping as $module => $mask) { + if (is_string($mask)) { + if (!preg_match('#^\\\\?([\w\\\\]*\\\\)?(\w*\*\w*?\\\\)?([\w\\\\]*\*\w*)\z#', $mask, $m)) { + throw new InvalidStateException(sprintf('Invalid mapping mask "%s" for module "%s".', $mask, $module)); + } + $this->mapping[$module] = [$m[1], $m[2] ?: '*Module\\', $m[3]]; + } elseif (is_array($mask) && count($mask) === 3) { + $this->mapping[$module] = [$mask[0] ? $mask[0] . '\\' : '', $mask[1] . '\\', $mask[2]]; + } else { + throw new InvalidStateException(sprintf('Invalid mapping mask for module "%s".', $module)); + } + } + return $this; + } + + private function formatControllerClass(string $controller): string + { + $parts = explode(':', $controller); + $mapping = isset($parts[1], $this->mapping[$parts[0]]) + ? $this->mapping[array_shift($parts)] + : $this->mapping['*']; + while ($part = array_shift($parts)) { + $mapping[0] .= str_replace('*', $part, $mapping[$parts ? 1 : 2]); + } + return $mapping[0]; + } + + private function unformatControllerClass(string $class): ?string + { + foreach ($this->mapping as $module => $mapping) { + $mapping = str_replace(['\\', '*'], ['\\\\', '(\w+)'], $mapping); + $matchPattern = sprintf('#^\\\\?%s((?:%s)*)%s\\z#i', $mapping[0], $mapping[1], $mapping[2]); + if (preg_match($matchPattern, $class, $matches)) { + $replacePattern = sprintf('#%s#iA', $mapping[1]); + return ($module === '*' ? '' : $module . ':') + . preg_replace($replacePattern, '$1:', $matches[1]) . $matches[3]; + } + } + return null; + } + + public function getControllerClass(string $name): string + { + if (isset($this->classCache[$name])) { + return $this->classCache[$name]; + } + + if (!preg_match('#^[a-zA-Z\x7f-\xff][a-zA-Z0-9\x7f-\xff:]*\z#', $name)) { + throw new InvalidControllerException(sprintf('Controller name must be alphanumeric string, "%s" is invalid.', $name)); + } + + $class = $this->formatControllerClass($name); + + if (!class_exists($class)) { + throw new InvalidControllerException(sprintf('Cannot load controller "%s", class "%s" was not found.', $name, $class)); + } + + $reflection = new ReflectionClass($class); + $class = $reflection->getName(); + + if (!$reflection->implementsInterface(IController::class)) { + throw new InvalidControllerException(sprintf('Cannot load controller "%s", class "%s" is not "%s" implementor.', $name, $class, IController::class)); + } + + if ($reflection->isAbstract()) { + throw new InvalidControllerException(sprintf('Cannot load controller "%s", class "%s" is abstract.', $name, $class)); + } + + $this->classCache[$name] = $class; + + if ($name !== ($realName = $this->unformatControllerClass($class))) { + trigger_error(sprintf('Case mismatch on controller name "%s", correct name is "%s".', $name, $realName), E_USER_WARNING); + } + + return $class; + } + +} diff --git a/src/LinkGenerator/LaxLinkGenerator.php b/src/LinkGenerator/LaxLinkGenerator.php new file mode 100644 index 00000000..59c87fa4 --- /dev/null +++ b/src/LinkGenerator/LaxLinkGenerator.php @@ -0,0 +1,41 @@ +getMask()); + + var_dump($maskParameters); + + $mask = preg_replace_callback( + '#{(.*?)}#', + function ($match) use ($pathParameters) { + return $pathParameters[$match[1]]; + }, + (string) $endpoint->getMask() + ); + + $query = ''; + return $this->getBaseUri() . $mask . $query . $fragment; + } + +} diff --git a/src/LinkGenerator/LinkGenerator.php b/src/LinkGenerator/LinkGenerator.php new file mode 100644 index 00000000..b85a6e09 --- /dev/null +++ b/src/LinkGenerator/LinkGenerator.php @@ -0,0 +1,14 @@ +requestParameterMapping = $requestParameterMapping; + } + + /** + * @param mixed[] $parameters + */ + protected function buildUrl(Endpoint $endpoint, array $parameters, string $fragment): string + { + $pathParameters = []; + foreach ($endpoint->getParametersByIn(EndpointParameter::IN_PATH) as $parameterDefinition) { + $name = $parameterDefinition->getName(); + if (isset($parameters[$name])) { + $pathParameters[$name] = $this->checkAndReplaceParameterType($parameterDefinition, $parameters[$name]); + unset($parameters[$name]); + } elseif (false) { // phpcs:ignore + //TODO - $parameter->hasDefaultValue() && ($parameter->isRequired || !$parameter->isAllowEmpty()) + } elseif ($parameterDefinition->isAllowEmpty() || !$parameterDefinition->isRequired()) { + //TODO - path parameter emptiness should be checked in schema validation + throw new InvalidArgumentException(sprintf( + 'Path parameter "%s" should not be empty. Please report to us if you have valid use case where it could be empty.', + $parameterDefinition->getName() + )); + } else { + $handler = $endpoint->getHandler(); + throw new InvalidArgumentException(sprintf( + 'Cannot generate url for method "%s:%s", required parameter "%s" is missing.', + $handler->getClass(), + $handler->getMethod(), + $name + )); + } + } + + $queryParameters = []; + foreach ($endpoint->getParametersByIn(EndpointParameter::IN_QUERY) as $parameterDefinition) { + $name = $parameterDefinition->getName(); + if (isset($parameters[$name])) { + $queryParameters[$name] = $this->checkAndReplaceParameterType($parameterDefinition, $parameters[$name]); + unset($parameters[$name]); + } elseif (false) { // phpcs:ignore + //TODO - $parameter->hasDefaultValue() && ($parameter->isRequired || !$parameter->isAllowEmpty()) + } elseif (!$parameterDefinition->isAllowEmpty() || $parameterDefinition->isRequired()) { + $handler = $endpoint->getHandler(); + throw new InvalidArgumentException(sprintf( + 'Cannot generate url for method "%s:%s", required parameter "%s" is missing.', + $handler->getClass(), + $handler->getMethod(), + $name + )); + } + } + + if ($parameters !== []) { + throw new InvalidArgumentException(sprintf('Parameters "%s" are not defined in a path nor query.', implode(', ', array_keys($parameters)))); + } + + //TODO - check if all parameters in mask are replaced (needed only if path EndpointParameters are optional - CoreMappingPlugin enforce it by RequestParameterValidation) + $mask = preg_replace_callback( + '#{(.*?)}#', + function ($match) use ($pathParameters) { + return $pathParameters[$match[1]]; + }, + (string) $endpoint->getMask() + ); + + $query = http_build_query($queryParameters); + + return $this->getBaseUri() . $mask . $query . $fragment; + } + + /** + * @param mixed $value + * @return mixed + */ + private function checkAndReplaceParameterType(EndpointParameter $parameter, $value) + { + if ($parameter->isAllowEmpty() && in_array($value, [null, '', []], true)) { + return ''; + } + + $type = $parameter->getType(); + $mapper = $this->requestParameterMapping->getMapper($type); + + if ($mapper === null) { + throw new InvalidStateException(sprintf('Missing mapper for parameter of type "%s".', $type)); + } + + return $mapper->denormalize($value); + } + +} diff --git a/src/Mapping/Parameter/BooleanTypeMapper.php b/src/Mapping/Parameter/BooleanTypeMapper.php index a13a15df..42d518a4 100644 --- a/src/Mapping/Parameter/BooleanTypeMapper.php +++ b/src/Mapping/Parameter/BooleanTypeMapper.php @@ -23,4 +23,21 @@ public function normalize($value): ?bool throw new InvalidArgumentTypeException(InvalidArgumentTypeException::TYPE_BOOLEAN); } + /** + * @param mixed $value + * @throws InvalidArgumentTypeException + */ + public function denormalize($value): string + { + if ($value === 'true' || $value === true) { + return 'true'; + } + + if ($value === 'false' || $value === false) { + return 'false'; + } + + throw new InvalidArgumentTypeException(InvalidArgumentTypeException::TYPE_BOOLEAN); + } + } diff --git a/src/Mapping/Parameter/DateTimeTypeMapper.php b/src/Mapping/Parameter/DateTimeTypeMapper.php index a9b7bd3f..e6877b03 100644 --- a/src/Mapping/Parameter/DateTimeTypeMapper.php +++ b/src/Mapping/Parameter/DateTimeTypeMapper.php @@ -4,6 +4,7 @@ use Apitte\Core\Exception\Runtime\InvalidArgumentTypeException; use DateTimeImmutable; +use DateTimeInterface; use TypeError; class DateTimeTypeMapper implements ITypeMapper @@ -27,4 +28,18 @@ public function normalize($value): ?DateTimeImmutable throw new InvalidArgumentTypeException(InvalidArgumentTypeException::TYPE_DATETIME); } + /** + * @param mixed $value + * @throws InvalidArgumentTypeException + */ + public function denormalize($value): string + { + if ($value instanceof DateTimeInterface) { + return $value->format(DATE_ATOM); + } + + //TODO - support different date formats? + throw new InvalidArgumentTypeException(InvalidArgumentTypeException::TYPE_DATETIME); + } + } diff --git a/src/Mapping/Parameter/FloatTypeMapper.php b/src/Mapping/Parameter/FloatTypeMapper.php index 49e127f8..30e9dbfe 100644 --- a/src/Mapping/Parameter/FloatTypeMapper.php +++ b/src/Mapping/Parameter/FloatTypeMapper.php @@ -23,4 +23,12 @@ public function normalize($value): ?float throw new InvalidArgumentTypeException(InvalidArgumentTypeException::TYPE_FLOAT); } + /** + * @param mixed $value + */ + public function denormalize($value): string + { + return (string) $value; + } + } diff --git a/src/Mapping/Parameter/ITypeMapper.php b/src/Mapping/Parameter/ITypeMapper.php index 7872dc66..c6efb13a 100644 --- a/src/Mapping/Parameter/ITypeMapper.php +++ b/src/Mapping/Parameter/ITypeMapper.php @@ -14,4 +14,10 @@ interface ITypeMapper */ public function normalize($value); + /** + * @param mixed $value + * @throws InvalidArgumentTypeException + */ + public function denormalize($value): string; + } diff --git a/src/Mapping/Parameter/IntegerTypeMapper.php b/src/Mapping/Parameter/IntegerTypeMapper.php index 531564be..513b21bd 100644 --- a/src/Mapping/Parameter/IntegerTypeMapper.php +++ b/src/Mapping/Parameter/IntegerTypeMapper.php @@ -19,4 +19,12 @@ public function normalize($value): int throw new InvalidArgumentTypeException(InvalidArgumentTypeException::TYPE_INTEGER); } + /** + * @param mixed $value + */ + public function denormalize($value): string + { + return (string) $value; + } + } diff --git a/src/Mapping/Parameter/StringTypeMapper.php b/src/Mapping/Parameter/StringTypeMapper.php index b5bd84fd..3f86dfe9 100644 --- a/src/Mapping/Parameter/StringTypeMapper.php +++ b/src/Mapping/Parameter/StringTypeMapper.php @@ -2,6 +2,8 @@ namespace Apitte\Core\Mapping\Parameter; +use Apitte\Core\Exception\Runtime\InvalidArgumentTypeException; + class StringTypeMapper implements ITypeMapper { @@ -13,4 +15,13 @@ public function normalize($value): ?string return (string) $value; } + /** + * @param mixed $value + * @throws InvalidArgumentTypeException + */ + public function denormalize($value): string + { + return (string) $value; + } + } diff --git a/src/Mapping/RequestParameterMapping.php b/src/Mapping/RequestParameterMapping.php index 8421268c..fb607296 100644 --- a/src/Mapping/RequestParameterMapping.php +++ b/src/Mapping/RequestParameterMapping.php @@ -213,7 +213,10 @@ protected function normalize($value, EndpointParameter $parameter, ITypeMapper $ } } - protected function getMapper(string $type): ?ITypeMapper + /** + * @internal + */ + public function getMapper(string $type): ?ITypeMapper { if (!isset($this->types[$type])) return null; diff --git a/tests/cases/DI/ApiExtension.phpt b/tests/cases/DI/ApiExtension.phpt index 57f74a87..d8135387 100644 --- a/tests/cases/DI/ApiExtension.phpt +++ b/tests/cases/DI/ApiExtension.phpt @@ -7,6 +7,7 @@ use Apitte\Core\Application\Application; use Apitte\Core\DI\ApiExtension; use Apitte\Core\Dispatcher\JsonDispatcher; +use Apitte\Core\Http\RequestScopeStorage; use Apitte\Core\Schema\Schema; use Nette\DI\Compiler; use Nette\DI\Container; @@ -34,6 +35,7 @@ test(function (): void { Assert::type(JsonDispatcher::class, $container->getService('api.core.dispatcher')); Assert::type(Schema::class, $container->getService('api.core.schema')); Assert::type(Application::class, $container->getService('api.core.application')); + Assert::type(RequestScopeStorage::class, $container->getService('api.core.requestScopeStorage')); }); // Annotations diff --git a/tests/cases/Http/RequestScopeStorage.phpt b/tests/cases/Http/RequestScopeStorage.phpt new file mode 100644 index 00000000..2e344cda --- /dev/null +++ b/tests/cases/Http/RequestScopeStorage.phpt @@ -0,0 +1,27 @@ +has('missing')); + Assert::null($storage->load('missing')); + Assert::same('', $storage->load('missing', '')); + + $storage->save('exists', 'foobar'); + Assert::true($storage->has('exists')); + Assert::same('foobar', $storage->load('exists')); + + $storage->clear(); + + Assert::false($storage->has('exists')); + Assert::null($storage->load('exists')); +}); diff --git a/tests/cases/LinkGenerator/ControllerMapper.phpt b/tests/cases/LinkGenerator/ControllerMapper.phpt new file mode 100644 index 00000000..1928922d --- /dev/null +++ b/tests/cases/LinkGenerator/ControllerMapper.phpt @@ -0,0 +1,49 @@ +setMapping([ + '*' => ['*', '*'], + ]); + }, + InvalidStateException::class, + 'Invalid mapping mask for module "*".' + ); +}); + +// Controller not found +test(function (): void { + $mapper = new ControllerMapper(); + + Assert::exception(function () use ($mapper): void { + $mapper->getControllerClass('Foo:Bar:Api:V1:Users'); + }, InvalidControllerException::class, 'Cannot load controller "Foo:Bar:Api:V1:Users", class "Foo\Bar\Api\V1\UsersController" was not found.'); +}); + +// Controller without IController +test(function (): void { + $mapper = new ControllerMapper(); + + Assert::exception(function () use ($mapper): void { + $mapper->getControllerClass('Tests:Fixtures:Controllers:NoInterfaceInvalid'); + }, InvalidControllerException::class, 'Cannot load controller "Tests:Fixtures:Controllers:NoInterfaceInvalid", class "Tests\Fixtures\Controllers\NoInterfaceInvalidController" is not "Apitte\Core\UI\Controller\IController" implementor.'); +}); + +// Abstract controller +test(function (): void { + $mapper = new ControllerMapper(); + + Assert::exception(function () use ($mapper): void { + $mapper->getControllerClass('Tests:Fixtures:Controllers:Abstract'); + }, InvalidControllerException::class, 'Cannot load controller "Tests:Fixtures:Controllers:Abstract", class "Tests\Fixtures\Controllers\AbstractController" is abstract.'); +}); diff --git a/tests/cases/LinkGenerator/LinkGenerator.phpt b/tests/cases/LinkGenerator/LinkGenerator.phpt new file mode 100644 index 00000000..4540cdbc --- /dev/null +++ b/tests/cases/LinkGenerator/LinkGenerator.phpt @@ -0,0 +1,116 @@ +link('abcd'); + }, InvalidLinkException::class, 'Invalid link destination "abcd".'); +}); + +// Controller not found in schema +test(function (): void { + $schema = new Schema(); + $storage = new RequestScopeStorage(); + $controllerMapper = new ControllerMapper(); + $requestParameterMapping = new RequestParameterMapping(); + $generator = new StrictLinkGenerator($schema, $storage, $controllerMapper, $requestParameterMapping); + + Assert::exception(function () use ($generator): void { + $link = $generator->link('Tests:Fixtures:Controllers:Foobar:baz1'); + }, InvalidStateException::class, 'Controller "Tests\Fixtures\Controllers\FoobarController" is missing in schema.'); +}); + +// Controller found in schema, but not with requested method +test(function (): void { + $schema = new Schema(); + $storage = new RequestScopeStorage(); + $controllerMapper = new ControllerMapper(); + $requestParameterMapping = new RequestParameterMapping(); + $generator = new StrictLinkGenerator($schema, $storage, $controllerMapper, $requestParameterMapping); + + $endpoint = new Endpoint(new EndpointHandler(FoobarController::class, 'baz2')); + $schema->addEndpoint($endpoint); + + Assert::exception(function () use ($generator): void { + $link = $generator->link('Tests:Fixtures:Controllers:Foobar:baz1'); + }, InvalidStateException::class, 'Controllers "Tests\Fixtures\Controllers\FoobarController" method "baz1" is missing in schema.'); +}); + +// Endpoint found - empty mask +test(function (): void { + $schema = new Schema(); + $storage = new RequestScopeStorage(); + $controllerMapper = new ControllerMapper(); + $requestParameterMapping = new RequestParameterMapping(); + $generator = new StrictLinkGenerator($schema, $storage, $controllerMapper, $requestParameterMapping); + + $endpoint = new Endpoint(new EndpointHandler(FoobarController::class, 'baz1')); + $schema->addEndpoint($endpoint); + + Assert::same('', $generator->link('Tests:Fixtures:Controllers:Foobar:baz1')); +}); + +// Endpoint found - with mask +test(function (): void { + $schema = new Schema(); + $storage = new RequestScopeStorage(); + $controllerMapper = new ControllerMapper(); + $requestParameterMapping = new RequestParameterMapping(); + $generator = new StrictLinkGenerator($schema, $storage, $controllerMapper, $requestParameterMapping); + + $endpoint = new Endpoint(new EndpointHandler(FoobarController::class, 'baz1')); + $endpoint->setMask('/api/v1/foo/bar/baz'); + $schema->addEndpoint($endpoint); + + Assert::same('/api/v1/foo/bar/baz', $generator->link('Tests:Fixtures:Controllers:Foobar:baz1')); +}); + +// Endpoint found - with mask with string parameters +test(function (): void { + $schema = new Schema(); + $storage = new RequestScopeStorage(); + $controllerMapper = new ControllerMapper(); + $requestParameterMapping = new RequestParameterMapping(); + $requestParameterMapping->addMapper(EndpointParameter::TYPE_STRING, StringTypeMapper::class); + $generator = new StrictLinkGenerator($schema, $storage, $controllerMapper, $requestParameterMapping); + + $endpoint = new Endpoint(new EndpointHandler(FoobarController::class, 'baz1')); + $endpoint->setMask('/api/v1/foo/{bar}/{baz}'); + + $parameter1 = new EndpointParameter('bar'); + $endpoint->addParameter($parameter1); + + $parameter2 = new EndpointParameter('baz'); + $endpoint->addParameter($parameter2); + + $schema->addEndpoint($endpoint); + + Assert::same('/api/v1/foo/lorem/ipsum', $generator->link('Tests:Fixtures:Controllers:Foobar:baz1', ['bar' => 'lorem', 'baz' => 'ipsum'])); + Assert::same('/api/v1/foo/lorem/ipsum#loremipsum', $generator->link('Tests:Fixtures:Controllers:Foobar:baz1#loremipsum', ['bar' => 'lorem', 'baz' => 'ipsum'])); +}); diff --git a/tests/fixtures/Controllers/NoInterfaceInvalidController.php b/tests/fixtures/Controllers/NoInterfaceInvalidController.php new file mode 100644 index 00000000..6363dd8f --- /dev/null +++ b/tests/fixtures/Controllers/NoInterfaceInvalidController.php @@ -0,0 +1,8 @@ +