JsonApi/Request/Parser.php
<?php
/**
* @copyright 2014 Integ S.A.
* @license http://opensource.org/licenses/MIT The MIT License (MIT)
* @author Javier Lorenzana <javier.lorenzana@gointegro.com>
*/
namespace GoIntegro\Hateoas\JsonApi\Request;
// HTTP.
use Symfony\Component\HttpFoundation\Request;
// RAML.
use GoIntegro\Raml\DocNavigator;
// JSON-API.
use GoIntegro\Hateoas\JsonApi\Document;
// Metadata.
use GoIntegro\Hateoas\Metadata\Resource\MetadataMinerInterface;
// Events.
use Doctrine\Common\EventSubscriber;
// Config.
use GoIntegro\Hateoas\Config\ResourceEntityMapper;
/**
* @see http://jsonapi.org/format/#fetching
*/
class Parser
{
const HTTP_OPTIONS = 'OPTIONS',
HTTP_HEAD = 'HEAD',
HTTP_GET = 'GET',
HTTP_POST = 'POST',
HTTP_PUT = 'PUT',
HTTP_DELETE = 'DELETE',
HTTP_PATCH = 'PATCH';
const PRIMARY_RESOURCE_TYPE = 0,
PRIMARY_RESOURCE_IDS = 1,
PRIMARY_RESOURCE_FIELD = 2,
PRIMARY_RESOURCE_RELATIONSHIP = 3,
RELATIONSHIP_RESOURCE_IDS = 4; // For multiple relationship deletes.
const ERROR_NO_API_BASE_PATH
= "The API base path is not configured.",
ERROR_MULTIPLE_IDS_WITH_RELATIONSHIP = "Multiple Ids are not supported when requesting a resource field or link.",
ERROR_RESOURCE_NOT_FOUND = "The requested resource was not found.",
ERROR_ACTION_NOT_ALLOWED = "The attempted action is not allowed on the requested resource. Supported HTTP methods are [%s].",
ERROR_RELATIONSHIP_UNDEFINED = "The requested relationship is undefined or can only be accessed through its own URL, filtering by its relationship with the current resource.",
ERROR_CONTENT_ON_DELETE = "JSON-API expects DELETE requests not to have a body.";
/**
* @var DocNavigator
*/
private $docNavigator;
/**
* @var ResourceEntityMapper
*/
private $resourceEntityMapper;
/**
* @var string
*/
private $apiUrlPath;
/**
* @var array
*/
private $magicServices;
/**
* @var PaginationParser
*/
private $paginationParser;
/**
* @var FilterParser
*/
private $filterParser;
/**
* @var BodyParser
*/
private $bodyParser;
/**
* @var ActionParser
*/
private $actionParser;
/**
* @var ParamEntityFinder
*/
private $entityFinder;
/**
* @var LocaleNegotiator
*/
private $localeNegotiator;
/**
* @var EventSubscriber
*/
private $translatableListener;
/**
* @var MetadataMinerInterface
*/
private $mm;
/**
* @param ResourceEntityMapper $resourceEntityMapper
* @param DocNavigator $docNavigator
* @param FilterParser $filterParser
* @param PaginationParser $paginationParser
* @param BodyParser $bodyParser
* @param ActionParser $actionParser
* @param ParamEntityFinder $entityFinder
* @param LocaleNegotiator $localeNegotiator
* @param MetadataMinerInterface $mm
* @param string $apiUrlPath
*/
public function __construct(
ResourceEntityMapper $resourceEntityMapper,
DocNavigator $docNavigator,
FilterParser $filterParser,
PaginationParser $paginationParser,
BodyParser $bodyParser,
ActionParser $actionParser,
ParamEntityFinder $entityFinder,
LocaleNegotiator $localeNegotiator,
MetadataMinerInterface $mm,
$apiUrlPath = ''
)
{
$this->resourceEntityMapper = $resourceEntityMapper;
$this->docNavigator = $docNavigator;
$this->apiUrlPath = $apiUrlPath;
$this->paginationParser = $paginationParser;
$this->filterParser = $filterParser;
$this->bodyParser = $bodyParser;
$this->actionParser = $actionParser;
$this->entityFinder = $entityFinder;
$this->localeNegotiator = $localeNegotiator;
$this->mm = $mm;
}
/**
* @param EventSubscriber $translatableListener
*/
public function setTranslatableListener(
EventSubscriber $translatableListener
)
{
$this->translatableListener = $translatableListener;
}
/**
* Parsea ciertos parĂ¡metros de un pedido de HTTP.
* @param Request $request
* @throws ResourceNotFoundException
* @throws ActionNotAllowedException
* @throws ParseException
* @throws EntityAccessDeniedException
* @throws EntityNotFoundException
*/
public function parse(Request $request)
{
$content = $request->getContent();
if (!empty($content) && self::HTTP_DELETE == $request->getMethod()) {
throw new ParseException(self::ERROR_CONTENT_ON_DELETE);
}
$params = new Params;
$params->path = $this->parsePath($request);
$params->i18n = $this->parseI18n($request);
$params->primaryType = $this->parsePrimaryType($request);
$params->primaryClass = $this->getEntityClass($params->primaryType);
$params->relationship = $this->parseRelationship($request, $params);
$params->primaryIds
= $this->parsePrimaryIds($request, $params->relationship);
$params->relationshipIds
= $this->parseRelationshipIds($request);
$params->locale = $this->localeNegotiator->negotiate($request);
if (!empty($this->translatableListener) && !empty($params->locale)) {
$this->translatableListener
->setTranslatableLocale($params->locale);
}
if ($request->query->has('include')) {
$params->include = $this->parseInclude($request);
}
if ($request->query->has('fields')) {
$params->sparseFields
= $this->parseSparseFields($request, $params->primaryType);
}
if ($request->query->has('sort')) {
$params->sorting
= $this->parseSorting($request, $params->primaryType);
}
if ($request->query->has('page')) {
$params->pagination
= $this->paginationParser->parse($request, $params);
}
$params->filters = $this->filterParser->parse($request, $params);
$params->action = $this->actionParser->parse($request, $params);
// Needs the params from the ActionParser.
$params->entities = $this->entityFinder->find($params);
// Needs the params from the ActionParser (and ParamEntityFinder).
$params->resources = $this->bodyParser->parse($request, $params);
return $params;
}
/**
* @param Request $request
* @return string
*/
public function parsePrimaryType(Request $request)
{
return $this->parseUrlPart($request, self::PRIMARY_RESOURCE_TYPE);
}
/**
* @param Request $request
* @param string|NULL $relationship
* @return array
*/
public function parsePrimaryIds(Request $request, $relationship)
{
$ids = $this->parseUrlPart($request, self::PRIMARY_RESOURCE_IDS);
$ids = !empty($ids) ? explode(',', $ids) : [];
if (1 < count($ids) && !empty($relationship)) {
throw new ParseException(
self::ERROR_MULTIPLE_IDS_WITH_RELATIONSHIP
);
}
if (Document::DEFAULT_RESOURCE_LIMIT < count($ids)) {
throw new DocumentTooLargeException;
}
return $ids;
}
/**
* @param Request $request
* @param Params $params
* @return string
*/
public function parseRelationship(Request $request, Params $params)
{
$relationship = $this->parseUrlPart(
$request, self::PRIMARY_RESOURCE_RELATIONSHIP
);
if (!empty($relationship)) {
$metadata = $this->mm->mine($params->primaryClass);
if (
!$metadata->isRelationship($relationship)
|| $metadata->isLinkOnlyRelationship($relationship)
) {
throw new RelationshipNotFoundException(
self::ERROR_RELATIONSHIP_UNDEFINED
);
}
}
return $relationship;
}
/**
* @param Request $request
* @return array
*/
public function parseRelationshipIds(Request $request)
{
$ids = $this->parseUrlPart($request, self::RELATIONSHIP_RESOURCE_IDS);
$ids = !empty($ids) ? explode(',', $ids) : [];
if (Document::DEFAULT_RESOURCE_LIMIT < count($ids)) {
throw new DocumentTooLargeException;
}
return $ids;
}
/**
* @param Request $request
* @param integer $part
* @return string
*/
private function parseUrlPart(
Request $request, $part = self::PRIMARY_RESOURCE_TYPE
)
{
$path = $this->parsePathParts($request);
return isset($path[$part]) ? $path[$part] : NULL;
}
/**
* @param Request $request
* @return array
*/
private function parsePath(Request $request)
{
$parts = $this->parsePathParts($request);
$path = '/' . implode('/', $parts);
$ramlDoc = $this->docNavigator->getDoc();
$method = strtolower($request->getMethod());
if (!$ramlDoc->isDefined($method, $path)) {
$allowedMethods = $ramlDoc->getAllowedMethods($path, CASE_UPPER);
$message = sprintf(
self::ERROR_ACTION_NOT_ALLOWED, implode(', ', $allowedMethods)
);
throw new ActionNotAllowedException($allowedMethods, $message);
}
return $path;
}
/**
* The "router" Symfony service cannot be used, regretably.
* @param Request $request
* @return array
*/
private function parsePathParts(Request $request)
{
if (empty($this->apiUrlPath)) {
throw new \Exception(self::ERROR_NO_API_BASE_PATH);
}
// Resolving not knowing whether the base contains a domain.
$base = explode('/', $this->apiUrlPath);
$path = explode('/', $request->getPathInfo());
return array_values(array_diff($path, $base));
}
/**
* @param Request $request
* @return array
*/
private function parseInclude(Request $request)
{
$include = explode(',', $request->query->get('include'));
array_walk($include, function(&$relationship) {
$relationship = explode('.', $relationship);
});
return $include;
}
/**
* @param Request $request
* @param string $primaryType
* @return array
*/
private function parseSparseFields(Request $request, $primaryType)
{
$fields = $request->query->get('fields');
$callback = function($fields) {
return explode(',', $fields);
};
if (is_array($fields)) {
$fields = array_map($callback, $fields);
} else {
$fields = [$primaryType => $callback($fields)];
}
return $fields;
}
/**
* @param Request $request
* @param string $primaryType
* @return array
*/
private function parseSorting(Request $request, $primaryType)
{
$sort = $request->query->get('sort');
$sorting = [];
$callback = function($sort, $type) use (&$sorting) {
foreach (explode(',', $sort) as $field) {
if ('-' != substr($field, 0, 1)) {
$order = Params::ASCENDING_ORDER;
} else {
$order = Params::DESCENDING_ORDER;
$field = substr($field, 1);
}
$sorting[$type][$field] = $order;
}
};
if (!is_array($sort)) {
$sort = [$primaryType => $sort];
}
array_walk($sort, $callback);
return $sorting;
}
/**
* @param Request $request
* @return boolean
*/
private function parseI18n(Request $request)
{
$meta = $request->query->get('meta');
$meta = explode(',', $meta);
return in_array('i18n', $meta);
}
/**
* @param string $type
* @return string
* @throws ResourceNotFoundException
*/
private function getEntityClass($type)
{
$map = $this->resourceEntityMapper->map();
if (isset($map[$type])) return $map[$type];
throw new ResourceNotFoundException(self::ERROR_RESOURCE_NOT_FOUND);
}
}