* * 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; } }