Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: LinkGenerator #94

Closed
wants to merge 3 commits into from
Closed
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
20 changes: 17 additions & 3 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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.*$#'
Expand All @@ -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
18 changes: 15 additions & 3 deletions src/Application/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(),
Expand Down
4 changes: 4 additions & 0 deletions src/DI/Plugin/CoreMappingPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

}
14 changes: 14 additions & 0 deletions src/DI/Plugin/CoreServicesPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

}
10 changes: 10 additions & 0 deletions src/Exception/Logical/InvalidControllerException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php declare(strict_types = 1);

namespace Apitte\Core\Exception\Logical;

use Apitte\Core\Exception\LogicalException;

class InvalidControllerException extends LogicalException
{

}
10 changes: 10 additions & 0 deletions src/Exception/Logical/InvalidLinkException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php declare(strict_types = 1);

namespace Apitte\Core\Exception\Logical;

use Apitte\Core\Exception\LogicalException;

class InvalidLinkException extends LogicalException
{

}
45 changes: 45 additions & 0 deletions src/Http/RequestScopeStorage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php declare(strict_types = 1);

namespace Apitte\Core\Http;

class RequestScopeStorage
{

/** @var mixed[] */
private $data = [];

/**
* @param mixed $data
*/
public function save(string $key, $data): void
{
$this->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 = [];
}

}
95 changes: 95 additions & 0 deletions src/LinkGenerator/BaseLinkGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php declare(strict_types = 1);

namespace Apitte\Core\LinkGenerator;

use Apitte\Core\Exception\Logical\InvalidLinkException;
use Apitte\Core\Exception\Logical\InvalidStateException;
use Apitte\Core\Http\RequestScopeStorage;
use Apitte\Core\Schema\Endpoint;
use Apitte\Core\Schema\Schema;
use Psr\Http\Message\UriInterface;

abstract class BaseLinkGenerator implements LinkGenerator
{

/** @var Schema */
protected $schema;

/** @var RequestScopeStorage */
protected $requestScopeStorage;

/** @var Endpoint[][] */
private $endpointCache = [];

/** @var ControllerMapper */
private $controllerMapper;

public function __construct(Schema $schema, RequestScopeStorage $requestScopeStorage, ControllerMapper $mapper)
{
$this->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));
}

}
106 changes: 106 additions & 0 deletions src/LinkGenerator/ControllerMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php declare(strict_types = 1);

namespace Apitte\Core\LinkGenerator;

use Apitte\Core\Exception\Logical\InvalidControllerException;
use Apitte\Core\Exception\Logical\InvalidStateException;
use Apitte\Core\UI\Controller\IController;
use ReflectionClass;

class ControllerMapper
{

/** @var mixed[] of module => 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;
}

}
Loading