Skip to content
Open
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
1 change: 1 addition & 0 deletions .docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
98 changes: 98 additions & 0 deletions .docs/link-generator.md
Original file line number Diff line number Diff line change
@@ -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
}
```
12 changes: 4 additions & 8 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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\.$#'
Expand Down Expand Up @@ -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\\>\\|string\\.$#"
path: %currentWorkingDirectory%/src/OpenApi/SchemaDefinition/Entity/EntityAdapter.php
Expand Down
10 changes: 3 additions & 7 deletions src/Core/DI/Helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/Core/DI/Plugin/CoreServicesPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/Core/DI/Plugin/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down
3 changes: 2 additions & 1 deletion src/Core/Dispatcher/DecoratedDispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down
4 changes: 2 additions & 2 deletions src/Core/Exception/Api/ValidationException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}
}

Expand Down
20 changes: 16 additions & 4 deletions src/Core/Http/ApiRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $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<string, mixed> $params */
$params = $this->getAttribute(RequestAttributes::ATTR_PARAMETERS, []);

return $params[$name] ?? $default;
}

public function getParameters(): mixed
/**
* @return array<string, mixed>
*/
public function getParameters(): array
{
return $this->getAttribute(RequestAttributes::ATTR_PARAMETERS, []);
/** @var array<string, mixed> $params */
$params = $this->getAttribute(RequestAttributes::ATTR_PARAMETERS, []);

return $params;
}

public function getEntity(mixed $default = null): mixed
Expand Down
8 changes: 6 additions & 2 deletions src/Core/Http/ApiResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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;
}

/**
Expand Down
116 changes: 116 additions & 0 deletions src/Core/LinkGenerator/LinkGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php declare(strict_types = 1);

namespace Apitte\Core\LinkGenerator;

use Apitte\Core\Schema\Endpoint;
use Apitte\Core\Schema\EndpointParameter;
use Apitte\Core\Schema\Schema;

class LinkGenerator
{

public function __construct(
private Schema $schema,
)
{
}

/**
* Generate a link to an endpoint.
*
* @param string $destination Controller::method or endpoint ID
* @param array<string, mixed> $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<string, mixed> $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;
}

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

namespace Apitte\Core\LinkGenerator;

use Apitte\Core\Exception\LogicalException;

final class LinkGeneratorException extends LogicalException
{

}
Loading
Loading