json-api-server/src/JsonApi.php

329 lines
9.3 KiB
PHP

<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Tobyz\JsonApiServer;
use HttpAccept\AcceptParser;
use JsonApiPhp\JsonApi\ErrorDocument;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface;
use Tobyz\JsonApiServer\Adapter\AdapterInterface;
use Tobyz\JsonApiServer\Endpoint\Concerns\FindsResources;
use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\InternalServerErrorException;
use Tobyz\JsonApiServer\Exception\MethodNotAllowedException;
use Tobyz\JsonApiServer\Exception\NotAcceptableException;
use Tobyz\JsonApiServer\Exception\NotImplementedException;
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
use Tobyz\JsonApiServer\Exception\UnsupportedMediaTypeException;
use Tobyz\JsonApiServer\Extension\Extension;
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
final class JsonApi implements RequestHandlerInterface
{
public const MEDIA_TYPE = 'application/vnd.api+json';
use FindsResources;
use HasMeta;
/**
* @var string
*/
private $basePath;
/**
* @var Extension[]
*/
private $extensions = [];
/**
* @var ResourceType[]
*/
private $resourceTypes = [];
public function __construct(string $basePath)
{
$this->basePath = $basePath;
}
/**
* Register an extension.
*/
public function extension(Extension $extension)
{
$this->extensions[$extension->uri()] = $extension;
}
/**
* Get all registered extensions.
*/
public function getExtensions(): array
{
return $this->extensions;
}
/**
* Define a new resource type.
*/
public function resourceType(string $type, AdapterInterface $adapter, callable $buildSchema = null): void
{
$this->resourceTypes[$type] = new ResourceType($type, $adapter, $buildSchema);
}
/**
* Get defined resource types.
*
* @return ResourceType[]
*/
public function getResourceTypes(): array
{
return $this->resourceTypes;
}
/**
* Get a resource type.
*
* @throws ResourceNotFoundException if the resource type has not been defined.
*/
public function getResourceType(string $type): ResourceType
{
if (! isset($this->resourceTypes[$type])) {
throw new ResourceNotFoundException($type);
}
return $this->resourceTypes[$type];
}
/**
* Handle a request.
*
* @throws UnsupportedMediaTypeException if the request Content-Type header is invalid
* @throws NotAcceptableException if the request Accept header is invalid
* @throws MethodNotAllowedException if the request method is invalid
* @throws BadRequestException if the request URI is invalid
* @throws NotImplementedException
*/
public function handle(Request $request): Response
{
$this->validateQueryParameters($request);
$context = new Context($this, $request);
$response = $this->runExtensions($context);
if (! $response) {
$response = $this->route($context);
}
return $response->withAddedHeader('Vary', 'Accept');
}
private function runExtensions(Context $context): ?Response
{
$request = $context->getRequest();
$contentTypeExtensionUris = $this->getContentTypeExtensionUris($request);
$acceptableExtensionUris = $this->getAcceptableExtensionUris($request);
$activeExtensions = array_intersect_key(
$this->extensions,
array_flip($contentTypeExtensionUris),
array_flip($acceptableExtensionUris)
);
foreach ($activeExtensions as $extension) {
if ($response = $extension->handle($context)) {
return $response->withHeader('Content-Type', self::MEDIA_TYPE.'; ext='.$extension->uri());
}
}
return null;
}
private function route(Context $context): Response
{
$segments = explode('/', trim($context->getPath(), '/'));
$resourceType = $this->getResourceType($segments[0]);
switch (count($segments)) {
case 1:
return $this->routeCollection($context, $resourceType);
case 2:
return $this->routeResource($context, $resourceType, $segments[1]);
case 3:
throw new NotImplementedException();
case 4:
if ($segments[2] === 'relationships') {
throw new NotImplementedException();
}
}
throw new BadRequestException();
}
private function validateQueryParameters(Request $request): void
{
foreach ($request->getQueryParams() as $key => $value) {
if (
! preg_match('/[^a-z]/', $key)
&& ! in_array($key, ['include', 'fields', 'filter', 'page', 'sort'])
) {
throw (new BadRequestException('Invalid query parameter: '.$key))->setSourceParameter($key);
}
}
}
private function routeCollection(Context $context, ResourceType $resourceType): Response
{
switch ($context->getRequest()->getMethod()) {
case 'GET':
return (new Endpoint\Index())->handle($context, $resourceType);
case 'POST':
return (new Endpoint\Create())->handle($context, $resourceType);
default:
throw new MethodNotAllowedException();
}
}
private function routeResource(Context $context, ResourceType $resourceType, string $resourceId): Response
{
$model = $this->findResource($resourceType, $resourceId, $context);
switch ($context->getRequest()->getMethod()) {
case 'PATCH':
return (new Endpoint\Update())->handle($context, $resourceType, $model);
case 'GET':
return (new Endpoint\Show())->handle($context, $resourceType, $model);
case 'DELETE':
return (new Endpoint\Delete())->handle($context, $resourceType, $model);
default:
throw new MethodNotAllowedException();
}
}
private function getContentTypeExtensionUris(Request $request): array
{
if (! $contentType = $request->getHeaderLine('Content-Type')) {
return [];
}
$mediaList = (new AcceptParser())->parse($contentType);
if ($mediaList->count() > 1) {
throw new UnsupportedMediaTypeException();
}
$mediaType = $mediaList->preferredMedia(0);
if ($mediaType->mimetype() !== JsonApi::MEDIA_TYPE) {
throw new UnsupportedMediaTypeException();
}
$parameters = $mediaType->parameter();
if (! empty(array_diff(array_keys($parameters->all()), ['ext', 'profile']))) {
throw new UnsupportedMediaTypeException();
}
$extensionUris = $parameters->has('ext') ? explode(' ', $parameters->get('ext')) : [];
if (! empty(array_diff($extensionUris, array_keys($this->extensions)))) {
throw new UnsupportedMediaTypeException();
}
return $extensionUris;
}
private function getAcceptableExtensionUris(Request $request): array
{
if (! $accept = $request->getHeaderLine('Accept')) {
return [];
}
$mediaList = (new AcceptParser())->parse($accept);
foreach ($mediaList->all() as $mediaType) {
if (! in_array($mediaType->mimetype(), [JsonApi::MEDIA_TYPE, '*/*'])) {
continue;
}
$parameters = $mediaType->parameter();
if (! empty(array_diff(array_keys($parameters->all()), ['ext', 'profile']))) {
continue;
}
$extensionUris = $parameters->has('ext') ? explode(' ', $parameters->get('ext')) : [];
if (! empty(array_diff($extensionUris, array_keys($this->extensions)))) {
continue;
}
return $extensionUris;
}
throw new NotAcceptableException();
}
/**
* Convert an exception into a JSON:API error document response.
*
* If the exception is not an instance of ErrorProviderInterface, an
* Internal Server Error response will be produced.
*/
public function error($e): Response
{
if (! $e instanceof ErrorProviderInterface) {
$e = new InternalServerErrorException();
}
$errors = $e->getJsonApiErrors();
$status = $e->getJsonApiStatus();
$document = new ErrorDocument(...$errors);
return json_api_response($document, $status);
}
/**
* Get the base path for the API.
*/
public function getBasePath(): string
{
return $this->basePath;
}
/**
* Strip the API's base path from the start of the given path.
*/
public function stripBasePath(string $path): string
{
$basePath = parse_url($this->basePath, PHP_URL_PATH);
$len = strlen($basePath);
if (substr($path, 0, $len) === $basePath) {
$path = substr($path, $len);
}
return $path;
}
}