diff --git a/README.md b/README.md index 311cdda..fe63f4f 100644 --- a/README.md +++ b/README.md @@ -22,145 +22,178 @@ composer require tobscure/json-api ```php use Tobscure\JsonApi\Document; -use Tobscure\JsonApi\Collection; -// Create a new collection of posts, and specify relationships to be included. -$collection = (new Collection($posts, new PostSerializer)) - ->with(['author', 'comments']); +$resource = new PostResource($post); -// Create a new JSON-API document with that collection as the data. -$document = new Document($collection); +$document = Document::fromData($resource); -// Add metadata and links. -$document->addMeta('total', count($posts)); -$document->addLink('self', 'http://example.com/api/posts'); +$document->setInclude(['author', 'comments']); +$document->setFields(['posts' => ['title', 'body']]); -// Output the document as JSON. +$document->setMetaItem('total', count($posts)); +$document->setSelfLink('http://example.com/api/posts/1'); + +header('Content-Type: ' . $document::MEDIA_TYPE); echo json_encode($document); ``` -### Elements +### Resources -The JSON-API spec describes *resource objects* as objects containing information about a single resource, and *collection objects* as objects containing information about many resources. In this package: +Resources are used to create JSON-API [resource objects](http://jsonapi.org/format/#document-resource-objects). They must implement `Tobscure\JsonApi\ResourceInterface`. An `AbstractResource` class is provided with some basic functionality. Subclasses must specify the resource `$type` and implement the `getId()` method: -- `Tobscure\JsonApi\Resource` represents a *resource object* -- `Tobscure\JsonApi\Collection` represents a *collection object* +```php +use Tobscure\JsonApi\AbstractResource; -Both Resources and Collections are termed as *Elements*. In conceptually the same way that the JSON-API spec describes, a Resource may have **relationships** with any number of other Elements (Resource for has-one relationships, Collection for has-many). Similarly, a Collection may contain many Resources. +class PostResource extends AbstractResource +{ + protected $type = 'posts'; -A JSON-API Document may contain one primary Element. The primary Element will be recursively parsed for relationships with other Elements; these Elements will be added to the Document as **included** resources. + protected $post; -#### Sparse Fieldsets + public function __construct(Post $post) + { + $this->post = $post; + } -You can specify which fields (attributes and relationships) are to be included on an Element using the `fields` method. You must provide a multidimensional array organized by resource type: + public function getId() + { + return $this->post->id; + } +} +``` + +A JSON-API document can then be created from an instantiated resource: ```php -$collection->fields(['posts' => ['title', 'date']]); -``` +$resource = new PostResource($post); -### Serializers +$document = Document::fromData($resource); +``` -A Serializer is responsible for building attributes and relationships for a certain resource type. Serializers must implement `Tobscure\JsonApi\SerializerInterface`. An `AbstractSerializer` is provided with some basic functionality. At a minimum, a serializer must specify its **type** and provide a method to transform **attributes**: +To output a collection of resource objects, map your data to an array of resources: ```php -use Tobscure\JsonApi\AbstractSerializer; +$resources = array_map(function (Post $post) { + return new PostResource($post); +}, $posts); -class PostSerializer extends AbstractSerializer -{ - protected $type = 'posts'; +$document = Document::fromData($resources); +``` + +#### Attributes & Sparse Fieldsets - public function getAttributes($post, array $fields = null) +To add [attributes](http://jsonapi.org/format/#document-resource-object-attributes) to your resource objects, you may implement the `getAttributes()` method in your resource: + +```php + public function getAttributes(array $fields = null) { return [ - 'title' => $post->title, - 'body' => $post->body, - 'date' => $post->date + 'title' => $this->post->title, + 'body' => $this->post->body, + 'date' => $this->post->date ]; } -} ``` -By default, a Resource object's **id** attribute will be set as the `id` property on the model. A serializer can provide a method to override this: +To output resource objects with a [sparse fieldset](http://jsonapi.org/format/#fetching-sparse-fieldsets), pass in an array of [fields](http://jsonapi.org/format/#document-resource-object-fields) (attributes and relationships), organised by resource type: ```php -public function getId($post) -{ - return $post->someOtherKey; -} +$document->setFields(['posts' => ['title', 'body']]); ``` -#### Relationships +The attributes returned by your resources will automatically be filtered according to the sparse fieldset for the resource type. However, if some attributes are expensive to calculate, then you can use the `$fields` argument provided to `getAttributes()`. This will be an `array` of fields, or `null` if no sparse fieldset has been specified. -The `AbstractSerializer` allows you to define a public method for each relationship that exists for a resource. A relationship method should return a `Tobscure\JsonApi\Relationship` instance. +```php + public function getAttributes(array $fields = null) + { + // Calculate the "expensive" attribute only if this field will show up + // in the final output + if ($fields === null || in_array('expensive', $fields)) { + $attributes['expensive'] = $this->getExpensiveAttribute(); + } + + return $attributes; + } +``` + +#### Relationships + +You can [include related resources](http://jsonapi.org/format/#document-compound-documents) alongside the document's primary data. First you must define the available relationships on your resource. The `AbstractResource` base class allows you to define a method for each relationship. Relationship methods should return a `Tobscure\JsonApi\Relationship` instance, containing the related resource(s). ```php -public function comments($post) -{ - $element = new Collection($post->comments, new CommentSerializer); + protected function getAuthorRelationship() + { + $resource = new UserResource($this->post->author); - return new Relationship($element); -} + return Relationship::fromData($resource); + } ``` -By default, the `AbstractSerializer` will convert relationship names from `kebab-case` and `snake_case` into a `camelCase` method name and call that on the serializer. If you wish to customize this behaviour, you may override the `getRelationship` method: +You can then specify which relationship paths should be included on the document: ```php -public function getRelationship($model, $name) -{ - // resolve Relationship called $name for $model -} +$document->setInclude(['author', 'comments', 'comments.author']); ``` -### Meta & Links +By default, the `AbstractResource` implementation will convert included relationship names from `kebab-case` and `snake_case` into a `getCamelCaseRelationship` method name. If you wish to customize this behaviour, you may override the `getRelationship` method: -The `Document`, `Resource`, and `Relationship` classes allow you to add meta information: +```php + public function getRelationship($name) + { + // resolve Relationship for $name + } +``` + +### Meta Information & Links + +The `Document`, `AbstractResource`, and `Relationship` classes allow you to add [meta information](http://jsonapi.org/format/#document-meta): ```php -$document = new Document; -$document->addMeta('key', 'value'); $document->setMeta(['key' => 'value']); +$document->setMetaItem('key', 'value'); +$document->removeMetaItem('key'); ``` -They also allow you to add links in a similar way: +They also allow you to add [links](http://jsonapi.org/format/#document-links). A link's value may be a string, or a `Tobscure\JsonApi\Link` instance. ```php -$resource = new Resource($data, $serializer); -$resource->addLink('self', 'url'); -$resource->setLinks(['key' => 'value']); +use Tobscure\JsonApi\Link; + +$resource->setSelfLink('url'); +$relationship->setRelatedLink(new Link('url', ['some' => 'metadata'])); ``` -You can also easily add pagination links: +You can also easily generate [pagination links](http://jsonapi.org/format/#fetching-pagination) on `Document` and `Relationship` instances: ```php -$document->addPaginationLinks( +$document->setPaginationLinks( 'url', // The base URL for the links - [], // The query params provided in the request + $_GET, // The query params provided in the request 40, // The current offset 20, // The current limit 100 // The total number of results ); ``` -Serializers can provide links and/or meta data as well: + +To define meta information and/or links globally for a resource type, call the appropriate methods in the constructor: ```php -use Tobscure\JsonApi\AbstractSerializer; +use Tobscure\JsonApi\AbstractResource; -class PostSerializer extends AbstractSerializer -{ - // ... - - public function getLinks($post) { - return ['self' => '/posts/' . $post->id]; - } +class PostResource extends AbstractResource +{ + public function __construct(Post $post) + { + $this->post = $post; - public function getMeta($post) { - return ['some' => 'metadata for ' . $post->id]; + $this->setSelfLink('/posts/' . $post->id); + $this->setMetaItem('some', 'metadata for ' . $post->id); } + + // ... } ``` -**Note:** Links and metadata of the resource overrule ones with the same key from the serializer! - ### Parameters The `Tobscure\JsonApi\Parameters` class allows you to easily parse and validate query parameters in accordance with the specification. @@ -173,25 +206,29 @@ $parameters = new Parameters($_GET); #### getInclude -Get the relationships requested for inclusion. Provide an array of available relationship paths; if anything else is present, an `InvalidParameterException` will be thrown. +Get the relationships requested for [inclusion](http://jsonapi.org/format/#fetching-includes). Provide an array of available relationship paths; if anything else is present, an `InvalidParameterException` will be thrown. ```php // GET /api?include=author,comments $include = $parameters->getInclude(['author', 'comments', 'comments.author']); // ['author', 'comments'] + +$document->setInclude($include); ``` #### getFields -Get the fields requested for inclusion, keyed by resource type. +Get the [sparse fieldsets](http://jsonapi.org/format/#fetching-sparse-fieldsets) requested for inclusion, keyed by resource type. ```php // GET /api?fields[articles]=title,body $fields = $parameters->getFields(); // ['articles' => ['title', 'body']] + +$document->setFields($fields); ``` #### getSort -Get the requested sort criteria. Provide an array of available fields that can be sorted by; if anything else is present, an `InvalidParameterException` will be thrown. +Get the requested [sort fields](http://jsonapi.org/format/#fetching-sorting). Provide an array of available fields that can be sorted by; if anything else is present, an `InvalidParameterException` will be thrown. ```php // GET /api?sort=-created,title @@ -200,7 +237,7 @@ $sort = $parameters->getSort(['title', 'created']); // ['created' => 'desc', 'ti #### getLimit and getOffset -Get the offset number and the number of resources to display using a page- or offset-based strategy. `getLimit` accepts an optional maximum. If the calculated offset is below zero, an `InvalidParameterException` will be thrown. +Get the offset number and the number of resources to display using a [page- or offset-based strategy](http://jsonapi.org/format/#fetching-pagination). `getLimit` accepts an optional maximum. If the calculated offset is below zero, an `InvalidParameterException` will be thrown. ```php // GET /api?page[number]=5&page[size]=20 @@ -212,28 +249,41 @@ $limit = $parameters->getLimit(100); // 100 $offset = $parameters->getOffset(); // 20 ``` -### Error Handling +#### getFilter -You can transform caught exceptions into JSON-API error documents using the `Tobscure\JsonApi\ErrorHandler` class. You must register the appropriate `Tobscure\JsonApi\Exception\Handler\ExceptionHandlerInterface` instances. +Get the contents of the [filter](http://jsonapi.org/format/#fetching-filtering) query parameter. ```php -try { - // API handling code -} catch (Exception $e) { - $errors = new ErrorHandler; +// GET /api?filter[author]=toby +$filter = $parameters->getFilter(); // ['author' => 'toby'] +``` + +### Errors - $errors->registerHandler(new InvalidParameterExceptionHandler); - $errors->registerHandler(new FallbackExceptionHandler); +You can create a `Document` containing [error objects](http://jsonapi.org/format/#error-objects) using `Tobscure\JsonApi\Error` instances: - $response = $errors->handle($e); +```php +use Tobscure\JsonApi\Error; - $document = new Document; - $document->setErrors($response->getErrors()); +$error = new Error; - return new JsonResponse($document, $response->getStatus()); -} +$error->setId('1'); +$error->setAboutLink('url'); +$error->setMeta('key', 'value'); +$error->setStatus(400); +$error->setCode('123'); +$error->setTitle('Something Went Wrong'); +$error->setDetail('You dun goofed!'); +$error->setSourcePointer('/data/attributes/body'); +$error->setSourceParameter('include'); + +$document = Document::fromErrors([$error]); ``` +## Examples + +* [Flarum](https://github.com/flarum/core/tree/master/src/Api) is forum software that uses tobscure/json-api to power its API. + ## Contributing Feel free to send pull requests or create issues if you come across problems or have great ideas. Any input is appreciated! @@ -241,7 +291,7 @@ Feel free to send pull requests or create issues if you come across problems or ### Running Tests ```bash -$ phpunit +$ vendor/bin/phpunit ``` ## License diff --git a/composer.json b/composer.json index 3061728..69aa0bb 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ "php": "^5.5.9 || ^7.0" }, "require-dev": { - "phpunit/phpunit": "^4.8 || ^5.0" + "phpunit/phpunit": "^4.8" }, "autoload": { "psr-4": { diff --git a/src/AbstractResource.php b/src/AbstractResource.php new file mode 100644 index 0000000..b975161 --- /dev/null +++ b/src/AbstractResource.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +use LogicException; + +abstract class AbstractResource implements ResourceInterface +{ + use LinksTrait, SelfLinkTrait, MetaTrait; + + /** + * The resource type. + * + * @var string + */ + protected $type; + + /** + * {@inheritdoc} + */ + public function getType() + { + return $this->type; + } + + /** + * {@inheritdoc} + */ + public function getAttributes(array $fields = null) + { + return []; + } + + /** + * Get the links. + * + * @return array + */ + public function getLinks() + { + return $this->links; + } + + /** + * Get the meta data. + * + * @return array + */ + public function getMeta() + { + return $this->meta; + } + + /** + * {@inheritdoc} + * + * @throws LogicException + */ + public function getRelationship($name) + { + $method = $this->getRelationshipMethodName($name); + + $relationship = $this->$method(); + + if ($relationship !== null && ! ($relationship instanceof Relationship)) { + throw new LogicException('Relationship method must return null or an instance of Tobscure\JsonApi\Relationship'); + } + + return $relationship; + } + + /** + * Get the method name for the given relationship. + * + * snake_case and kebab-case are converted into camelCase. + * + * @param string $name + * + * @return string + */ + private function getRelationshipMethodName($name) + { + return 'get'.implode(array_map('ucfirst', preg_split('/[-_]/', $name))).'Relationship'; + } +} diff --git a/src/AbstractSerializer.php b/src/AbstractSerializer.php deleted file mode 100644 index 6e343cc..0000000 --- a/src/AbstractSerializer.php +++ /dev/null @@ -1,106 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\JsonApi; - -use LogicException; - -abstract class AbstractSerializer implements SerializerInterface -{ - /** - * The type. - * - * @var string - */ - protected $type; - - /** - * {@inheritdoc} - */ - public function getType($model) - { - return $this->type; - } - - /** - * {@inheritdoc} - */ - public function getId($model) - { - return $model->id; - } - - /** - * {@inheritdoc} - */ - public function getAttributes($model, array $fields = null) - { - return []; - } - - /** - * {@inheritdoc} - */ - public function getLinks($model) - { - return []; - } - - /** - * {@inheritdoc} - */ - public function getMeta($model) - { - return []; - } - - /** - * {@inheritdoc} - * - * @throws \LogicException - */ - public function getRelationship($model, $name) - { - $method = $this->getRelationshipMethodName($name); - - if (method_exists($this, $method)) { - $relationship = $this->$method($model); - - if ($relationship !== null && ! ($relationship instanceof Relationship)) { - throw new LogicException('Relationship method must return null or an instance of Tobscure\JsonApi\Relationship'); - } - - return $relationship; - } - } - - /** - * Get the serializer method name for the given relationship. - * - * snake_case and kebab-case are converted into camelCase. - * - * @param string $name - * - * @return string - */ - private function getRelationshipMethodName($name) - { - if (stripos($name, '-')) { - $name = lcfirst(implode('', array_map('ucfirst', explode('-', $name)))); - } - - if (stripos($name, '_')) { - $name = lcfirst(implode('', array_map('ucfirst', explode('_', $name)))); - } - - return $name; - } -} diff --git a/src/Collection.php b/src/Collection.php deleted file mode 100644 index 4d6707b..0000000 --- a/src/Collection.php +++ /dev/null @@ -1,126 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\JsonApi; - -class Collection implements ElementInterface -{ - /** - * @var array - */ - protected $resources = []; - - /** - * Create a new collection instance. - * - * @param mixed $data - * @param \Tobscure\JsonApi\SerializerInterface $serializer - */ - public function __construct($data, SerializerInterface $serializer) - { - $this->resources = $this->buildResources($data, $serializer); - } - - /** - * Convert an array of raw data to Resource objects. - * - * @param mixed $data - * @param SerializerInterface $serializer - * - * @return \Tobscure\JsonApi\Resource[] - */ - protected function buildResources($data, SerializerInterface $serializer) - { - $resources = []; - - foreach ($data as $resource) { - if (! ($resource instanceof Resource)) { - $resource = new Resource($resource, $serializer); - } - - $resources[] = $resource; - } - - return $resources; - } - - /** - * {@inheritdoc} - */ - public function getResources() - { - return $this->resources; - } - - /** - * Set the resources array. - * - * @param array $resources - * - * @return void - */ - public function setResources($resources) - { - $this->resources = $resources; - } - - /** - * Request a relationship to be included for all resources. - * - * @param string|array $relationships - * - * @return $this - */ - public function with($relationships) - { - foreach ($this->resources as $resource) { - $resource->with($relationships); - } - - return $this; - } - - /** - * Request a restricted set of fields. - * - * @param array|null $fields - * - * @return $this - */ - public function fields($fields) - { - foreach ($this->resources as $resource) { - $resource->fields($fields); - } - - return $this; - } - - /** - * {@inheritdoc} - */ - public function toArray() - { - return array_map(function (Resource $resource) { - return $resource->toArray(); - }, $this->resources); - } - - /** - * {@inheritdoc} - */ - public function toIdentifier() - { - return array_map(function (Resource $resource) { - return $resource->toIdentifier(); - }, $this->resources); - } -} diff --git a/src/Document.php b/src/Document.php index aa350ea..a5c509a 100644 --- a/src/Document.php +++ b/src/Document.php @@ -15,219 +15,248 @@ class Document implements JsonSerializable { - use LinksTrait; - use MetaTrait; + use LinksTrait, SelfLinkTrait, PaginationLinksTrait, MetaTrait; const MEDIA_TYPE = 'application/vnd.api+json'; - /** - * The included array. - * - * @var array - */ - protected $included = []; + private $data; + private $errors; + private $jsonapi; + + private $include = []; + private $fields = []; + + private function __construct() + { + } /** - * The errors array. + * @param ResourceInterface|ResourceInterface[] $data * - * @var array + * @return self */ - protected $errors; + public static function fromData($data) + { + $document = new self; + $document->setData($data); + + return $document; + } /** - * The jsonapi array. + * @param array $meta * - * @var array + * @return self */ - protected $jsonapi; + public static function fromMeta(array $meta) + { + $document = new self; + $document->setMeta($meta); + + return $document; + } /** - * The data object. + * @param Error[] $errors * - * @var ElementInterface + * @return self */ - protected $data; + public static function fromErrors(array $errors) + { + $document = new self; + $document->setErrors($errors); + + return $document; + } /** - * @param ElementInterface $data + * Set the primary data. + * + * @param ResourceInterface|ResourceInterface[]|null $data */ - public function __construct(ElementInterface $data = null) + public function setData($data) { $this->data = $data; } /** - * Get included resources. - * - * @param \Tobscure\JsonApi\ElementInterface $element - * @param bool $includeParent + * Set the errors array. * - * @return \Tobscure\JsonApi\Resource[] + * @param Error[]|null $errors */ - protected function getIncluded(ElementInterface $element, $includeParent = false) + public function setErrors(array $errors = null) { - $included = []; - - foreach ($element->getResources() as $resource) { - if ($resource->isIdentifier()) { - continue; - } - - if ($includeParent) { - $included = $this->mergeResource($included, $resource); - } else { - $type = $resource->getType(); - $id = $resource->getId(); - } - - foreach ($resource->getUnfilteredRelationships() as $relationship) { - $includedElement = $relationship->getData(); - - if (! $includedElement instanceof ElementInterface) { - continue; - } - - foreach ($this->getIncluded($includedElement, true) as $child) { - // If this resource is the same as the top-level "data" - // resource, then we don't want it to show up again in the - // "included" array. - if (! $includeParent && $child->getType() === $type && $child->getId() === $id) { - continue; - } - - $included = $this->mergeResource($included, $child); - } - } - } - - $flattened = []; - - array_walk_recursive($included, function ($a) use (&$flattened) { - $flattened[] = $a; - }); - - return $flattened; + $this->errors = $errors; } /** - * @param \Tobscure\JsonApi\Resource[] $resources - * @param \Tobscure\JsonApi\Resource $newResource + * Set the jsonapi version. * - * @return \Tobscure\JsonApi\Resource[] + * @param string $version */ - protected function mergeResource(array $resources, Resource $newResource) + public function setApiVersion($version) { - $type = $newResource->getType(); - $id = $newResource->getId(); - - if (isset($resources[$type][$id])) { - $resources[$type][$id]->merge($newResource); - } else { - $resources[$type][$id] = $newResource; - } - - return $resources; + $this->jsonapi['version'] = $version; } /** - * Set the data object. - * - * @param \Tobscure\JsonApi\ElementInterface $element + * Set the jsonapi meta information. * - * @return $this + * @param array $meta */ - public function setData(ElementInterface $element) + public function setApiMeta(array $meta) { - $this->data = $element; - - return $this; + $this->jsonapi['meta'] = $meta; } /** - * Set the errors array. - * - * @param array $errors + * Set the relationship paths to include. * - * @return $this + * @param string[] $include */ - public function setErrors($errors) + public function setInclude($include) { - $this->errors = $errors; - - return $this; + $this->include = $include; } /** - * Set the jsonapi array. - * - * @param array $jsonapi + * Set the sparse fieldsets. * - * @return $this + * @param array $fields */ - public function setJsonapi($jsonapi) + public function setFields($fields) { - $this->jsonapi = $jsonapi; - - return $this; + $this->fields = $fields; } /** - * Map everything to arrays. + * Serialize for JSON usage. * - * @return array + * @return object */ - public function toArray() + public function jsonSerialize() { - $document = []; + $document = [ + 'links' => $this->links, + 'meta' => $this->meta, + 'errors' => $this->errors, + 'jsonapi' => $this->jsonapi + ]; - if (! empty($this->links)) { - $document['links'] = $this->links; - } + if ($this->data) { + $isCollection = is_array($this->data); + $resources = $isCollection ? $this->data : [$this->data]; - if (! empty($this->data)) { - $document['data'] = $this->data->toArray(); + $map = $this->buildResourceMap($resources); - $resources = $this->getIncluded($this->data); + $primary = $this->extractResourcesFromMap($map, $resources); - if (count($resources)) { - $document['included'] = array_map(function (Resource $resource) { - return $resource->toArray(); - }, $resources); + $document['data'] = $isCollection ? $primary : $primary[0]; + + if ($map) { + $document['included'] = call_user_func_array('array_merge', $map); } } - if (! empty($this->meta)) { - $document['meta'] = $this->meta; + return (object) array_filter($document); + } + + private function buildResourceMap(array $resources) + { + $map = []; + + $include = $this->buildRelationshipTree($this->include); + + $this->mergeResources($map, $resources, $include); + + return $map; + } + + private function mergeResources(array &$map, array $resources, array $include) + { + foreach ($resources as $resource) { + $relationships = []; + + foreach ($include as $name => $nested) { + if (! ($relationship = $resource->getRelationship($name))) { + continue; + } + + $relationships[$name] = $relationship; + + if ($data = $relationship->getData()) { + $children = is_array($data) ? $data : [$data]; + + $this->mergeResources($map, $children, $nested); + } + } + + $this->mergeResource($map, $resource, $relationships); } + } + + private function mergeResource(array &$map, ResourceInterface $resource, array $relationships) + { + $type = $resource->getType(); + $id = $resource->getId(); + $links = $resource->getLinks(); + $meta = $resource->getMeta(); + + $fields = isset($this->fields[$type]) ? $this->fields[$type] : null; + + $attributes = $resource->getAttributes($fields); + + if ($fields) { + $keys = array_flip($fields); - if (! empty($this->errors)) { - $document['errors'] = $this->errors; + $attributes = array_intersect_key($attributes, $keys); + $relationships = array_intersect_key($relationships, $keys); } - if (! empty($this->jsonapi)) { - $document['jsonapi'] = $this->jsonapi; + if (empty($map[$type][$id])) { + $map[$type][$id] = new ResourceObject($type, $id); } - return $document; + array_map([$map[$type][$id], 'setAttribute'], array_keys($attributes), $attributes); + array_map([$map[$type][$id], 'setRelationship'], array_keys($relationships), $relationships); + array_map([$map[$type][$id], 'setLink'], array_keys($links), $links); + array_map([$map[$type][$id], 'setMetaItem'], array_keys($meta), $meta); } - /** - * Map to string. - * - * @return string - */ - public function __toString() + private function extractResourcesFromMap(array &$map, array $resources) { - return json_encode($this->toArray()); + return array_filter( + array_map(function ($resource) use (&$map) { + $type = $resource->getType(); + $id = $resource->getId(); + + if (isset($map[$type][$id])) { + $resource = $map[$type][$id]; + unset($map[$type][$id]); + + return $resource; + } + }, $resources) + ); } - /** - * Serialize for JSON usage. - * - * @return array - */ - public function jsonSerialize() + private function buildRelationshipTree(array $paths) { - return $this->toArray(); + $tree = []; + + foreach ($paths as $path) { + $keys = explode('.', $path); + $array = &$tree; + + foreach ($keys as $key) { + if (! isset($array[$key])) { + $array[$key] = []; + } + + $array = &$array[$key]; + } + } + + return $tree; } } diff --git a/src/ElementInterface.php b/src/ElementInterface.php deleted file mode 100644 index 81b74ba..0000000 --- a/src/ElementInterface.php +++ /dev/null @@ -1,54 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\JsonApi; - -interface ElementInterface -{ - /** - * Get the resources array. - * - * @return array - */ - public function getResources(); - - /** - * Map to a "resource object" array. - * - * @return array - */ - public function toArray(); - - /** - * Map to a "resource object identifier" array. - * - * @return array - */ - public function toIdentifier(); - - /** - * Request a relationship to be included. - * - * @param string|array $relationships - * - * @return $this - */ - public function with($relationships); - - /** - * Request a restricted set of fields. - * - * @param array|null $fields - * - * @return $this - */ - public function fields($fields); -} diff --git a/src/Error.php b/src/Error.php new file mode 100644 index 0000000..061c7fd --- /dev/null +++ b/src/Error.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +use JsonSerializable; + +class Error implements JsonSerializable +{ + use LinksTrait, MetaTrait; + + private $id; + private $status; + private $code; + private $title; + private $detail; + private $source; + + public function setId($id) + { + $this->id = $id; + } + + public function setAboutLink($link) + { + $this->links['about'] = $link; + } + + public function setStatus($status) + { + $this->status = $status; + } + + public function setCode($code) + { + $this->code = $code; + } + + public function setTitle($title) + { + $this->title = $title; + } + + public function setDetail($detail) + { + $this->detail = $detail; + } + + public function setSourcePointer($pointer) + { + $this->source['pointer'] = $pointer; + } + + public function setSourceParameter($parameter) + { + $this->source['parameter'] = $parameter; + } + + public function jsonSerialize() + { + return array_filter( + [ + 'id' => $this->id, + 'links' => $this->links, + 'status' => $this->status, + 'code' => $this->code, + 'title' => $this->title, + 'detail' => $this->detail, + 'source' => $this->source, + 'meta' => $this->meta + ] + ); + } +} diff --git a/src/ErrorHandler.php b/src/ErrorHandler.php deleted file mode 100644 index 1e3492d..0000000 --- a/src/ErrorHandler.php +++ /dev/null @@ -1,58 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\JsonApi; - -use Exception; -use RuntimeException; -use Tobscure\JsonApi\Exception\Handler\ExceptionHandlerInterface; - -class ErrorHandler -{ - /** - * Stores the valid handlers. - * - * @var \Tobscure\JsonApi\Exception\Handler\ExceptionHandlerInterface[] - */ - private $handlers = []; - - /** - * Handle the exception provided. - * - * @param Exception $e - * - * @throws RuntimeException - * - * @return \Tobscure\JsonApi\Exception\Handler\ResponseBag - */ - public function handle(Exception $e) - { - foreach ($this->handlers as $handler) { - if ($handler->manages($e)) { - return $handler->handle($e); - } - } - - throw new RuntimeException('Exception handler for '.get_class($e).' not found.'); - } - - /** - * Register a new exception handler. - * - * @param \Tobscure\JsonApi\Exception\Handler\ExceptionHandlerInterface $handler - * - * @return void - */ - public function registerHandler(ExceptionHandlerInterface $handler) - { - $this->handlers[] = $handler; - } -} diff --git a/src/Exception/Handler/ExceptionHandlerInterface.php b/src/Exception/Handler/ExceptionHandlerInterface.php deleted file mode 100644 index 4108fc0..0000000 --- a/src/Exception/Handler/ExceptionHandlerInterface.php +++ /dev/null @@ -1,36 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\JsonApi\Exception\Handler; - -use Exception; - -interface ExceptionHandlerInterface -{ - /** - * If the exception handler is able to format a response for the provided exception, - * then the implementation should return true. - * - * @param \Exception $e - * - * @return bool - */ - public function manages(Exception $e); - - /** - * Handle the provided exception. - * - * @param \Exception $e - * - * @return \Tobscure\JsonApi\Exception\Handler\ResponseBag - */ - public function handle(Exception $e); -} diff --git a/src/Exception/Handler/FallbackExceptionHandler.php b/src/Exception/Handler/FallbackExceptionHandler.php deleted file mode 100644 index 2799d55..0000000 --- a/src/Exception/Handler/FallbackExceptionHandler.php +++ /dev/null @@ -1,66 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\JsonApi\Exception\Handler; - -use Exception; - -class FallbackExceptionHandler implements ExceptionHandlerInterface -{ - /** - * @var bool - */ - private $debug; - - /** - * @param bool $debug - */ - public function __construct($debug) - { - $this->debug = $debug; - } - - /** - * {@inheritdoc} - */ - public function manages(Exception $e) - { - return true; - } - - /** - * {@inheritdoc} - */ - public function handle(Exception $e) - { - $status = 500; - $error = $this->constructError($e, $status); - - return new ResponseBag($status, [$error]); - } - - /** - * @param \Exception $e - * @param $status - * - * @return array - */ - private function constructError(Exception $e, $status) - { - $error = ['code' => $status, 'title' => 'Internal server error']; - - if ($this->debug) { - $error['detail'] = (string) $e; - } - - return $error; - } -} diff --git a/src/Exception/Handler/InvalidParameterExceptionHandler.php b/src/Exception/Handler/InvalidParameterExceptionHandler.php deleted file mode 100644 index 374020a..0000000 --- a/src/Exception/Handler/InvalidParameterExceptionHandler.php +++ /dev/null @@ -1,47 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\JsonApi\Exception\Handler; - -use Exception; -use Tobscure\JsonApi\Exception\InvalidParameterException; - -class InvalidParameterExceptionHandler implements ExceptionHandlerInterface -{ - /** - * {@inheritdoc} - */ - public function manages(Exception $e) - { - return $e instanceof InvalidParameterException; - } - - /** - * {@inheritdoc} - */ - public function handle(Exception $e) - { - $status = 400; - $error = []; - - $code = $e->getCode(); - if ($code) { - $error['code'] = $code; - } - - $invalidParameter = $e->getInvalidParameter(); - if ($invalidParameter) { - $error['source'] = ['parameter' => $invalidParameter]; - } - - return new ResponseBag($status, [$error]); - } -} diff --git a/src/Exception/Handler/ResponseBag.php b/src/Exception/Handler/ResponseBag.php deleted file mode 100644 index 0da7d88..0000000 --- a/src/Exception/Handler/ResponseBag.php +++ /dev/null @@ -1,47 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\JsonApi\Exception\Handler; - -/** - * DTO to manage JSON error response handling. - */ -class ResponseBag -{ - private $status; - private $errors; - - /** - * @param int $status - * @param array $errors - */ - public function __construct($status, array $errors) - { - $this->status = $status; - $this->errors = $errors; - } - - /** - * @return array - */ - public function getErrors() - { - return $this->errors; - } - - /** - * @return int - */ - public function getStatus() - { - return $this->status; - } -} diff --git a/src/Exception/InvalidParameterException.php b/src/Exception/InvalidParameterException.php index 72027c0..6f3e978 100644 --- a/src/Exception/InvalidParameterException.php +++ b/src/Exception/InvalidParameterException.php @@ -33,7 +33,9 @@ public function __construct($message = '', $code = 0, $previous = null, $invalid } /** - * @return string The parameter that caused this exception. + * Get the parameter that caused this exception. + * + * @return string */ public function getInvalidParameter() { diff --git a/src/Link.php b/src/Link.php new file mode 100644 index 0000000..d3a2c31 --- /dev/null +++ b/src/Link.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +use JsonSerializable; + +class Link implements JsonSerializable +{ + use MetaTrait; + + protected $href; + + public function __construct($href, $meta = null) + { + $this->href = $href; + $this->meta = $meta; + } + + public function setHref($href) + { + $this->href = $href; + } + + public function jsonSerialize() + { + return $this->meta ? ['href' => $this->href, 'meta' => $this->meta] : $this->href; + } +} diff --git a/src/LinksTrait.php b/src/LinksTrait.php index 1f22171..2eac1d4 100644 --- a/src/LinksTrait.php +++ b/src/LinksTrait.php @@ -13,123 +13,36 @@ trait LinksTrait { - /** - * The links array. - * - * @var array - */ - protected $links; - - /** - * Get the links. - * - * @return array - */ - public function getLinks() - { - return $this->links; - } + private $links = []; /** * Set the links. * * @param array $links - * - * @return $this */ public function setLinks(array $links) { $this->links = $links; - - return $this; } /** - * Add a link. + * Set a link. * * @param string $key - * @param string $value - * - * @return $this + * @param string|Link $value */ - public function addLink($key, $value) + public function setLink($key, $value) { $this->links[$key] = $value; - - return $this; } /** - * Add pagination links (first, prev, next, and last). + * Remove a link. * - * @param string $url The base URL for pagination links. - * @param array $queryParams The query params provided in the request. - * @param int $offset The current offset. - * @param int $limit The current limit. - * @param int|null $total The total number of results, or null if unknown. - * - * @return void - */ - public function addPaginationLinks($url, array $queryParams, $offset, $limit, $total = null) - { - if (isset($queryParams['page']['number'])) { - $offset = floor($offset / $limit) * $limit; - } - - $this->addPaginationLink('first', $url, $queryParams, 0, $limit); - - if ($offset > 0) { - $this->addPaginationLink('prev', $url, $queryParams, max(0, $offset - $limit), $limit); - } - - if ($total === null || $offset + $limit < $total) { - $this->addPaginationLink('next', $url, $queryParams, $offset + $limit, $limit); - } - - if ($total) { - $this->addPaginationLink('last', $url, $queryParams, floor(($total - 1) / $limit) * $limit, $limit); - } - } - - /** - * Add a pagination link. - * - * @param string $name The name of the link. - * @param string $url The base URL for pagination links. - * @param array $queryParams The query params provided in the request. - * @param int $offset The offset to link to. - * @param int $limit The current limit. - * - * @return void + * @param string $key */ - protected function addPaginationLink($name, $url, array $queryParams, $offset, $limit) + public function removeLink($key) { - if (! isset($queryParams['page']) || ! is_array($queryParams['page'])) { - $queryParams['page'] = []; - } - - $page = &$queryParams['page']; - - if (isset($page['number'])) { - $page['number'] = floor($offset / $limit) + 1; - - if ($page['number'] <= 1) { - unset($page['number']); - } - } else { - $page['offset'] = $offset; - - if ($page['offset'] <= 0) { - unset($page['offset']); - } - } - - if (isset($page['limit'])) { - $page['limit'] = $limit; - } - - $queryString = http_build_query($queryParams); - - $this->addLink($name, $url.($queryString ? '?'.$queryString : '')); + unset($this->links[$key]); } } diff --git a/src/MetaTrait.php b/src/MetaTrait.php index 5572446..e7d3816 100644 --- a/src/MetaTrait.php +++ b/src/MetaTrait.php @@ -13,49 +13,37 @@ trait MetaTrait { - /** - * The meta data array. - * - * @var array - */ - protected $meta; + private $meta = []; /** - * Get the meta. + * Set the meta data. * - * @return array + * @param array $meta */ - public function getMeta() + public function setMeta(array $meta) { - return $this->meta; + $this->meta = $meta; } /** - * Set the meta data array. - * - * @param array $meta + * Set a piece of meta data. * - * @return $this + * @param string $key + * @param mixed $value */ - public function setMeta(array $meta) + public function setMetaItem($key, $value) { - $this->meta = $meta; - - return $this; + $this->meta[$key] = $value; } /** - * Add meta data. + * Remove a piece of meta data. * * @param string $key - * @param string $value - * - * @return $this + * @param mixed $value */ - public function addMeta($key, $value) + public function removeMetaItem($key) { - $this->meta[$key] = $value; - - return $this; + unset($this->meta[$key]); } } diff --git a/src/PaginationLinksTrait.php b/src/PaginationLinksTrait.php new file mode 100644 index 0000000..cc32169 --- /dev/null +++ b/src/PaginationLinksTrait.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +trait PaginationLinksTrait +{ + abstract public function setLink($key, $value); + + abstract public function removeLink($key); + + /** + * Set pagination links (first, prev, next, and last). + * + * @param string $url The base URL for pagination links. + * @param array $queryParams The query params provided in the request. + * @param int $offset The current offset. + * @param int $limit The current limit. + * @param int|null $total The total number of results, or null if unknown. + */ + public function setPaginationLinks($url, array $queryParams, $offset, $limit, $total = null) + { + if (isset($queryParams['page']['number'])) { + $offset = floor($offset / $limit) * $limit; + } + + $this->setPaginationLink('first', $url, $queryParams, 0, $limit); + + $this->removeLink('prev'); + $this->removeLink('next'); + $this->removeLink('last'); + + if ($offset > 0) { + $this->setPaginationLink('prev', $url, $queryParams, max(0, $offset - $limit), $limit); + } + + if ($total === null || $offset + $limit < $total) { + $this->setPaginationLink('next', $url, $queryParams, $offset + $limit, $limit); + } + + if ($total) { + $this->setPaginationLink('last', $url, $queryParams, floor(($total - 1) / $limit) * $limit, $limit); + } + } + + /** + * Set a pagination link. + * + * @param string $name The name of the link. + * @param string $url The base URL for pagination links. + * @param array $queryParams The query params provided in the request. + * @param int $offset The offset to link to. + * @param int $limit The current limit. + */ + private function setPaginationLink($name, $url, array $queryParams, $offset, $limit) + { + if (! isset($queryParams['page']) || ! is_array($queryParams['page'])) { + $queryParams['page'] = []; + } + + $page = &$queryParams['page']; + + if (isset($page['number'])) { + $page['number'] = floor($offset / $limit) + 1; + + if ($page['number'] <= 1) { + unset($page['number']); + } + } else { + $page['offset'] = $offset; + + if ($page['offset'] <= 0) { + unset($page['offset']); + } + } + + if (isset($page['limit'])) { + $page['limit'] = $limit; + } elseif (isset($page['size'])) { + $page['size'] = $limit; + } + + $queryString = http_build_query($queryParams); + + $this->setLink($name, $url.($queryString ? '?'.$queryString : '')); + } +} diff --git a/src/Parameters.php b/src/Parameters.php index 0280e47..09212d0 100644 --- a/src/Parameters.php +++ b/src/Parameters.php @@ -77,7 +77,12 @@ public function getOffset($perPage = null) $offset = (int) $this->getPage('offset'); if ($offset < 0) { - throw new InvalidParameterException('page[offset] must be >=0', 2, null, 'page[offset]'); + throw new InvalidParameterException( + 'page[offset] must be >=0', + 2, + null, + 'page[offset]' + ); } return $offset; @@ -92,7 +97,7 @@ public function getOffset($perPage = null) * * @return int */ - protected function getOffsetFromNumber($perPage) + private function getOffsetFromNumber($perPage) { $page = (int) $this->getPage('number'); @@ -199,7 +204,7 @@ public function getFilter() * * @return mixed */ - protected function getInput($key, $default = null) + private function getInput($key, $default = null) { return isset($this->input[$key]) ? $this->input[$key] : $default; } @@ -211,7 +216,7 @@ protected function getInput($key, $default = null) * * @return string */ - protected function getPage($key) + private function getPage($key) { $page = $this->getInput('page'); diff --git a/src/RelatedLinkTrait.php b/src/RelatedLinkTrait.php new file mode 100644 index 0000000..bd76324 --- /dev/null +++ b/src/RelatedLinkTrait.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +trait RelatedLinkTrait +{ + abstract public function setLink($key, $value); + + abstract public function removeLink($key); + + /** + * Set the related link. + * + * @param string|Link $value + */ + public function setRelatedLink($value) + { + return $this->setLink('related', $value); + } + + /** + * Remove the related link. + */ + public function removeRelatedLink() + { + return $this->removeLink('related'); + } +} diff --git a/src/Relationship.php b/src/Relationship.php index 29f7fc6..d28843d 100644 --- a/src/Relationship.php +++ b/src/Relationship.php @@ -11,73 +11,81 @@ namespace Tobscure\JsonApi; -class Relationship +use JsonSerializable; + +class Relationship implements JsonSerializable { - use LinksTrait; - use MetaTrait; - - /** - * The data object. - * - * @var \Tobscure\JsonApi\ElementInterface|null - */ - protected $data; - - /** - * Create a new relationship. - * - * @param \Tobscure\JsonApi\ElementInterface|null $data - */ - public function __construct(ElementInterface $data = null) + use LinksTrait, SelfLinkTrait, RelatedLinkTrait, PaginationLinksTrait, MetaTrait; + + private $data; + + private function __construct() { - $this->data = $data; } - /** - * Get the data object. - * - * @return \Tobscure\JsonApi\ElementInterface|null - */ + public static function fromMeta($meta) + { + $r = new self; + $r->setMeta($meta); + + return $r; + } + + public static function fromSelfLink($link) + { + $r = new self; + $r->setSelfLink($link); + + return $r; + } + + public static function fromRelatedLink($link) + { + $r = new self; + $r->setRelatedLink($link); + + return $r; + } + + public static function fromData($data) + { + $r = new self; + $r->setData($data); + + return $r; + } + public function getData() { return $this->data; } - /** - * Set the data object. - * - * @param \Tobscure\JsonApi\ElementInterface|null $data - * - * @return $this - */ public function setData($data) { $this->data = $data; - - return $this; } - /** - * Map everything to an array. - * - * @return array - */ - public function toArray() + public function jsonSerialize() { - $array = []; + $relationship = []; - if (! empty($this->data)) { - $array['data'] = $this->data->toIdentifier(); + if ($this->data) { + $relationship['data'] = is_array($this->data) + ? array_map([$this, 'buildIdentifier'], $this->data) + : $this->buildIdentifier($this->data); } - if (! empty($this->meta)) { - $array['meta'] = $this->meta; - } + return array_filter($relationship + [ + 'meta' => $this->meta, + 'links' => $this->links + ]); + } - if (! empty($this->links)) { - $array['links'] = $this->links; - } + private function buildIdentifier(ResourceInterface $resource) + { + $id = new ResourceIdentifier($resource->getType(), $resource->getId()); + $id->setMeta($resource->getMeta()); - return $array; + return $id; } } diff --git a/src/Resource.php b/src/Resource.php deleted file mode 100644 index b52230b..0000000 --- a/src/Resource.php +++ /dev/null @@ -1,406 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\JsonApi; - -class Resource implements ElementInterface -{ - use LinksTrait; - use MetaTrait; - - /** - * @var mixed - */ - protected $data; - - /** - * @var \Tobscure\JsonApi\SerializerInterface - */ - protected $serializer; - - /** - * A list of relationships to include. - * - * @var array - */ - protected $includes = []; - - /** - * A list of fields to restrict to. - * - * @var array|null - */ - protected $fields; - - /** - * An array of Resources that should be merged into this one. - * - * @var \Tobscure\JsonApi\Resource[] - */ - protected $merged = []; - - /** - * @var \Tobscure\JsonApi\Relationship[] - */ - private $relationships; - - /** - * @param mixed $data - * @param \Tobscure\JsonApi\SerializerInterface $serializer - */ - public function __construct($data, SerializerInterface $serializer) - { - $this->data = $data; - $this->serializer = $serializer; - } - - /** - * {@inheritdoc} - */ - public function getResources() - { - return [$this]; - } - - /** - * {@inheritdoc} - */ - public function toArray() - { - $array = $this->toIdentifier(); - - if (! $this->isIdentifier()) { - $attributes = $this->getAttributes(); - if ($attributes) { - $array['attributes'] = $attributes; - } - } - - $relationships = $this->getRelationshipsAsArray(); - - if (count($relationships)) { - $array['relationships'] = $relationships; - } - - $links = []; - if (! empty($this->links)) { - $links = $this->links; - } - $serializerLinks = $this->serializer->getLinks($this->data); - if (! empty($serializerLinks)) { - $links = array_merge($serializerLinks, $links); - } - if (! empty($links)) { - $array['links'] = $links; - } - - $meta = []; - if (! empty($this->meta)) { - $meta = $this->meta; - } - $serializerMeta = $this->serializer->getMeta($this->data); - if (! empty($serializerMeta)) { - $meta = array_merge($serializerMeta, $meta); - } - if (! empty($meta)) { - $array['meta'] = $meta; - } - - return $array; - } - - /** - * Check whether or not this resource is an identifier (i.e. does it have - * any data attached?). - * - * @return bool - */ - public function isIdentifier() - { - return ! is_object($this->data) && ! is_array($this->data); - } - - /** - * {@inheritdoc} - */ - public function toIdentifier() - { - if (! $this->data) { - return; - } - - $array = [ - 'type' => $this->getType(), - 'id' => $this->getId() - ]; - - if (! empty($this->meta)) { - $array['meta'] = $this->meta; - } - - return $array; - } - - /** - * Get the resource type. - * - * @return string - */ - public function getType() - { - return $this->serializer->getType($this->data); - } - - /** - * Get the resource ID. - * - * @return string - */ - public function getId() - { - if (! is_object($this->data) && ! is_array($this->data)) { - return (string) $this->data; - } - - return (string) $this->serializer->getId($this->data); - } - - /** - * Get the resource attributes. - * - * @return array - */ - public function getAttributes() - { - $attributes = (array) $this->serializer->getAttributes($this->data, $this->getOwnFields()); - - $attributes = $this->filterFields($attributes); - - $attributes = $this->mergeAttributes($attributes); - - return $attributes; - } - - /** - * Get the requested fields for this resource type. - * - * @return array|null - */ - protected function getOwnFields() - { - $type = $this->getType(); - - if (isset($this->fields[$type])) { - return $this->fields[$type]; - } - } - - /** - * Filter the given fields array (attributes or relationships) according - * to the requested fieldset. - * - * @param array $fields - * - * @return array - */ - protected function filterFields(array $fields) - { - if ($requested = $this->getOwnFields()) { - $fields = array_intersect_key($fields, array_flip($requested)); - } - - return $fields; - } - - /** - * Merge the attributes of merged resources into an array of attributes. - * - * @param array $attributes - * - * @return array - */ - protected function mergeAttributes(array $attributes) - { - foreach ($this->merged as $resource) { - $attributes = array_replace_recursive($attributes, $resource->getAttributes()); - } - - return $attributes; - } - - /** - * Get the resource relationships. - * - * @return \Tobscure\JsonApi\Relationship[] - */ - public function getRelationships() - { - $relationships = $this->buildRelationships(); - - return $this->filterFields($relationships); - } - - /** - * Get the resource relationships without considering requested ones. - * - * @return \Tobscure\JsonApi\Relationship[] - */ - public function getUnfilteredRelationships() - { - return $this->buildRelationships(); - } - - /** - * Get the resource relationships as an array. - * - * @return array - */ - public function getRelationshipsAsArray() - { - $relationships = $this->getRelationships(); - - $relationships = $this->convertRelationshipsToArray($relationships); - - return $this->mergeRelationships($relationships); - } - - /** - * Get an array of built relationships. - * - * @return \Tobscure\JsonApi\Relationship[] - */ - protected function buildRelationships() - { - if (isset($this->relationships)) { - return $this->relationships; - } - - $paths = Util::parseRelationshipPaths($this->includes); - - $relationships = []; - - foreach ($paths as $name => $nested) { - $relationship = $this->serializer->getRelationship($this->data, $name); - - if ($relationship) { - $relationshipData = $relationship->getData(); - if ($relationshipData instanceof ElementInterface) { - $relationshipData->with($nested)->fields($this->fields); - } - - $relationships[$name] = $relationship; - } - } - - return $this->relationships = $relationships; - } - - /** - * Merge the relationships of merged resources into an array of - * relationships. - * - * @param array $relationships - * - * @return array - */ - protected function mergeRelationships(array $relationships) - { - foreach ($this->merged as $resource) { - $relationships = array_replace_recursive($relationships, $resource->getRelationshipsAsArray()); - } - - return $relationships; - } - - /** - * Convert the given array of Relationship objects into an array. - * - * @param \Tobscure\JsonApi\Relationship[] $relationships - * - * @return array - */ - protected function convertRelationshipsToArray(array $relationships) - { - return array_map(function (Relationship $relationship) { - return $relationship->toArray(); - }, $relationships); - } - - /** - * Merge a resource into this one. - * - * @param \Tobscure\JsonApi\Resource $resource - * - * @return void - */ - public function merge(Resource $resource) - { - $this->merged[] = $resource; - } - - /** - * {@inheritdoc} - */ - public function with($relationships) - { - $this->includes = array_unique(array_merge($this->includes, (array) $relationships)); - - $this->relationships = null; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function fields($fields) - { - $this->fields = $fields; - - return $this; - } - - /** - * @return mixed - */ - public function getData() - { - return $this->data; - } - - /** - * @param mixed $data - * - * @return void - */ - public function setData($data) - { - $this->data = $data; - } - - /** - * @return \Tobscure\JsonApi\SerializerInterface - */ - public function getSerializer() - { - return $this->serializer; - } - - /** - * @param \Tobscure\JsonApi\SerializerInterface $serializer - * - * @return void - */ - public function setSerializer(SerializerInterface $serializer) - { - $this->serializer = $serializer; - } -} diff --git a/src/ResourceIdentifier.php b/src/ResourceIdentifier.php new file mode 100644 index 0000000..d4c4915 --- /dev/null +++ b/src/ResourceIdentifier.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +use JsonSerializable; + +class ResourceIdentifier implements JsonSerializable +{ + use MetaTrait; + + private $type; + private $id; + + public function __construct($type, $id) + { + $this->type = $type; + $this->id = $id; + } + + public function jsonSerialize() + { + return array_filter( + [ + 'type' => $this->type, + 'id' => $this->id, + 'meta' => $this->meta + ] + ); + } +} diff --git a/src/ResourceInterface.php b/src/ResourceInterface.php new file mode 100644 index 0000000..5fda24a --- /dev/null +++ b/src/ResourceInterface.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +interface ResourceInterface +{ + /** + * Get the resource type. + * + * @return string + */ + public function getType(); + + /** + * Get the resource ID. + * + * @return string + */ + public function getId(); + + /** + * Get the resource attributes. + * + * @param string[]|null $fields + * + * @return array|null + */ + public function getAttributes(array $fields = null); + + /** + * Get the resource links. + * + * @return array|null + */ + public function getLinks(); + + /** + * Get the resource meta information. + * + * @return array|null + */ + public function getMeta(); + + /** + * Get a relationship. + * + * @param string $name + * + * @return \Tobscure\JsonApi\Relationship|null + */ + public function getRelationship($name); +} diff --git a/src/ResourceObject.php b/src/ResourceObject.php new file mode 100644 index 0000000..bd130a7 --- /dev/null +++ b/src/ResourceObject.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +use InvalidArgumentException; +use LogicException; + +class ResourceObject extends ResourceIdentifier +{ + use LinksTrait, SelfLinkTrait; + + private $attributes = []; + private $relationships = []; + + public function setAttribute($name, $value) + { + if (! $this->validateField($name)) { + throw new InvalidArgumentException('Invalid attribute name'); + } + + if (isset($this->relationships[$name])) { + throw new LogicException("Field $name already exists in relationships"); + } + + $this->attributes[$name] = $value; + } + + public function setRelationship($name, Relationship $value) + { + if (! $this->validateField($name)) { + throw new InvalidArgumentException('Invalid relationship name'); + } + + if (isset($this->attributes[$name])) { + throw new LogicException("Field $name already exists in attributes"); + } + + $this->relationships[$name] = $value; + } + + public function jsonSerialize() + { + return array_filter( + parent::jsonSerialize() + [ + 'attributes' => $this->attributes, + 'relationships' => $this->relationships, + 'links' => $this->links + ] + ); + } + + private function validateField($name) + { + return ! in_array($name, ['id', 'type']); + } +} diff --git a/src/SelfLinkTrait.php b/src/SelfLinkTrait.php new file mode 100644 index 0000000..42cd3bc --- /dev/null +++ b/src/SelfLinkTrait.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\JsonApi; + +trait SelfLinkTrait +{ + abstract public function setLink($key, $value); + + abstract public function removeLink($key); + + /** + * Set the self link. + * + * @param string|Link $value + */ + public function setSelfLink($value) + { + return $this->setLink('self', $value); + } + + /** + * Remove the self link. + */ + public function removeSelfLink() + { + return $this->removeLink('self'); + } +} diff --git a/src/SerializerInterface.php b/src/SerializerInterface.php deleted file mode 100644 index 96730fa..0000000 --- a/src/SerializerInterface.php +++ /dev/null @@ -1,71 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\JsonApi; - -interface SerializerInterface -{ - /** - * Get the type. - * - * @param mixed $model - * - * @return string - */ - public function getType($model); - - /** - * Get the id. - * - * @param mixed $model - * - * @return string - */ - public function getId($model); - - /** - * Get the attributes array. - * - * @param mixed $model - * @param array|null $fields - * - * @return array - */ - public function getAttributes($model, array $fields = null); - - /** - * Get the links array. - * - * @param mixed $model - * - * @return array - */ - public function getLinks($model); - - /** - * Get the meta. - * - * @param mixed $model - * - * @return array - */ - public function getMeta($model); - - /** - * Get a relationship. - * - * @param mixed $model - * @param string $name - * - * @return \Tobscure\JsonApi\Relationship|null - */ - public function getRelationship($model, $name); -} diff --git a/src/Util.php b/src/Util.php deleted file mode 100644 index cac77a3..0000000 --- a/src/Util.php +++ /dev/null @@ -1,50 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\JsonApi; - -class Util -{ - /** - * Parse relationship paths. - * - * Given a flat array of relationship paths like: - * - * ['user', 'user.employer', 'user.employer.country', 'comments'] - * - * create a nested array of relationship paths one-level deep that can - * be passed on to other serializers: - * - * ['user' => ['employer', 'employer.country'], 'comments' => []] - * - * @param array $paths - * - * @return array - */ - public static function parseRelationshipPaths(array $paths) - { - $tree = []; - - foreach ($paths as $path) { - list($primary, $nested) = array_pad(explode('.', $path, 2), 2, null); - - if (! isset($tree[$primary])) { - $tree[$primary] = []; - } - - if ($nested) { - $tree[$primary][] = $nested; - } - } - - return $tree; - } -} diff --git a/tests/AbstractResourceTest.php b/tests/AbstractResourceTest.php new file mode 100644 index 0000000..96253c3 --- /dev/null +++ b/tests/AbstractResourceTest.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\Tests\JsonApi; + +use Tobscure\JsonApi\AbstractResource; +use Tobscure\JsonApi\Relationship; + +class AbstractResourceTest extends AbstractTestCase +{ + public function testGetTypeReturnsTheType() + { + $resource = new AbstractResourceStub; + + $this->assertEquals('stub', $resource->getType()); + } + + public function testGetAttributesReturnsEmptyArray() + { + $resource = new AbstractResourceStub; + + $this->assertEquals([], $resource->getAttributes()); + } + + public function testGetRelationshipReturnsRelationshipFromMethod() + { + $resource = new AbstractResourceStub; + + $relationship = $resource->getRelationship('valid'); + $this->assertTrue($relationship instanceof Relationship); + + $relationship = $resource->getRelationship('va-lid'); + $this->assertTrue($relationship instanceof Relationship); + + $relationship = $resource->getRelationship('va_lid'); + $this->assertTrue($relationship instanceof Relationship); + } + + /** + * @expectedException \LogicException + */ + public function testGetRelationshipValidatesRelationship() + { + $resource = new AbstractResourceStub; + + $resource->getRelationship('invalid'); + } +} + +class AbstractResourceStub extends AbstractResource +{ + protected $type = 'stub'; + + public function getId() + { + } + + public function getValidRelationship() + { + return Relationship::fromData(null); + } + + public function getInvalidRelationship() + { + return 'invalid'; + } +} diff --git a/tests/AbstractSerializerTest.php b/tests/AbstractSerializerTest.php deleted file mode 100644 index 0ddc86e..0000000 --- a/tests/AbstractSerializerTest.php +++ /dev/null @@ -1,101 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\Tests\JsonApi; - -use Tobscure\JsonApi\AbstractSerializer; -use Tobscure\JsonApi\Collection; -use Tobscure\JsonApi\Relationship; -use Tobscure\JsonApi\Resource; - -class AbstractSerializerTest extends AbstractTestCase -{ - public function testGetTypeReturnsTheType() - { - $serializer = new PostSerializer1; - - $this->assertEquals('posts', $serializer->getType(null)); - } - - public function testGetAttributesReturnsTheAttributes() - { - $serializer = new PostSerializer1; - $post = (object) ['foo' => 'bar']; - - $this->assertEquals(['foo' => 'bar'], $serializer->getAttributes($post)); - } - - public function testGetRelationshipReturnsRelationshipFromMethod() - { - $serializer = new PostSerializer1; - - $relationship = $serializer->getRelationship(null, 'comments'); - - $this->assertTrue($relationship instanceof Relationship); - } - - public function testGetRelationshipReturnsRelationshipFromMethodUnderscored() - { - $serializer = new PostSerializer1; - - $relationship = $serializer->getRelationship(null, 'parent_post'); - - $this->assertTrue($relationship instanceof Relationship); - } - - public function testGetRelationshipReturnsRelationshipFromMethodKebabCase() - { - $serializer = new PostSerializer1; - - $relationship = $serializer->getRelationship(null, 'parent-post'); - - $this->assertTrue($relationship instanceof Relationship); - } - - /** - * @expectedException \LogicException - */ - public function testGetRelationshipValidatesRelationship() - { - $serializer = new PostSerializer1; - - $serializer->getRelationship(null, 'invalid'); - } -} - -class PostSerializer1 extends AbstractSerializer -{ - protected $type = 'posts'; - - public function getAttributes($post, array $fields = null) - { - return ['foo' => $post->foo]; - } - - public function comments($post) - { - $element = new Collection([], new self); - - return new Relationship($element); - } - - public function parentPost($post) - { - $element = new Resource([], new self); - - return new Relationship($element); - } - - public function invalid($post) - { - return 'invalid'; - } -} diff --git a/tests/AbstractTestCase.php b/tests/AbstractTestCase.php index 4c8ac63..050bc7d 100644 --- a/tests/AbstractTestCase.php +++ b/tests/AbstractTestCase.php @@ -13,12 +13,17 @@ use PHPUnit_Framework_TestCase; -/** - * This is the abstract test case class. - * - * @author Vincent Klaiber - */ abstract class AbstractTestCase extends PHPUnit_Framework_TestCase { - // + /** + * Asserts the two values encode to json and produce same result. + * + * @param mixed $expected + * @param mixed $actual + * @param string $message + */ + public static function assertProduceSameJson($expected, $actual, $message = '') + { + self::assertJsonStringEqualsJsonString(json_encode($expected), json_encode($actual), $message); + } } diff --git a/tests/CollectionTest.php b/tests/CollectionTest.php deleted file mode 100644 index ab4a866..0000000 --- a/tests/CollectionTest.php +++ /dev/null @@ -1,65 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\Tests\JsonApi\Element; - -use Tobscure\JsonApi\AbstractSerializer; -use Tobscure\JsonApi\Collection; -use Tobscure\JsonApi\Resource; -use Tobscure\Tests\JsonApi\AbstractTestCase; - -/** - * This is the collection test class. - * - * @author Toby Zerner - */ -class CollectionTest extends AbstractTestCase -{ - public function testToArrayReturnsArrayOfResources() - { - $serializer = new PostSerializer3; - - $post1 = (object) ['id' => 1, 'foo' => 'bar']; - $post2 = new Resource((object) ['id' => 2, 'foo' => 'baz'], $serializer); - - $collection = new Collection([$post1, $post2], $serializer); - - $resource1 = new Resource($post1, $serializer); - $resource2 = $post2; - - $this->assertEquals([$resource1->toArray(), $resource2->toArray()], $collection->toArray()); - } - - public function testToIdentifierReturnsArrayOfResourceIdentifiers() - { - $serializer = new PostSerializer3; - - $post1 = (object) ['id' => 1]; - $post2 = (object) ['id' => 2]; - - $collection = new Collection([$post1, $post2], $serializer); - - $resource1 = new Resource($post1, $serializer); - $resource2 = new Resource($post2, $serializer); - - $this->assertEquals([$resource1->toIdentifier(), $resource2->toIdentifier()], $collection->toIdentifier()); - } -} - -class PostSerializer3 extends AbstractSerializer -{ - protected $type = 'posts'; - - public function getAttributes($post, array $fields = null) - { - return ['foo' => $post->foo]; - } -} diff --git a/tests/DocumentTest.php b/tests/DocumentTest.php index e1c6f51..ecfd37e 100644 --- a/tests/DocumentTest.php +++ b/tests/DocumentTest.php @@ -11,99 +11,177 @@ namespace Tobscure\Tests\JsonApi; -use Tobscure\JsonApi\AbstractSerializer; -use Tobscure\JsonApi\Collection; use Tobscure\JsonApi\Document; use Tobscure\JsonApi\Relationship; -use Tobscure\JsonApi\Resource; +use Tobscure\JsonApi\ResourceInterface; -/** - * This is the document test class. - * - * @author Toby Zerner - */ class DocumentTest extends AbstractTestCase { - public function testToArrayIncludesTheResourcesRepresentation() + public function testResource() { - $post = (object) [ - 'id' => 1, - 'foo' => 'bar' - ]; - - $resource = new Resource($post, new PostSerializer2); + $resource = $this->mockResource('a', '1'); - $document = new Document($resource); + $document = Document::fromData($resource); - $this->assertEquals(['data' => $resource->toArray()], $document->toArray()); + $this->assertProduceSameJson( + [ + 'data' => ['type' => 'a', 'id' => '1'], + ], + $document + ); } - public function testItCanBeSerializedToJson() + public function testCollection() { - $this->assertEquals('[]', (string) new Document()); + $resource1 = $this->mockResource('a', '1'); + $resource2 = $this->mockResource('a', '2'); + + $document = Document::fromData([$resource1, $resource2]); + + $this->assertProduceSameJson( + [ + 'data' => [ + ['type' => 'a', 'id' => '1'], + ['type' => 'a', 'id' => '2'], + ], + ], + $document + ); } - public function testToArrayIncludesIncludedResources() + public function testMergeResource() { - $comment = (object) ['id' => 1, 'foo' => 'bar']; - $post = (object) ['id' => 1, 'foo' => 'bar', 'comments' => [$comment]]; - - $resource = new Resource($post, new PostSerializer2); - $includedResource = new Resource($comment, new CommentSerializer2); - - $document = new Document($resource->with('comments')); - - $this->assertEquals([ - 'data' => $resource->toArray(), - 'included' => [ - $includedResource->toArray() - ] - ], $document->toArray()); + $array1 = ['a' => 1, 'b' => 1]; + $array2 = ['a' => 2, 'c' => 2]; + + $resource1 = $this->mockResource('a', '1', $array1, $array1, $array1); + $resource2 = $this->mockResource('a', '1', $array2, $array2, $array2); + + $document = Document::fromData([$resource1, $resource2]); + + $this->assertProduceSameJson( + [ + 'data' => [ + [ + 'type' => 'a', + 'id' => '1', + 'attributes' => $merged = array_merge($array1, $array2), + 'meta' => $merged, + 'links' => $merged, + ], + ], + ], + $document + ); } - public function testNoEmptyAttributes() + public function testSparseFieldsets() { - $post = (object) [ - 'id' => 1, - ]; + $resource = $this->mockResource('a', '1', ['present' => 1, 'absent' => 1]); + + $resource->expects($this->once())->method('getAttributes')->with($this->equalTo(['present'])); + + $document = Document::fromData($resource); + $document->setFields(['a' => ['present']]); + + $this->assertProduceSameJson( + [ + 'data' => [ + 'type' => 'a', + 'id' => '1', + 'attributes' => ['present' => 1], + ], + ], + $document + ); + } - $resource = new Resource($post, new PostSerializerEmptyAttributes2); + public function testIncludeRelationships() + { + $resource1 = $this->mockResource('a', '1'); + $resource2 = $this->mockResource('a', '2'); + $resource3 = $this->mockResource('b', '1'); + + $relationshipJson = ['data' => 'stub']; + + $relationshipA = $this->getMockBuilder(Relationship::class)->disableOriginalConstructor()->getMock(); + $relationshipA->method('getData')->willReturn($resource2); + $relationshipA->method('jsonSerialize')->willReturn($relationshipJson); + + $relationshipB = $this->getMockBuilder(Relationship::class)->disableOriginalConstructor()->getMock(); + $relationshipB->method('getData')->willReturn($resource3); + $relationshipB->method('jsonSerialize')->willReturn($relationshipJson); + + $resource1 + ->expects($this->once()) + ->method('getRelationship') + ->with($this->equalTo('a')) + ->willReturn($relationshipA); + + $resource2 + ->expects($this->once()) + ->method('getRelationship') + ->with($this->equalTo('b')) + ->willReturn($relationshipB); + + $document = Document::fromData($resource1); + $document->setInclude(['a', 'a.b']); + + $this->assertProduceSameJson( + [ + 'data' => [ + 'type' => 'a', + 'id' => '1', + 'relationships' => ['a' => $relationshipJson], + ], + 'included' => [ + [ + 'type' => 'b', + 'id' => '1', + ], + [ + 'type' => 'a', + 'id' => '2', + 'relationships' => ['b' => $relationshipJson], + ], + ], + ], + $document + ); + } - $document = new Document($resource); + public function testErrors() + { + $document = Document::fromErrors(['a']); - $this->assertEquals('{"data":{"type":"posts","id":"1"}}', (string) $document, 'Attributes should be omitted'); + $this->assertProduceSameJson(['errors' => ['a']], $document); } -} -class PostSerializer2 extends AbstractSerializer -{ - protected $type = 'posts'; - - public function getAttributes($post, array $fields = null) + public function testLinks() { - return ['foo' => $post->foo]; + $document = Document::fromMeta([]); + $document->setLink('a', 'b'); + + $this->assertProduceSameJson(['links' => ['a' => 'b']], $document); } - public function comments($post) + public function testMeta() { - return new Relationship(new Collection($post->comments, new CommentSerializer2)); + $document = Document::fromMeta(['a' => 'b']); + + $this->assertProduceSameJson(['meta' => ['a' => 'b']], $document); } -} -class PostSerializerEmptyAttributes2 extends PostSerializer2 -{ - public function getAttributes($post, array $fields = null) + private function mockResource($type, $id, $attributes = [], $meta = [], $links = []) { - return []; - } -} + $mock = $this->getMock(ResourceInterface::class); -class CommentSerializer2 extends AbstractSerializer -{ - protected $type = 'comments'; + $mock->method('getType')->willReturn($type); + $mock->method('getId')->willReturn($id); + $mock->method('getAttributes')->willReturn($attributes); + $mock->method('getMeta')->willReturn($meta); + $mock->method('getLinks')->willReturn($links); - public function getAttributes($comment, array $fields = null) - { - return ['foo' => $comment->foo]; + return $mock; } } diff --git a/tests/ErrorHandlerTest.php b/tests/ErrorHandlerTest.php deleted file mode 100644 index b2b9583..0000000 --- a/tests/ErrorHandlerTest.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\Tests\JsonApi; - -use Exception; -use Tobscure\JsonApi\ErrorHandler; - -class ErrorHandlerTest extends AbstractTestCase -{ - public function test_it_should_throw_an_exception_when_no_handlers_are_present() - { - $this->setExpectedException('RuntimeException'); - - $handler = new ErrorHandler; - - $handler->handle(new Exception); - } -} diff --git a/tests/Exception/Handler/FallbackExceptionHandlerTest.php b/tests/Exception/Handler/FallbackExceptionHandlerTest.php deleted file mode 100644 index 37ba9ed..0000000 --- a/tests/Exception/Handler/FallbackExceptionHandlerTest.php +++ /dev/null @@ -1,46 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\Tests\Exception\Handler; - -use Exception; -use Tobscure\JsonApi\Exception\Handler\FallbackExceptionHandler; -use Tobscure\JsonApi\Exception\Handler\ResponseBag; - -class FallbackExceptionHandlerTest extends \PHPUnit_Framework_TestCase -{ - public function testHandlerCanManageExceptions() - { - $handler = new FallbackExceptionHandler(false); - - $this->assertTrue($handler->manages(new Exception)); - } - - public function testErrorHandlingWithoutDebugMode() - { - $handler = new FallbackExceptionHandler(false); - $response = $handler->handle(new Exception); - - $this->assertInstanceOf(ResponseBag::class, $response); - $this->assertEquals(500, $response->getStatus()); - $this->assertEquals([['code' => 500, 'title' => 'Internal server error']], $response->getErrors()); - } - - public function testErrorHandlingWithDebugMode() - { - $handler = new FallbackExceptionHandler(true); - $response = $handler->handle(new Exception); - - $this->assertInstanceOf(ResponseBag::class, $response); - $this->assertEquals(500, $response->getStatus()); - $this->assertArrayHasKey('detail', $response->getErrors()[0]); - } -} diff --git a/tests/Exception/Handler/InvalidParameterExceptionHandlerTest.php b/tests/Exception/Handler/InvalidParameterExceptionHandlerTest.php deleted file mode 100644 index eb01ec2..0000000 --- a/tests/Exception/Handler/InvalidParameterExceptionHandlerTest.php +++ /dev/null @@ -1,44 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\Tests\Exception\Handler; - -use Exception; -use Tobscure\JsonApi\Exception\Handler\InvalidParameterExceptionHandler; -use Tobscure\JsonApi\Exception\Handler\ResponseBag; -use Tobscure\JsonApi\Exception\InvalidParameterException; - -class InvalidParameterExceptionHandlerTest extends \PHPUnit_Framework_TestCase -{ - public function testHandlerCanManageInvalidParameterExceptions() - { - $handler = new InvalidParameterExceptionHandler(); - - $this->assertTrue($handler->manages(new InvalidParameterException)); - } - - public function testHandlerCanNotManageOtherExceptions() - { - $handler = new InvalidParameterExceptionHandler(); - - $this->assertFalse($handler->manages(new Exception)); - } - - public function testErrorHandling() - { - $handler = new InvalidParameterExceptionHandler(); - $response = $handler->handle(new InvalidParameterException('error', 1, null, 'include')); - - $this->assertInstanceOf(ResponseBag::class, $response); - $this->assertEquals(400, $response->getStatus()); - $this->assertEquals([['code' => 1, 'source' => ['parameter' => 'include']]], $response->getErrors()); - } -} diff --git a/tests/LinksTraitTest.php b/tests/LinksTraitTest.php deleted file mode 100644 index 5d80bb3..0000000 --- a/tests/LinksTraitTest.php +++ /dev/null @@ -1,58 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\Tests\JsonApi; - -use Tobscure\JsonApi\LinksTrait; - -/** - * This is the document test class. - * - * @author Toby Zerner - */ -class LinksTraitTest extends AbstractTestCase -{ - public function testAddPaginationLinks() - { - $document = new Document; - $document->addPaginationLinks('http://example.org', [], 0, 20); - - $this->assertEquals([ - 'first' => 'http://example.org', - 'next' => 'http://example.org?page%5Boffset%5D=20' - ], $document->getLinks()); - - $document = new Document; - $document->addPaginationLinks('http://example.org', ['foo' => 'bar', 'page' => ['limit' => 20]], 30, 20, 100); - - $this->assertEquals([ - 'first' => 'http://example.org?foo=bar&page%5Blimit%5D=20', - 'prev' => 'http://example.org?foo=bar&page%5Blimit%5D=20&page%5Boffset%5D=10', - 'next' => 'http://example.org?foo=bar&page%5Blimit%5D=20&page%5Boffset%5D=50', - 'last' => 'http://example.org?foo=bar&page%5Blimit%5D=20&page%5Boffset%5D=80' - ], $document->getLinks()); - - $document = new Document; - $document->addPaginationLinks('http://example.org', ['page' => ['number' => 2]], 50, 20, 100); - - $this->assertEquals([ - 'first' => 'http://example.org', - 'prev' => 'http://example.org?page%5Bnumber%5D=2', - 'next' => 'http://example.org?page%5Bnumber%5D=4', - 'last' => 'http://example.org?page%5Bnumber%5D=5' - ], $document->getLinks()); - } -} - -class Document -{ - use LinksTrait; -} diff --git a/tests/PaginationLinksTraitTest.php b/tests/PaginationLinksTraitTest.php new file mode 100644 index 0000000..076f30d --- /dev/null +++ b/tests/PaginationLinksTraitTest.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\Tests\JsonApi; + +use Tobscure\JsonApi\LinksTrait; +use Tobscure\JsonApi\PaginationLinksTrait; + +class PaginationLinksTraitTest extends AbstractTestCase +{ + public function testSetPaginationLinks() + { + $stub = new PaginationLinksTraitStub; + $stub->setPaginationLinks('http://example.org', [], 0, 20); + + $this->assertEquals([ + 'first' => 'http://example.org', + 'next' => 'http://example.org?page%5Boffset%5D=20' + ], $stub->getLinks()); + + $stub = new PaginationLinksTraitStub; + $stub->setPaginationLinks('http://example.org', ['foo' => 'bar', 'page' => ['limit' => 20]], 30, 20, 100); + + $this->assertEquals([ + 'first' => 'http://example.org?foo=bar&page%5Blimit%5D=20', + 'prev' => 'http://example.org?foo=bar&page%5Blimit%5D=20&page%5Boffset%5D=10', + 'next' => 'http://example.org?foo=bar&page%5Blimit%5D=20&page%5Boffset%5D=50', + 'last' => 'http://example.org?foo=bar&page%5Blimit%5D=20&page%5Boffset%5D=80' + ], $stub->getLinks()); + + $stub = new PaginationLinksTraitStub; + $stub->setPaginationLinks('http://example.org', ['page' => ['number' => 2]], 50, 20, 100); + + $this->assertEquals([ + 'first' => 'http://example.org', + 'prev' => 'http://example.org?page%5Bnumber%5D=2', + 'next' => 'http://example.org?page%5Bnumber%5D=4', + 'last' => 'http://example.org?page%5Bnumber%5D=5' + ], $stub->getLinks()); + + $stub = new PaginationLinksTraitStub; + $stub->setPaginationLinks('http://example.org', ['page' => ['number' => 3, 'size' => 1]], 2, 1, 2); + + $this->assertEquals([ + 'first' => 'http://example.org?page%5Bsize%5D=1', + 'prev' => 'http://example.org?page%5Bnumber%5D=2&page%5Bsize%5D=1', + 'last' => 'http://example.org?page%5Bnumber%5D=2&page%5Bsize%5D=1' + ], $stub->getLinks()); + } +} + +class PaginationLinksTraitStub +{ + use LinksTrait; + use PaginationLinksTrait; + + public function getLinks() + { + return $this->links; + } +} diff --git a/tests/ParametersTest.php b/tests/ParametersTest.php index e38362b..b365e47 100644 --- a/tests/ParametersTest.php +++ b/tests/ParametersTest.php @@ -13,11 +13,6 @@ use Tobscure\JsonApi\Parameters; -/** - * This is the parameters test class. - * - * @author Toby Zerner - */ class ParametersTest extends AbstractTestCase { public function testGetIncludeReturnsArrayOfIncludes() diff --git a/tests/RelationshipTest.php b/tests/RelationshipTest.php new file mode 100644 index 0000000..340a4b5 --- /dev/null +++ b/tests/RelationshipTest.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tobscure\Tests\JsonApi; + +use Tobscure\JsonApi\AbstractResource; +use Tobscure\JsonApi\Relationship; + +class RelationshipTest extends AbstractTestCase +{ + public function testJsonSerialize() + { + $resource1 = new RelationshipResourceStub(); + $resource2 = new RelationshipResourceStub(); + + $relationship = Relationship::fromData($resource1); + + $this->assertProduceSameJson( + [ + 'data' => ['type' => 'stub', 'id' => '1'], + ], + $relationship + ); + + $relationship = Relationship::fromData([$resource1, $resource2]); + + $this->assertProduceSameJson( + [ + 'data' => [ + ['type' => 'stub', 'id' => '1'], + ['type' => 'stub', 'id' => '1'], + ], + ], + $relationship + ); + } +} + +class RelationshipResourceStub extends AbstractResource +{ + public function getType() + { + return 'stub'; + } + + public function getId() + { + return '1'; + } +} diff --git a/tests/ResourceTest.php b/tests/ResourceTest.php deleted file mode 100644 index 63246ca..0000000 --- a/tests/ResourceTest.php +++ /dev/null @@ -1,262 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\Tests\JsonApi\Element; - -use Tobscure\JsonApi\AbstractSerializer; -use Tobscure\JsonApi\Collection; -use Tobscure\JsonApi\Relationship; -use Tobscure\JsonApi\Resource; -use Tobscure\Tests\JsonApi\AbstractTestCase; - -class ResourceTest extends AbstractTestCase -{ - public function testToArrayReturnsArray() - { - $data = (object) ['id' => '123', 'foo' => 'bar', 'baz' => 'qux']; - - $resource = new Resource($data, new PostSerializer4WithLinksAndMeta); - - $this->assertEquals([ - 'type' => 'posts', - 'id' => '123', - 'attributes' => [ - 'foo' => 'bar', - 'baz' => 'qux' - ], - 'links' => [ - 'self' => '/posts/123' - ], - 'meta' => [ - 'some-meta' => 'from-serializer-for-123' - ] - ], $resource->toArray()); - } - - public function testToIdentifierReturnsResourceIdentifier() - { - $data = (object) ['id' => '123', 'foo' => 'bar']; - - $resource = new Resource($data, new PostSerializer4); - - $this->assertEquals([ - 'type' => 'posts', - 'id' => '123' - ], $resource->toIdentifier()); - - $resource->addMeta('foo', 'bar'); - - $this->assertEquals([ - 'type' => 'posts', - 'id' => '123', - 'meta' => ['foo' => 'bar'] - ], $resource->toIdentifier()); - } - - public function testGetIdReturnsString() - { - $data = (object) ['id' => 123]; - - $resource = new Resource($data, new PostSerializer4); - - $this->assertSame('123', $resource->getId()); - } - - public function testGetIdWorksWithScalarData() - { - $resource = new Resource(123, new PostSerializer4); - - $this->assertSame('123', $resource->getId()); - } - - public function testCanFilterFields() - { - $data = (object) ['id' => '123', 'foo' => 'bar', 'baz' => 'qux']; - - $resource = new Resource($data, new PostSerializer4); - - $resource->fields(['posts' => ['baz']]); - - $this->assertEquals([ - 'type' => 'posts', - 'id' => '123', - 'attributes' => [ - 'baz' => 'qux' - ] - ], $resource->toArray()); - } - - public function testCanMergeWithAnotherResource() - { - $post1 = (object) ['id' => '123', 'foo' => 'bar', 'comments' => [1]]; - $post2 = (object) ['id' => '123', 'baz' => 'qux', 'comments' => [1, 2]]; - - $resource1 = new Resource($post1, new PostSerializer4); - $resource2 = new Resource($post2, new PostSerializer4); - - $resource1->with(['comments']); - $resource2->with(['comments']); - - $resource1->merge($resource2); - - $this->assertEquals([ - 'type' => 'posts', - 'id' => '123', - 'attributes' => [ - 'baz' => 'qux', - 'foo' => 'bar' - ], - 'relationships' => [ - 'comments' => [ - 'data' => [ - ['type' => 'comments', 'id' => '1'], - ['type' => 'comments', 'id' => '2'] - ] - ] - ] - ], $resource1->toArray()); - } - - public function testLinksMergeWithSerializerLinks() - { - $post1 = (object) ['id' => '123', 'foo' => 'bar', 'comments' => [1]]; - - $resource1 = new Resource($post1, new PostSerializer4WithLinksAndMeta()); - $resource1->addLink('self', 'overridden/by/resource'); - $resource1->addLink('related', '/some/other/comment'); - - $this->assertEquals([ - 'type' => 'posts', - 'id' => '123', - 'attributes' => [ - 'foo' => 'bar' - ], - 'links' => [ - 'self' => 'overridden/by/resource', - 'related' => '/some/other/comment' - ], - 'meta' => [ - 'some-meta' => 'from-serializer-for-123' - ] - ], $resource1->toArray()); - } - - public function testMetaMergeWithSerializerLinks() - { - $post1 = (object) ['id' => '123', 'foo' => 'bar', 'comments' => [1]]; - - $resource1 = new Resource($post1, new PostSerializer4WithLinksAndMeta()); - $resource1->addMeta('some-meta', 'overridden-by-resource'); - - $this->assertEquals([ - 'type' => 'posts', - 'id' => '123', - 'attributes' => [ - 'foo' => 'bar' - ], - 'links' => [ - 'self' => '/posts/123' - ], - 'meta' => [ - 'some-meta' => 'overridden-by-resource' - ] - ], $resource1->toArray()); - } - - public function testEmptyToOneRelationships() - { - $post1 = (object) ['id' => '123', 'foo' => 'bar']; - - $resource1 = new Resource($post1, new PostSerializer4()); - $resource1->with('author'); - - $this->assertEquals([ - 'type' => 'posts', - 'id' => '123', - 'attributes' => [ - 'foo' => 'bar' - ], - 'relationships' => [ - 'author' => ['data' => null] - ] - ], $resource1->toArray()); - } - - public function testEmptyToManyRelationships() - { - $post1 = (object) ['id' => '123', 'foo' => 'bar']; - - $resource1 = new Resource($post1, new PostSerializer4()); - $resource1->with('likes'); - - $this->assertEquals([ - 'type' => 'posts', - 'id' => '123', - 'attributes' => [ - 'foo' => 'bar' - ], - 'relationships' => [ - 'likes' => ['data' => []] - ] - ], $resource1->toArray()); - } -} - -class PostSerializer4 extends AbstractSerializer -{ - protected $type = 'posts'; - - public function getAttributes($post, array $fields = null) - { - $attributes = []; - - if (isset($post->foo)) { - $attributes['foo'] = $post->foo; - } - if (isset($post->baz)) { - $attributes['baz'] = $post->baz; - } - - return $attributes; - } - - public function comments($post) - { - return new Relationship(new Collection($post->comments, new CommentSerializer)); - } - - public function author($post) - { - return new Relationship(new Resource(null, new CommentSerializer)); - } - - public function likes($post) - { - return new Relationship(new Collection([], new CommentSerializer)); - } -} -class PostSerializer4WithLinksAndMeta extends PostSerializer4 -{ - public function getLinks($post) - { - return ['self' => sprintf('/posts/%s', $post->id)]; - } - - public function getMeta($post) - { - return ['some-meta' => sprintf('from-serializer-for-%s', $post->id)]; - } -} - -class CommentSerializer extends AbstractSerializer -{ - protected $type = 'comments'; -} diff --git a/tests/UtilTest.php b/tests/UtilTest.php deleted file mode 100644 index 48f979b..0000000 --- a/tests/UtilTest.php +++ /dev/null @@ -1,30 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tobscure\Tests\JsonApi; - -use Tobscure\JsonApi\Util; - -class UtilTest extends AbstractTestCase -{ - public function testParseRelationshipPaths() - { - $this->assertEquals( - ['user' => ['employer', 'employer.country'], 'comments' => []], - Util::parseRelationshipPaths(['user', 'user.employer', 'user.employer.country', 'comments']) - ); - - $this->assertEquals( - ['user' => ['employer.country']], - Util::parseRelationshipPaths(['user.employer.country']) - ); - } -} diff --git a/tests/benchmarks/compound10k.php b/tests/benchmarks/compound10k.php new file mode 100644 index 0000000..f636220 --- /dev/null +++ b/tests/benchmarks/compound10k.php @@ -0,0 +1,180 @@ + 'Dan', + 'last-name' => 'Gebhardt', + 'twitter' => 'dgeb' + ]; + } + + public function getLinks() + { + return [ + 'self' => 'http://example.com/people/9' + ]; + } + + public function getMeta() + { + return []; + } + + public function getRelationship($name) + { + } +}; + +class Comment05 implements ResourceInterface +{ + public function getType() + { + return 'articles'; + } + + public function getId() + { + return '5'; + } + + public function getAttributes(array $fields = null) + { + return [ + "body" => "First!" + ]; + } + + public function getLinks() + { + return [ + "self" => "http://example.com/comments/5" + ]; + } + + public function getMeta() + { + return []; + } + + public function getRelationship($name) + { + if ($name === 'author') { + return Relationship::fromData(new ResourceIdentifier('people', '2')); + } + } +} + +class Comment12 implements ResourceInterface +{ + public function getType() + { + return 'articles'; + } + + public function getId() + { + return '12'; + } + + public function getAttributes(array $fields = null) + { + return [ + "body" => "I like XML better" + ]; + } + + public function getLinks() + { + return [ + "self" => "http://example.com/comments/12" + ]; + } + + public function getMeta() + { + return []; + } + + public function getRelationship($name) + { + if ($name === 'author') { + return Relationship::fromData(new Dan()); + } + } +} + +class Article implements ResourceInterface { + public function getType() + { + return 'articles'; + } + + public function getId() + { + return '1'; + } + + public function getAttributes(array $fields = null) + { + return [ + 'title' => 'JSON API paints my bikeshed!' + ]; + } + + public function getLinks() + { + return [ + 'self' => 'http://example.com/articles/1' + ]; + } + + public function getMeta() + { + return []; + } + + public function getRelationship($name) + { + if ($name === 'author') { + $author = Relationship::fromData(new Dan()); + $author->setLink('self', 'http://example.com/articles/1/relationships/author'); + $author->setLink('related', 'http://example.com/articles/1/author'); + return $author; + } + if ($name === 'comments') { + $comments = Relationship::fromData([new Comment05(), new Comment12()]); + $comments->setLink("self", "http://example.com/articles/1/relationships/comments"); + $comments->setLink("related", "http://example.com/articles/1/comments"); + return $comments; + } + } +}; +$article = new Article(); +for ($i = 0; $i < 10000; $i++) { + $doc = Document::fromData($article); + $doc->setLink('self', 'http://example.com/articles'); + $doc->setLink('next', 'http://example.com/articles?page[offset]=2'); + $doc->setLink('last', 'http://example.com/articles?page[offset]=10'); + $doc->setInclude(['author', 'comments']); + $json = json_encode($doc); +} +echo json_encode($doc, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); \ No newline at end of file