Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@

### Unreleased

* Add ResponseFactory helper class for creating simple response objects.
* Add HealthOKHandler for returning a simple 200 OK response to a GET, and NotFoundHandler to return a plain-text
404 response.
* Add RequestMethodGuardTrait that handlers can use to ensure that the incoming request is an allowed method, returns 405 if not.
* [BREAKING]: Handler factory functions now take HandlerFactoryArgs instead of LoggerInterface, this allows us to
also pass the request, to allow for simple routing to different handlers.
* [BREAKING]: Added strict types and array shapes throughout.
Expand Down
19 changes: 19 additions & 0 deletions src/Const/RequestMethod.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Ingenerator\MicroFramework\Const;

enum RequestMethod: string
{
case GET = 'GET';
case QUERY = 'QUERY';
case HEAD = 'HEAD';
case OPTIONS = 'OPTIONS';
case TRACE = 'TRACE';
case PUT = 'PUT';
case DELETE = 'DELETE';
case POST = 'POST';
case PATCH = 'PATCH';
case CONNECT = 'CONNECT';
}
29 changes: 29 additions & 0 deletions src/Handler/HealthOKHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Ingenerator\MicroFramework\Handler;

use Ingenerator\MicroFramework\Const\RequestMethod;
use Ingenerator\MicroFramework\Handler\Trait\RequestMethodGuardTrait;
use Ingenerator\MicroFramework\RequestHandler;
use Ingenerator\MicroFramework\ResponseFactory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
* Simple handler that just returns a 200 response with text "OK" on any GET request.
*
* Designed for e.g. use servicing a healthcheck endpoint, or when bootstrapping a new
* project before actual handlers are implemented.
*/
final class HealthOKHandler implements RequestHandler
{
use RequestMethodGuardTrait;

public function handle(ServerRequestInterface $request): ResponseInterface
{
return $this->guardRequestMethod($request, RequestMethod::GET)
?? ResponseFactory::text('OK', 200);
}
}
25 changes: 25 additions & 0 deletions src/Handler/NotFoundHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Ingenerator\MicroFramework\Handler;

use Ingenerator\MicroFramework\RequestHandler;
use Ingenerator\MicroFramework\ResponseFactory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
* Just returns a 404 to all requests.
*
* Useful if your handler factory function returns different handlers for different
* requests / paths so that you need a fallback handler for anything that doesn't
* match.
*/
final class NotFoundHandler implements RequestHandler
{
public function handle(ServerRequestInterface $request): ResponseInterface
{
return ResponseFactory::text('Not Found', 404);
}
}
39 changes: 39 additions & 0 deletions src/Handler/Trait/RequestMethodGuardTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Ingenerator\MicroFramework\Handler\Trait;

use Ingenerator\MicroFramework\Const\RequestMethod;
use Ingenerator\MicroFramework\ResponseFactory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
* Ensures that the incoming request is an allowed method, returns 405 if not.
*
* Usage:
*
* class Something implements RequestHandler {
* use RequestMethodGuardTrait;
*
* public function handle(ServerRequestInterface $request): ResponseInterface {
* return $this->guardRequestMethod($request, RequestMethod::GET)
* ?? ResponseFactory::text('Great! This is an allowed method!');
* }
* }
*/
trait RequestMethodGuardTrait
{
private function guardRequestMethod(ServerRequestInterface $request, RequestMethod $allowedMethod, RequestMethod ...$allowedMethods): ?ResponseInterface
{
array_unshift($allowedMethods, $allowedMethod);
$allowedMethodStrs = array_map(fn (RequestMethod $method) => $method->value, $allowedMethods);

if (in_array(strtoupper($request->getMethod()), $allowedMethodStrs)) {
return null;
}

return ResponseFactory::text('Method not allowed', status: 405, headers: ['Allow' => implode(',', $allowedMethodStrs)]);
}
}
45 changes: 45 additions & 0 deletions src/ResponseFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace Ingenerator\MicroFramework;

use GuzzleHttp\Psr7\Response;
use JsonException;

final class ResponseFactory
{
private function __construct()
{
}

/**
* Shorthand to create a text/plain response.
*
* @param array<string, array<string>|string> $headers
*/
public static function text(string $content, int $status = 200, array $headers = []): Response
{
return new Response(
$status,
['Content-Type' => 'text/plain', ...$headers],
$content
);
}

/**
* Shorthand to create an application/json response.
*
* @param array<string, array<string>|string> $headers
*
* @throws JsonException
*/
public static function json(mixed $content, int $status = 200, array $headers = []): Response
{
return new Response(
$status,
['Content-Type' => 'application/json', ...$headers],
json_encode($content, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
);
}
}
38 changes: 38 additions & 0 deletions test/unit/AssertResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace test\unit;

use PHPUnit\Framework\Assert;
use Psr\Http\Message\ResponseInterface;

final class AssertResponse
{
/**
* @param array<string, array<string>> $expectHeaders
*/
public static function isExactly(
int $expectStatus,
string $expectBody,
array $expectHeaders,
ResponseInterface $actual,
): void {
// Ensure body has not been read elsewhere
$actual->getBody()->rewind();
Assert::assertSame(
[
'status' => $expectStatus,
'body' => $expectBody,
'headers' => $expectHeaders,
],
[
'status' => $actual->getStatusCode(),
'body' => $actual->getBody()->getContents(),
'headers' => $actual->getHeaders(),
]
);
// Rewind for any further reads
$actual->getBody()->rewind();
}
}
61 changes: 61 additions & 0 deletions test/unit/Handler/HealthOKHandlerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace test\unit\Handler;

use GuzzleHttp\Psr7\ServerRequest;
use Ingenerator\MicroFramework\Handler\HealthOKHandler;
use Ingenerator\MicroFramework\RequestHandler;
use Iterator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use test\unit\AssertResponse;

final class HealthOKHandlerTest extends TestCase
{
public function testItIsInitialisableRequestHandler(): void
{
$this->assertInstanceOf(RequestHandler::class, $this->newSubject());
}

public function testItReturns200OKForGetRequest(): void
{
$request = new ServerRequest('GET', '/healthz');

$response = $this->newSubject()->handle($request);
AssertResponse::isExactly(200, 'OK', ['Content-Type' => ['text/plain']], $response);
}

/**
* @return Iterator<int, array{string}>
*/
public static function provider_disallowed_methods(): Iterator
{
// Two is enough to prove the point
yield ['POST'];
yield ['HEAD'];
}

#[DataProvider('provider_disallowed_methods')]
public function testItReturns405MethodNotAllowedForNonGetRequests(string $method): void
{
$request = new ServerRequest($method, '/healthz');
$response = $this->newSubject()->handle($request);

AssertResponse::isExactly(
405,
'Method not allowed',
[
'Content-Type' => ['text/plain'],
'Allow' => ['GET'],
],
$response
);
}

private function newSubject(): HealthOKHandler
{
return new HealthOKHandler();
}
}
37 changes: 37 additions & 0 deletions test/unit/Handler/NotFoundHandlerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace test\unit\Handler;

use GuzzleHttp\Psr7\ServerRequest;
use Ingenerator\MicroFramework\Handler\NotFoundHandler;
use Ingenerator\MicroFramework\RequestHandler;
use PHPUnit\Framework\TestCase;
use test\unit\AssertResponse;

final class NotFoundHandlerTest extends TestCase
{
public function testItIsInitialisableRequestHandler(): void
{
$this->assertInstanceOf(RequestHandler::class, $this->newSubject());
}

public function testItReturns404NotFoundResponse(): void
{
$request = new ServerRequest('GET', '/any-path');
$response = $this->newSubject()->handle($request);

AssertResponse::isExactly(
404,
'Not Found',
['Content-Type' => ['text/plain']],
$response
);
}

private function newSubject(): NotFoundHandler
{
return new NotFoundHandler();
}
}
Loading
Loading