API: Moved json handling and route-api tagging to ApiRouteHandler
This commit is contained in:
parent
8adad075bf
commit
e2e18db460
|
@ -28,7 +28,6 @@ return [
|
|||
\Engelsystem\Renderer\TwigServiceProvider::class,
|
||||
\Engelsystem\Middleware\RouteDispatcherServiceProvider::class,
|
||||
\Engelsystem\Middleware\RequestHandlerServiceProvider::class,
|
||||
\Engelsystem\Middleware\SessionHandlerServiceProvider::class,
|
||||
\Engelsystem\Http\Validation\ValidationServiceProvider::class,
|
||||
\Engelsystem\Http\RedirectServiceProvider::class,
|
||||
|
||||
|
@ -54,6 +53,7 @@ return [
|
|||
|
||||
// The application code
|
||||
\Engelsystem\Middleware\ErrorHandler::class,
|
||||
\Engelsystem\Middleware\ApiRouteHandler::class,
|
||||
\Engelsystem\Middleware\VerifyCsrfToken::class,
|
||||
\Engelsystem\Middleware\RouteDispatcher::class,
|
||||
\Engelsystem\Middleware\SessionHandler::class,
|
||||
|
|
|
@ -32,12 +32,31 @@ components:
|
|||
bearerFormat: API key from settings
|
||||
|
||||
responses:
|
||||
UnauthorizedError:
|
||||
UnauthorizedError: # 401
|
||||
description: Access token is missing or invalid
|
||||
ForbiddenError:
|
||||
description: The client is not allowed to acces
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
ForbiddenError: # 403
|
||||
description: The client is not allowed to access
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
NotImplementedError: # 501
|
||||
description: This endpoint or method is not implemented
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
|
||||
schemas:
|
||||
Error:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
News:
|
||||
type: object
|
||||
properties:
|
||||
|
|
|
@ -42,7 +42,7 @@ class Authenticator
|
|||
}
|
||||
|
||||
$this->user = $this->userFromSession();
|
||||
if (!$this->user && request()->getAttribute('route-api', false)) {
|
||||
if (!$this->user && request()->getAttribute('route-api-accessible', false)) {
|
||||
$this->user = $this->userFromApi();
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Engelsystem\Middleware;
|
||||
|
||||
use Engelsystem\Exceptions\Handler;
|
||||
use Engelsystem\Http\Exceptions\HttpException;
|
||||
use Engelsystem\Http\Request;
|
||||
use Engelsystem\Http\Response;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Str;
|
||||
use Nyholm\Psr7\Stream;
|
||||
use Nyholm\Psr7\Uri;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Throwable;
|
||||
|
||||
class ApiRouteHandler implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected ?string $apiPrefix = '/api',
|
||||
protected ?array $apiAccessiblePaths = [
|
||||
'/atom',
|
||||
'/rss',
|
||||
'/health',
|
||||
'/ical',
|
||||
'/metrics',
|
||||
'/shifts-json-export',
|
||||
'/stats',
|
||||
]
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the incoming request and handling API responses
|
||||
*/
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$path = (new Uri((string) $request->getUri()))->getPath();
|
||||
if ($request instanceof Request) {
|
||||
$path = $request->getPathInfo();
|
||||
}
|
||||
|
||||
$path = urldecode($path);
|
||||
$isApi = $this->apiPrefix && (Str::startsWith($path, $this->apiPrefix . '/') || $path == $this->apiPrefix);
|
||||
$isApiAccessible = $isApi || $this->apiAccessiblePaths && in_array($path, $this->apiAccessiblePaths);
|
||||
$request = $request
|
||||
->withAttribute('route-api', $isApi)
|
||||
->withAttribute('route-api-accessible', $isApiAccessible);
|
||||
|
||||
return $isApi ? $this->processApi($request, $handler) : $handler->handle($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the API request by ensuring that JSON is returned
|
||||
*/
|
||||
protected function processApi(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
try {
|
||||
$response = $handler->handle($request);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
$response = new Response('', 404);
|
||||
$response->setContent($response->getReasonPhrase());
|
||||
} catch (HttpException $e) {
|
||||
$response = new Response($e->getMessage(), $e->getStatusCode(), $e->getHeaders());
|
||||
$response->setContent($response->getContent() ?: $response->getReasonPhrase());
|
||||
} catch (Throwable $e) {
|
||||
/** @var Handler $handler */
|
||||
$handler = app('error.handler');
|
||||
$handler->exceptionHandler($e, true);
|
||||
$response = new Response('', 500);
|
||||
$response->setContent($response->getReasonPhrase());
|
||||
}
|
||||
|
||||
if (!Str::isJson((string) $response->getBody())) {
|
||||
$content = (string) $response->getBody();
|
||||
$content = Stream::create(json_encode([
|
||||
'message' => $content,
|
||||
]));
|
||||
$response = $response
|
||||
->withHeader('content-type', 'application/json')
|
||||
->withBody($content);
|
||||
}
|
||||
|
||||
$eTag = md5((string) $response->getBody());
|
||||
$response->setEtag($eTag);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
|
@ -14,18 +14,15 @@ use Psr\Http\Server\RequestHandlerInterface;
|
|||
|
||||
class RouteDispatcher implements MiddlewareInterface
|
||||
{
|
||||
protected ?MiddlewareInterface $notFound = null;
|
||||
|
||||
/**
|
||||
* @param ResponseInterface $response Default response
|
||||
* @param ResponseInterface $response Default response
|
||||
* @param MiddlewareInterface|null $notFound Handles any requests if the route can't be found
|
||||
*/
|
||||
public function __construct(
|
||||
protected FastRouteDispatcher $dispatcher,
|
||||
protected ResponseInterface $response,
|
||||
MiddlewareInterface $notFound = null
|
||||
protected ?MiddlewareInterface $notFound = null
|
||||
) {
|
||||
$this->notFound = $notFound;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||
|
||||
namespace Engelsystem\Middleware;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
|
@ -14,26 +13,21 @@ use Symfony\Component\HttpFoundation\Session\Storage\SessionStorageInterface;
|
|||
|
||||
class SessionHandler implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected SessionStorageInterface $session,
|
||||
protected array $paths = [],
|
||||
protected ?string $apiPrefix = null
|
||||
) {
|
||||
public function __construct(protected SessionStorageInterface $session)
|
||||
{
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$requestPath = $request->getAttribute('route-request-path');
|
||||
$isApi = in_array($requestPath, $this->paths)
|
||||
|| ($this->apiPrefix && Str::startsWith($requestPath, $this->apiPrefix));
|
||||
$request = $request->withAttribute('route-api', $isApi);
|
||||
|
||||
$return = $handler->handle($request);
|
||||
|
||||
$cookies = $request->getCookieParams();
|
||||
if (
|
||||
$isApi
|
||||
// Is api (accessible) path
|
||||
$request->getAttribute('route-api-accessible')
|
||||
// Uses native PHP session
|
||||
&& $this->session instanceof NativeSessionStorage
|
||||
// No session cookie was sent on request
|
||||
&& !isset($cookies[$this->session->getName()])
|
||||
) {
|
||||
$this->destroyNative();
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Engelsystem\Middleware;
|
||||
|
||||
use Engelsystem\Container\ServiceProvider;
|
||||
|
||||
class SessionHandlerServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app
|
||||
->when(SessionHandler::class)
|
||||
->needs('$paths')
|
||||
->give(function () {
|
||||
return [
|
||||
'/atom',
|
||||
'/rss',
|
||||
'/health',
|
||||
'/ical',
|
||||
'/metrics',
|
||||
'/shifts-json-export',
|
||||
'/stats',
|
||||
];
|
||||
});
|
||||
$this->app
|
||||
->when(SessionHandler::class)
|
||||
->needs('$apiPrefix')
|
||||
->give('/api');
|
||||
}
|
||||
}
|
|
@ -9,7 +9,7 @@ use Engelsystem\Http\Response;
|
|||
use Engelsystem\Models\News;
|
||||
use League\OpenAPIValidation\PSR7\OperationAddress as OpenApiAddress;
|
||||
use League\OpenAPIValidation\PSR7\ResponseValidator as OpenApiResponseValidator;
|
||||
use League\OpenAPIValidation\PSR7\ValidatorBuilder;
|
||||
use League\OpenAPIValidation\PSR7\ValidatorBuilder as OpenApiValidatorBuilder;
|
||||
|
||||
class ApiControllerTest extends ControllerTest
|
||||
{
|
||||
|
@ -106,7 +106,7 @@ class ApiControllerTest extends ControllerTest
|
|||
parent::setUp();
|
||||
|
||||
$openApiDefinition = $this->app->get('path.resources.api') . '/openapi.yml';
|
||||
$this->validator = (new ValidatorBuilder())
|
||||
$this->validator = (new OpenApiValidatorBuilder())
|
||||
->fromYamlFile($openApiDefinition)
|
||||
->getResponseValidator();
|
||||
}
|
||||
|
|
|
@ -90,7 +90,7 @@ class AuthenticatorTest extends ServiceProviderTest
|
|||
$session = new Session(new MockArraySessionStorage());
|
||||
|
||||
$request = $request->withHeader('Authorization', 'Bearer F00Bar');
|
||||
$request = $request->withAttribute('route-api', true);
|
||||
$request = $request->withAttribute('route-api-accessible', true);
|
||||
$this->app->instance('request', $request);
|
||||
User::factory()->create(['api_key' => 'F00Bar']);
|
||||
|
||||
|
@ -160,7 +160,7 @@ class AuthenticatorTest extends ServiceProviderTest
|
|||
$this->initDatabase();
|
||||
|
||||
$request = new Request();
|
||||
$request = $request->withAttribute('route-api', true);
|
||||
$request = $request->withAttribute('route-api-accessible', true);
|
||||
$session = new Session(new MockArraySessionStorage());
|
||||
$this->app->instance('request', $request);
|
||||
|
||||
|
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Engelsystem\Test\Unit\Middleware;
|
||||
|
||||
use Engelsystem\Exceptions\Handler;
|
||||
use Engelsystem\Http\Exceptions\HttpNotFound;
|
||||
use Engelsystem\Http\Request;
|
||||
use Engelsystem\Http\Response;
|
||||
use Engelsystem\Middleware\ApiRouteHandler;
|
||||
use Engelsystem\Models\User\User;
|
||||
use Engelsystem\Test\Unit\TestCase;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
class ApiRouteHandlerTest extends TestCase
|
||||
{
|
||||
public function provideIsApi(): array
|
||||
{
|
||||
return [
|
||||
['/foo', false],
|
||||
['/lorem/api', false],
|
||||
['/apiDocs', false],
|
||||
['/api', true],
|
||||
['/api/', true],
|
||||
['/api/lorem', true],
|
||||
['/api/v1/testing', true],
|
||||
];
|
||||
}
|
||||
|
||||
public function provideIsApiAccessiblePath(): array
|
||||
{
|
||||
return [
|
||||
...$this->provideIsApi(),
|
||||
['/metrics', true, false],
|
||||
['/metrics/test', false, false],
|
||||
['/health', true, false],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers \Engelsystem\Middleware\ApiRouteHandler::process
|
||||
* @covers \Engelsystem\Middleware\ApiRouteHandler::processApi
|
||||
* @covers \Engelsystem\Middleware\ApiRouteHandler::__construct
|
||||
* @dataProvider provideIsApi
|
||||
*/
|
||||
public function testProcessIsApi(string $uri, bool $isApi): void
|
||||
{
|
||||
$request = Request::create($uri);
|
||||
/** @var RequestHandlerInterface|MockObject $handler */
|
||||
$handler = $this->getMockForAbstractClass(RequestHandlerInterface::class);
|
||||
$response = new Response('response content');
|
||||
|
||||
$handler->expects($this->once())
|
||||
->method('handle')
|
||||
->willReturnCallback(function (ServerRequestInterface $request) use ($response, $isApi) {
|
||||
$this->assertEquals($isApi, $request->getAttribute('route-api'));
|
||||
return $response;
|
||||
});
|
||||
|
||||
$middleware = new ApiRouteHandler();
|
||||
$apiResponse = $middleware->process($request, $handler);
|
||||
|
||||
if ($isApi) {
|
||||
$this->assertEquals('application/json', $apiResponse->getHeaderLine('content-type'));
|
||||
$this->assertEquals('{"message":"response content"}', (string) $apiResponse->getBody());
|
||||
$this->assertNotEmpty($apiResponse->getHeaderLine('Etag'));
|
||||
} else {
|
||||
$this->assertEquals($response, $apiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers \Engelsystem\Middleware\ApiRouteHandler::process
|
||||
* @dataProvider provideIsApiAccessiblePath
|
||||
*/
|
||||
public function testProcessIsApiAccessiblePath(string $uri, bool $isApiAccessible, bool $isOnlyApi = true): void
|
||||
{
|
||||
$request = Request::create($uri);
|
||||
/** @var RequestHandlerInterface|MockObject $handler */
|
||||
$handler = $this->getMockForAbstractClass(RequestHandlerInterface::class);
|
||||
$response = new Response('response content');
|
||||
|
||||
$handler->expects($this->once())
|
||||
->method('handle')
|
||||
->willReturnCallback(function (ServerRequestInterface $request) use ($response, $isApiAccessible) {
|
||||
$this->assertEquals($isApiAccessible, $request->getAttribute('route-api-accessible'));
|
||||
return $response;
|
||||
});
|
||||
|
||||
$middleware = new ApiRouteHandler();
|
||||
$apiResponse = $middleware->process($request, $handler);
|
||||
|
||||
if (!$isOnlyApi) {
|
||||
$this->assertEquals($response, $apiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers \Engelsystem\Middleware\ApiRouteHandler::processApi
|
||||
*/
|
||||
public function testProcessApiModelNotFoundException(): void
|
||||
{
|
||||
$request = Request::create('/api/test');
|
||||
/** @var RequestHandlerInterface|MockObject $handler */
|
||||
$handler = $this->getMockForAbstractClass(RequestHandlerInterface::class);
|
||||
|
||||
$handler->expects($this->once())
|
||||
->method('handle')
|
||||
->willReturnCallback(function (): void {
|
||||
throw new ModelNotFoundException(User::class);
|
||||
});
|
||||
|
||||
$middleware = new ApiRouteHandler();
|
||||
$response = $middleware->process($request, $handler);
|
||||
|
||||
$this->assertEquals(404, $response->getStatusCode());
|
||||
$this->assertEquals('{"message":"Not Found"}', (string) $response->getBody());
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers \Engelsystem\Middleware\ApiRouteHandler::processApi
|
||||
*/
|
||||
public function testProcessApiHttpException(): void
|
||||
{
|
||||
$request = Request::create('/api/test');
|
||||
/** @var RequestHandlerInterface|MockObject $handler */
|
||||
$handler = $this->getMockForAbstractClass(RequestHandlerInterface::class);
|
||||
|
||||
$handler->expects($this->once())
|
||||
->method('handle')
|
||||
->willReturnCallback(function (): void {
|
||||
throw new HttpNotFound();
|
||||
});
|
||||
|
||||
$middleware = new ApiRouteHandler();
|
||||
$response = $middleware->process($request, $handler);
|
||||
|
||||
$this->assertEquals(404, $response->getStatusCode());
|
||||
$this->assertEquals('{"message":"Not Found"}', (string) $response->getBody());
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers \Engelsystem\Middleware\ApiRouteHandler::processApi
|
||||
*/
|
||||
public function testProcessGenericException(): void
|
||||
{
|
||||
$e = new Exception();
|
||||
$request = Request::create('/api/test');
|
||||
/** @var RequestHandlerInterface|MockObject $handler */
|
||||
$handler = $this->getMockForAbstractClass(RequestHandlerInterface::class);
|
||||
$errorHandler = $this->createMock(Handler::class);
|
||||
$this->setExpects($errorHandler, 'exceptionHandler', [$e, true], '', $this->once());
|
||||
$this->app->instance('error.handler', $errorHandler);
|
||||
|
||||
$handler->expects($this->once())
|
||||
->method('handle')
|
||||
->willReturnCallback(function () use ($e): void {
|
||||
throw $e;
|
||||
});
|
||||
|
||||
$middleware = new ApiRouteHandler();
|
||||
$response = $middleware->process($request, $handler);
|
||||
|
||||
$this->assertEquals(500, $response->getStatusCode());
|
||||
$this->assertEquals('{"message":"Internal Server Error"}', (string) $response->getBody());
|
||||
}
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Engelsystem\Test\Unit\Middleware;
|
||||
|
||||
use Engelsystem\Middleware\SessionHandler;
|
||||
use Engelsystem\Middleware\SessionHandlerServiceProvider;
|
||||
use Engelsystem\Test\Unit\ServiceProviderTest;
|
||||
use Illuminate\Contracts\Container\ContextualBindingBuilder;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
|
||||
class SessionHandlerServiceProviderTest extends ServiceProviderTest
|
||||
{
|
||||
/**
|
||||
* @covers \Engelsystem\Middleware\SessionHandlerServiceProvider::register()
|
||||
*/
|
||||
public function testRegister(): void
|
||||
{
|
||||
/** @var ContextualBindingBuilder|MockObject $bindingBuilder */
|
||||
$bindingBuilder = $this->createMock(ContextualBindingBuilder::class);
|
||||
$app = $this->getApp(['when']);
|
||||
|
||||
$app->expects($this->once())
|
||||
->method('when')
|
||||
->with(SessionHandler::class)
|
||||
->willReturn($bindingBuilder);
|
||||
|
||||
$bindingBuilder->expects($this->once())
|
||||
->method('needs')
|
||||
->with('$paths')
|
||||
->willReturn($bindingBuilder);
|
||||
|
||||
$bindingBuilder->expects($this->once())
|
||||
->method('give')
|
||||
->willReturnCallback(function (callable $callable): void {
|
||||
$paths = $callable();
|
||||
|
||||
$this->assertIsArray($paths);
|
||||
$this->assertTrue(in_array('/metrics', $paths));
|
||||
});
|
||||
|
||||
$serviceProvider = new SessionHandlerServiceProvider($app);
|
||||
$serviceProvider->register();
|
||||
}
|
||||
}
|
|
@ -40,13 +40,8 @@ class SessionHandlerTest extends TestCase
|
|||
|
||||
$request->expects($this->exactly(2))
|
||||
->method('getAttribute')
|
||||
->with('route-request-path')
|
||||
->willReturnOnConsecutiveCalls('/foo', '/lorem');
|
||||
|
||||
$request->expects($this->exactly(2))
|
||||
->method('withAttribute')
|
||||
->withConsecutive(['route-api', true], ['route-api', false])
|
||||
->willReturn($request);
|
||||
->with('route-api-accessible')
|
||||
->willReturnOnConsecutiveCalls(true, false);
|
||||
|
||||
$sessionStorage->expects($this->once())
|
||||
->method('getName')
|
||||
|
|
Loading…
Reference in New Issue