diff --git a/.php_cs.dist b/.php_cs.dist index 9c1b5d4..06b82a3 100644 --- a/.php_cs.dist +++ b/.php_cs.dist @@ -2,6 +2,7 @@ $finder = PhpCsFixer\Finder::create() ->files() ->name('*.php') + ->in(__DIR__ . '/examples') ->in(__DIR__ . '/src') ->in(__DIR__ . '/test'); return PhpCsFixer\Config::create() @@ -42,6 +43,7 @@ return PhpCsFixer\Config::create() 'psr0' => true, 'short_scalar_cast' => true, 'single_blank_line_before_namespace' => true, + 'single_quote' => true, 'standardize_not_equals' => true, 'strict_comparison' => true, 'ternary_operator_spaces' => true, diff --git a/.travis.yml b/.travis.yml index 9a292eb..bef7869 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,10 @@ language: php php: - - '7.1' - '7.2' before_script: - composer install - - mkdir -p build/logs + - mkdir build/logs -p script: - vendor/bin/php-cs-fixer fix -v --dry-run diff --git a/CHANGELOG.md b/CHANGELOG.md index 604c8b6..548098b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,20 +6,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] -## [1.0.2] - 2018-02-21 -### Added -- This changelog - -### Removed -- Removed doc2test dependency - -## [1.0.1] - 2017-10-27 -### Changed -- Exceptions precision. Using DomainException instead of LogicException - -## [1.0.0] - 2017-10-24 +## [2.0.0-rc.0] - 2018-02-26 Initial stable release -[Unreleased]: https://github.com/json-api-php/json-api/compare/1.0.2...HEAD -[1.0.2]: https://github.com/json-api-php/json-api/compare/1.0.1...1.0.2 -[1.0.1]: https://github.com/json-api-php/json-api/compare/1.0.0...1.0.1 +[Unreleased]: https://github.com/json-api-php/json-api/compare/2.0.0-rc.0...HEAD diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f3169b..761757a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,5 +5,4 @@ Thank you for you interest! Here are some key guidelines. We want to follow the latest actual [PSR](http://www.php-fig.org/psr/) standards. ## Tests are the documentation -The use cases and library's API must be expressed in a form of tests. We use PHPUnit, but it does not mean all tests -are expected to be unit tests. No logic must be written without a supporting test. +The use cases and library's API must be expressed as tests. No logic must be written without a supporting test. diff --git a/README.md b/README.md index acc8573..9431d4e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ -# Implementation of [JSON API](http://jsonapi.org) in PHP 7 -This library is an attempt to express business rules of JSON API specification in a set of PHP 7 classes. +# [JSON API](http://jsonapi.org) spec implemented in PHP 7. Immutable -A simple example to illustrate the general idea. This JSON representation from -[the documentation](http://jsonapi.org/format/#document-resource-objects) - +**This is v2 of the implementation. For v1 click [here](/json-api-php/json-api/tree/v2).** + +The goal of this library is to ensure strict validity of JSON API documents being produced. + +JSON: ```json { "data": { @@ -27,32 +28,56 @@ A simple example to illustrate the general idea. This JSON representation from } } ``` -can be built with the following php code: - +PHP: ```php setLink('self', '/articles/1/relationships/author'); -$author->setLink('related', '/articles/1/author'); -$articles = new ResourceObject('articles', '1'); -$articles->setRelationship('author', $author); -$articles->setAttribute('title', 'Rails is Omakase'); -echo json_encode(Document::fromResource($articles), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); +echo json_encode( + new DataDocument( + new ResourceObject( + 'articles', + '1', + new Attribute('title', 'Rails is Omakase'), + new Relationship( + 'author', + new SingleLinkage(new ResourceIdentifier('author', '9')), + new SelfLink(new Url('/articles/1/relationships/author')), + new RelatedLink(new Url('/articles/1/author')) + ) + ) + ), + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES +); ``` +## Installation +`composer require json-api-php/json-api` -Please refer to [the tests](./test) for the full API documentation: -* [Documents](./test/Document/DocumentTest.php). Creating documents with primary data, errors, and meta. -Adding links and API version to a document. - * [Compound Documents](./test/Document/CompoundDocumentTest.php). Resource linkage. -* [Errors](./test/Document/ErrorTest.php) -* [Resources](./test/Document/Resource/ResourceTest.php) -* [Relationships](./test/Document/Resource/Relationship/RelationshipTest.php) -* [Linkage](./test/Document/Resource/Relationship/LinkageTest.php) +## Documentation -## Installation -With [composer](https://getcomposer.org/): `json-api-php/json-api`. +First, take a look at the examples. All of them are runnable. +- [Simple Document](./examples/simple_doc.php) (the same as above) +- [Extensive Compound Document](./examples/compound_doc.php) + +The library API and use-cases are expressed in comprehensive suite of tests. +- Data Documents (containing primary data) + - [with a single Resource Object](./test/DataDocument/SingleResourceObjectTest.php) + - [with a single Resource Identifier](./test/DataDocument/SingleResourceIdentifierTest.php) + - [with null data](./test/DataDocument/NullDataTest.php) + - [with multiple Resource Objects](./test/DataDocument/ManyResourceObjectsTest.php) + - [with multiple Resource Identifiers](./test/DataDocument/ManyResourceIdentifiersTest.php) +- [Compound Documents](./test/CompoundDocumentTest.php) +- [Error Documents](./test/ErrorDocumentTest.php) +- [Meta Documents (containing neither data nor errors)](./test/MetaDocumentTest.php) +- [Pagination links](./test/PaginationLinksTest.php) +- [Link Objects](./test/LinkObjectTest.php) +- [JSON API Object](./test/JsonApiTest.php) +- [Meta Objects](./test/MetaTest.php) diff --git a/composer.json b/composer.json index f1c84b3..a83d94f 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ } ], "require": { - "php": ">=7.1" + "php": ">=7.2" }, "require-dev": { "phpunit/phpunit": "^6.0", diff --git a/examples/compound_doc.php b/examples/compound_doc.php new file mode 100644 index 0000000..02fdac0 --- /dev/null +++ b/examples/compound_doc.php @@ -0,0 +1,74 @@ +identifier())) +); + +$document = new CompoundDocument( + new ResourceObjectSet( + new ResourceObject( + 'articles', + '1', + new Attribute('title', 'JSON API paints my bikeshed!'), + new SelfLink(new Url('http://example.com/articles/1')), + new Relationship( + 'author', + new SingleLinkage($dan->identifier()), + new SelfLink(new Url('http://example.com/articles/1/relationships/author')), + new RelatedLink(new Url('http://example.com/articles/1/author')) + ), + new Relationship( + 'comments', + new MultiLinkage( + $comment05->identifier(), + $comment12->identifier() + ), + new SelfLink(new Url('http://example.com/articles/1/relationships/comments')), + new RelatedLink(new Url('http://example.com/articles/1/comments')) + ) + ) + ), + new Included($dan, $comment05, $comment12), + new SelfLink(new Url('http://example.com/articles')), + new NextLink(new Url('http://example.com/articles?page[offset]=2')), + new LastLink(new Url('http://example.com/articles?page[offset]=10')) +); + +echo json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); diff --git a/examples/simple_doc.php b/examples/simple_doc.php new file mode 100644 index 0000000..48b60b7 --- /dev/null +++ b/examples/simple_doc.php @@ -0,0 +1,29 @@ +key = $key; + } + + public function attachTo(object $o) + { + $o->{$this->key} = $this; + } +} diff --git a/src/Attribute.php b/src/Attribute.php new file mode 100644 index 0000000..dde5b80 --- /dev/null +++ b/src/Attribute.php @@ -0,0 +1,13 @@ +identifies($resource)) { + continue; + } + foreach ($included as $anotherResource) { + if ($anotherResource->identifies($resource)) { + continue 2; + } + } + throw new \DomainException('Full linkage required for '.json_encode($resource->identifier())); + } + parent::__construct(combine($data, $included, ...$members)); + } +} diff --git a/src/DataDocument.php b/src/DataDocument.php new file mode 100644 index 0000000..64b4e31 --- /dev/null +++ b/src/DataDocument.php @@ -0,0 +1,13 @@ +setMeta($meta); - return $doc; - } - - public static function fromErrors(Error ...$errors): self - { - $doc = new self; - $doc->errors = $errors; - return $doc; - } - - public static function fromResource(ResourceObject $resource): self - { - $doc = new self; - $doc->data = new SingleResourceData($resource); - return $doc; - } - - public static function fromResources(ResourceObject ...$resources): self - { - $doc = new self; - $doc->data = new MultiResourceData(...$resources); - return $doc; - } - - public static function fromIdentifier(ResourceIdentifier $identifier): self - { - $doc = new self; - $doc->data = new SingleIdentifierData($identifier); - return $doc; - } - - public static function fromIdentifiers(ResourceIdentifier... $identifiers): self - { - $doc = new self; - $doc->data = new MultiIdentifierData(...$identifiers); - return $doc; - } - - public static function nullDocument(): self - { - $doc = new self; - $doc->data = new NullData(); - return $doc; - } - - public function setApiVersion(string $version = self::DEFAULT_API_VERSION) - { - $this->api['version'] = $version; - } - - public function setApiMeta(iterable $meta): void - { - $this->api['meta'] = new Container($meta); - } - - public function setIncluded(ResourceObject ...$resources): void - { - if (null === $this->data) { - throw new \DomainException('Document with no data cannot contain included resources'); - } - foreach ($resources as $resource) { - if (isset($this->included[(string) $resource->toIdentifier()])) { - throw new \DomainException("Resource {$resource->toIdentifier()} is already included"); - } - $this->included[(string) $resource->toIdentifier()] = $resource; - } - } - - public function markSparse(): void - { - $this->sparse = true; - } - - public function jsonSerialize() - { - $this->enforceFullLinkage(); - return filterNulls([ - 'data' => $this->data, - 'errors' => $this->errors, - 'meta' => $this->meta, - 'jsonapi' => $this->api, - 'links' => $this->links, - 'included' => $this->included ? array_values($this->included) : null, - ]); - } - - private function enforceFullLinkage(): void - { - if ($this->sparse || empty($this->included)) { - return; - } - foreach ($this->included as $included) { - if ($this->data->hasLinkTo($included)) { - continue; - } - foreach ($this->included as $anotherIncluded) { - if ($anotherIncluded->identifies($included)) { - continue 2; - } - } - throw new \DomainException("Full linkage is required for {$included->toIdentifier()}"); - } - } -} diff --git a/src/Document/Container.php b/src/Document/Container.php deleted file mode 100644 index 694f504..0000000 --- a/src/Document/Container.php +++ /dev/null @@ -1,33 +0,0 @@ - $v) { - $this->set((string) $k, $v); - } - } - } - - public function set(string $name, $value) - { - if (! isValidMemberName($name)) { - throw new \OutOfBoundsException("Invalid member name '$name'"); - } - $this->data[$name] = $value; - } - - public function jsonSerialize() - { - return (object) $this->data; - } -} diff --git a/src/Document/Error.php b/src/Document/Error.php deleted file mode 100644 index b8ed3a8..0000000 --- a/src/Document/Error.php +++ /dev/null @@ -1,81 +0,0 @@ -id = $id; - } - - public function setId(string $id) - { - $this->id = $id; - } - - public function setAboutLink(string $link) - { - $this->links['about'] = $link; - } - - public function setStatus(string $status) - { - $this->status = $status; - } - - public function setCode(string $code) - { - $this->code = $code; - } - - public function setTitle(string $title) - { - $this->title = $title; - } - - public function setDetail(string $detail) - { - $this->detail = $detail; - } - - public function setSourcePointer(string $pointer) - { - $this->source['pointer'] = $pointer; - } - - public function setSourceParameter(string $parameter) - { - $this->source['parameter'] = $parameter; - } - - public function jsonSerialize() - { - return filterNulls([ - 'id' => $this->id, - 'links' => $this->links, - 'status' => $this->status, - 'code' => $this->code, - 'title' => $this->title, - 'detail' => $this->detail, - 'source' => $this->source, - 'meta' => $this->meta, - ]) ?: (object) []; - } -} diff --git a/src/Document/LinksTrait.php b/src/Document/LinksTrait.php deleted file mode 100644 index 8f84849..0000000 --- a/src/Document/LinksTrait.php +++ /dev/null @@ -1,18 +0,0 @@ -links = $this->links ?: new Container(); - $this->links->set($name, $meta ? ['meta' => new Container($meta), 'href' => $url] : $url); - } -} diff --git a/src/Document/MetaTrait.php b/src/Document/MetaTrait.php deleted file mode 100644 index 562402d..0000000 --- a/src/Document/MetaTrait.php +++ /dev/null @@ -1,14 +0,0 @@ -meta = new Container($meta); - } -} diff --git a/src/Document/PrimaryData/MultiIdentifierData.php b/src/Document/PrimaryData/MultiIdentifierData.php deleted file mode 100644 index 008fee2..0000000 --- a/src/Document/PrimaryData/MultiIdentifierData.php +++ /dev/null @@ -1,32 +0,0 @@ -identifiers = $identifiers; - } - - public function hasLinkTo(ResourceObject $resource): bool - { - foreach ($this->identifiers as $identifier) { - if ($identifier->identifies($resource)) { - return true; - } - } - return false; - } - - public function jsonSerialize() - { - return $this->identifiers; - } -} diff --git a/src/Document/PrimaryData/MultiResourceData.php b/src/Document/PrimaryData/MultiResourceData.php deleted file mode 100644 index a607ee9..0000000 --- a/src/Document/PrimaryData/MultiResourceData.php +++ /dev/null @@ -1,31 +0,0 @@ -resources = $resources; - } - - public function hasLinkTo(ResourceObject $resource): bool - { - foreach ($this->resources as $myResource) { - if ($myResource->identifies($resource)) { - return true; - } - } - return false; - } - - public function jsonSerialize() - { - return $this->resources; - } -} diff --git a/src/Document/PrimaryData/NullData.php b/src/Document/PrimaryData/NullData.php deleted file mode 100644 index 9f577cd..0000000 --- a/src/Document/PrimaryData/NullData.php +++ /dev/null @@ -1,19 +0,0 @@ -identifier = $identifier; - } - - public function hasLinkTo(ResourceObject $resource): bool - { - return $this->identifier->identifies($resource); - } - - public function jsonSerialize() - { - return $this->identifier; - } -} diff --git a/src/Document/PrimaryData/SingleResourceData.php b/src/Document/PrimaryData/SingleResourceData.php deleted file mode 100644 index b81279b..0000000 --- a/src/Document/PrimaryData/SingleResourceData.php +++ /dev/null @@ -1,26 +0,0 @@ -resource = $resource; - } - - public function hasLinkTo(ResourceObject $resource): bool - { - return $this->resource->identifies($resource); - } - - public function jsonSerialize() - { - return $this->resource; - } -} diff --git a/src/Document/Resource/Linkage/LinkageInterface.php b/src/Document/Resource/Linkage/LinkageInterface.php deleted file mode 100644 index 466a762..0000000 --- a/src/Document/Resource/Linkage/LinkageInterface.php +++ /dev/null @@ -1,13 +0,0 @@ -identifiers = $identifiers; - } - - public function isLinkedTo(ResourceObject $resource): bool - { - foreach ($this->identifiers as $identifier) { - if ($identifier->identifies($resource)) { - return true; - } - } - return false; - } - - public function jsonSerialize() - { - return $this->identifiers; - } -} diff --git a/src/Document/Resource/Linkage/NullLinkage.php b/src/Document/Resource/Linkage/NullLinkage.php deleted file mode 100644 index a44c2ba..0000000 --- a/src/Document/Resource/Linkage/NullLinkage.php +++ /dev/null @@ -1,19 +0,0 @@ -identifier = $identifier; - } - - public function isLinkedTo(ResourceObject $resource): bool - { - return $this->identifier->identifies($resource); - } - - public function jsonSerialize() - { - return $this->identifier; - } -} diff --git a/src/Document/Resource/Relationship.php b/src/Document/Resource/Relationship.php deleted file mode 100644 index da2bdd3..0000000 --- a/src/Document/Resource/Relationship.php +++ /dev/null @@ -1,66 +0,0 @@ -setMeta($meta); - return $r; - } - - public static function fromSelfLink(string $url, iterable $meta = null): self - { - $r = new self; - $r->setLink('self', $url, $meta); - return $r; - } - - public static function fromRelatedLink(string $url, iterable $meta = null): self - { - $r = new self; - $r->setLink('related', $url, $meta); - return $r; - } - - public static function fromLinkage(LinkageInterface $linkage): self - { - $r = new self; - $r->linkage = $linkage; - return $r; - } - - public function hasLinkageTo(ResourceObject $resource): bool - { - return ($this->linkage && $this->linkage->isLinkedTo($resource)); - } - - public function jsonSerialize() - { - return filterNulls([ - 'data' => $this->linkage, - 'links' => $this->links, - 'meta' => $this->meta, - ]); - } -} diff --git a/src/Document/Resource/ResourceIdentifier.php b/src/Document/Resource/ResourceIdentifier.php deleted file mode 100644 index 77e5cba..0000000 --- a/src/Document/Resource/ResourceIdentifier.php +++ /dev/null @@ -1,59 +0,0 @@ -type = $type; - $this->id = $id; - if ($meta) { - $this->meta = new Container($meta); - } - } - - public function jsonSerialize() - { - return filterNulls([ - 'type' => $this->type, - 'id' => $this->id, - 'meta' => $this->meta, - ]); - } - - public function identifies(ResourceObject $resource): bool - { - return $resource->toIdentifier()->equals($this); - } - - private function equals(ResourceIdentifier $that) - { - return $this->type === $that->type && $this->id === $that->id; - } - - public function __toString(): string - { - return "$this->type:$this->id"; - } -} diff --git a/src/Document/Resource/ResourceObject.php b/src/Document/Resource/ResourceObject.php deleted file mode 100644 index 0cfeabf..0000000 --- a/src/Document/Resource/ResourceObject.php +++ /dev/null @@ -1,101 +0,0 @@ -type = $type; - $this->id = $id; - } - - public function setMeta(iterable $meta) - { - $this->meta = new Container($meta); - } - - public function setAttribute(string $name, $value) - { - if ($this->isReservedName($name)) { - throw new \DomainException("Can not use a reserved name '$name'"); - } - if (! isValidMemberName($name)) { - throw new \OutOfBoundsException("Invalid member name '$name'"); - } - if (isset($this->relationships[$name])) { - throw new \DomainException("Field '$name' already exists in relationships"); - } - $this->attributes[$name] = $value; - } - - public function setRelationship(string $name, Relationship $relationship) - { - if ($this->isReservedName($name)) { - throw new \DomainException("Can not use a reserved name '$name'"); - } - if (! isValidMemberName($name)) { - throw new \OutOfBoundsException("Invalid member name '$name'"); - } - if (isset($this->attributes[$name])) { - throw new \DomainException("Field '$name' already exists in attributes"); - } - $this->relationships[$name] = $relationship; - } - - public function toIdentifier(): ResourceIdentifier - { - return new ResourceIdentifier($this->type, $this->id); - } - - public function jsonSerialize() - { - return filterNulls([ - 'type' => $this->type, - 'id' => $this->id, - 'attributes' => $this->attributes, - 'relationships' => $this->relationships, - 'links' => $this->links, - 'meta' => $this->meta, - ]); - } - - public function identifies(ResourceObject $resource): bool - { - if ($this->relationships) { - foreach ($this->relationships as $relationship) { - if ($relationship->hasLinkageTo($resource)) { - return true; - } - } - } - return false; - } - - private function isReservedName(string $name): bool - { - return in_array($name, ['id', 'type']); - } -} diff --git a/src/DocumentMember.php b/src/DocumentMember.php new file mode 100644 index 0000000..d69a5ea --- /dev/null +++ b/src/DocumentMember.php @@ -0,0 +1,10 @@ +errors[] = $this; + } +} diff --git a/src/Error/Code.php b/src/Error/Code.php new file mode 100644 index 0000000..fc3dc92 --- /dev/null +++ b/src/Error/Code.php @@ -0,0 +1,16 @@ +identifier()); + if (isset($this->resources[$string_id])) { + throw new \LogicException("Resource $string_id is already included"); + } + $this->resources[$string_id] = $resource; + } + parent::__construct('included', $resources); + } + + public function getIterator() + { + return new \ArrayIterator($this->resources); + } +} diff --git a/src/JsonApi.php b/src/JsonApi.php new file mode 100644 index 0000000..a769c17 --- /dev/null +++ b/src/JsonApi.php @@ -0,0 +1,17 @@ + $version, + ]; + if ($meta) { + $meta->attachTo($jsonapi); + } + parent::__construct('jsonapi', $jsonapi); + } +} diff --git a/src/JsonSerializableValue.php b/src/JsonSerializableValue.php new file mode 100644 index 0000000..5784c9f --- /dev/null +++ b/src/JsonSerializableValue.php @@ -0,0 +1,21 @@ +value = $value; + } + + public function jsonSerialize() + { + return $this->value; + } +} diff --git a/src/Link/AboutLink.php b/src/Link/AboutLink.php new file mode 100644 index 0000000..036a185 --- /dev/null +++ b/src/Link/AboutLink.php @@ -0,0 +1,13 @@ + $href, + ]; + if ($meta) { + $meta->attachTo($link); + } + parent::__construct($link); + } +} diff --git a/src/Link/NextLink.php b/src/Link/NextLink.php new file mode 100644 index 0000000..b40bad2 --- /dev/null +++ b/src/Link/NextLink.php @@ -0,0 +1,15 @@ +identifiers = $identifiers; + } + + public function identifies(ResourceObject $resource): bool + { + foreach ($this->identifiers as $identifier) { + if ($identifier->identifies($resource)) { + return true; + } + } + return false; + } +} diff --git a/src/NullData.php b/src/NullData.php new file mode 100644 index 0000000..7d39ca6 --- /dev/null +++ b/src/NullData.php @@ -0,0 +1,18 @@ +key = $key; + } + + public function key(): string + { + return $this->key; + } +} diff --git a/src/PrimaryData/ResourceMember.php b/src/PrimaryData/ResourceMember.php new file mode 100644 index 0000000..0a88dff --- /dev/null +++ b/src/PrimaryData/ResourceMember.php @@ -0,0 +1,12 @@ +identifiers[] = $m; + } + } + } + + public function attachTo(object $o) + { + parent::attachTo(child($o, 'relationships')); + } + + public function identifies(ResourceObject $resource): bool + { + foreach ($this->identifiers as $identifier) { + if ($identifier->identifies($resource)) { + return true; + } + } + return false; + } +} diff --git a/src/RelationshipMember.php b/src/RelationshipMember.php new file mode 100644 index 0000000..8360f92 --- /dev/null +++ b/src/RelationshipMember.php @@ -0,0 +1,10 @@ + $type, + 'id' => $id, + ]; + if ($meta) { + $meta->attachTo($identifier); + } + parent::__construct('data', $identifier); + $this->type = $type; + $this->id = $id; + } + + public function identifies(ResourceObject $resource): bool + { + return $resource->identifier()->equals($this); + } + + public function equals(ResourceIdentifier $that): bool + { + return $this->type === $that->type && $this->id === $that->id; + } +} diff --git a/src/ResourceIdentifierSet.php b/src/ResourceIdentifierSet.php new file mode 100644 index 0000000..e63efc0 --- /dev/null +++ b/src/ResourceIdentifierSet.php @@ -0,0 +1,29 @@ +identifiers = $identifiers; + } + + public function identifies(ResourceObject $resource): bool + { + foreach ($this->identifiers as $identifier) { + if ($identifier->identifies($resource)) { + return true; + } + } + return false; + } +} diff --git a/src/ResourceObject.php b/src/ResourceObject.php new file mode 100644 index 0000000..b7e2cb8 --- /dev/null +++ b/src/ResourceObject.php @@ -0,0 +1,64 @@ +checkUniqueness(...$members); + $obj = combine(...$members); + $obj->type = $type; + $obj->id = $id; + parent::__construct('data', $obj); + $this->type = $type; + $this->id = $id; + foreach ($members as $member) { + if ($member instanceof Identifier) { + $this->identifiers[] = $member; + } + } + } + + public function identifier(): ResourceIdentifier + { + return new ResourceIdentifier($this->type, $this->id); + } + + public function identifies(ResourceObject $resource): bool + { + foreach ($this->identifiers as $identifier) { + if ($identifier->identifies($resource)) { + return true; + } + } + return false; + } + + private function checkUniqueness(ResourceMember ...$members): void + { + $keys = []; + foreach ($members as $member) { + if ($member instanceof ResourceField) { + $key = $member->key(); + if (isset($keys[$key])) { + throw new \LogicException("Field '$key' already exists'"); + } + $keys[$key] = true; + } + } + } +} diff --git a/src/ResourceObjectSet.php b/src/ResourceObjectSet.php new file mode 100644 index 0000000..2eb37c5 --- /dev/null +++ b/src/ResourceObjectSet.php @@ -0,0 +1,29 @@ +resources = $resources; + parent::__construct('data', $this->resources); + } + + public function identifies(ResourceObject $resource): bool + { + foreach ($this->resources as $myResource) { + if ($myResource->identifies($resource)) { + return true; + } + } + return false; + } +} diff --git a/src/SingleLinkage.php b/src/SingleLinkage.php new file mode 100644 index 0000000..e53b74a --- /dev/null +++ b/src/SingleLinkage.php @@ -0,0 +1,21 @@ +identifier = $identifier; + } + + public function identifies(ResourceObject $resource): bool + { + return $this->identifier && $this->identifier->identifies($resource); + } +} diff --git a/src/TopLevelDocumentMember.php b/src/TopLevelDocumentMember.php new file mode 100644 index 0000000..57c8210 --- /dev/null +++ b/src/TopLevelDocumentMember.php @@ -0,0 +1,10 @@ +attachTo($obj); + } + return $obj; } -function isValidResourceType(string $name): bool +function child(object $o, string $name): object { - /** - * The values of type members MUST adhere to the same constraints as member names. - * @see http://jsonapi.org/format/#document-resource-object-identification - */ - return isValidMemberName($name); + if (empty($o->{$name})) { + $o->{$name} = (object) []; + } + return $o->{$name}; } -function filterNulls(array $a): array +function isValidName(string $name): bool { - return array_filter($a, function ($v) { - return $v !== null; - }); + return preg_match('/^(?=[^-_ ])[a-zA-Z0-9\x{0080}-\x{FFFF}-_ ]*(?<=[^-_ ])$/u', $name) === 1; } diff --git a/test/BaseTestCase.php b/test/BaseTestCase.php index c9841b4..2485e47 100644 --- a/test/BaseTestCase.php +++ b/test/BaseTestCase.php @@ -1,5 +1,4 @@ -identifier())) + ); + + $document = new CompoundDocument( + new ResourceObjectSet( + new ResourceObject( + 'articles', + '1', + new Attribute('title', 'JSON API paints my bikeshed!'), + new SelfLink(new Url('http://example.com/articles/1')), + new Relationship( + 'author', + new SingleLinkage($dan->identifier()), + new SelfLink(new Url('http://example.com/articles/1/relationships/author')), + new RelatedLink(new Url('http://example.com/articles/1/author')) + ), + new Relationship( + 'comments', + new MultiLinkage( + $comment05->identifier(), + $comment12->identifier() + ), + new SelfLink(new Url('http://example.com/articles/1/relationships/comments')), + new RelatedLink(new Url('http://example.com/articles/1/comments')) + ) + ) + ), + new Included($dan, $comment05, $comment12), + new SelfLink(new Url('http://example.com/articles')), + new NextLink(new Url('http://example.com/articles?page[offset]=2')), + new LastLink(new Url('http://example.com/articles?page[offset]=10')) + ); + $this->assertEncodesTo( + ' + { + "links": { + "self": "http://example.com/articles", + "next": "http://example.com/articles?page[offset]=2", + "last": "http://example.com/articles?page[offset]=10" + }, + "data": [{ + "type": "articles", + "id": "1", + "attributes": { + "title": "JSON API paints my bikeshed!" + }, + "links": { + "self": "http://example.com/articles/1" + }, + "relationships": { + "author": { + "links": { + "self": "http://example.com/articles/1/relationships/author", + "related": "http://example.com/articles/1/author" + }, + "data": { "type": "people", "id": "9" } + }, + "comments": { + "links": { + "self": "http://example.com/articles/1/relationships/comments", + "related": "http://example.com/articles/1/comments" + }, + "data": [ + { "type": "comments", "id": "5" }, + { "type": "comments", "id": "12" } + ] + } + } + }], + "included": [{ + "type": "people", + "id": "9", + "attributes": { + "first-name": "Dan", + "last-name": "Gebhardt", + "twitter": "dgeb" + }, + "links": { + "self": "http://example.com/people/9" + } + }, { + "type": "comments", + "id": "5", + "attributes": { + "body": "First!" + }, + "relationships": { + "author": { + "data": { "type": "people", "id": "2" } + } + }, + "links": { + "self": "http://example.com/comments/5" + } + }, { + "type": "comments", + "id": "12", + "attributes": { + "body": "I like XML better" + }, + "relationships": { + "author": { + "data": { "type": "people", "id": "9" } + } + }, + "links": { + "self": "http://example.com/comments/12" + } + }] + } + ', + $document + ); + } + + /** + * Compound documents require “full linkage”, meaning that every included resource MUST be identified + * by at least one resource identifier object in the same document. + * These resource identifier objects could either be primary data or represent resource linkage + * contained within primary or included resources. + * + * @dataProvider documentsWithoutFullLinkage + * @param callable $create_doc + */ + public function testFullLinkage(callable $create_doc) + { + $this->expectException(\DomainException::class); + $this->expectExceptionMessage('Full linkage required for {"type":"apples","id":"1"}'); + $create_doc(); + } + + public function documentsWithoutFullLinkage(): array + { + $included = new Included(new ResourceObject('apples', '1')); + return [ + [ + function () use ($included) { + return new CompoundDocument(new NullData(), $included); + }, + ], + [ + function () use ($included) { + return new CompoundDocument(new ResourceObjectSet(), $included); + }, + ], + [ + function () use ($included) { + return new CompoundDocument(new ResourceIdentifier('oranges', '1'), $included); + }, + ], + [ + function () use ($included) { + return new CompoundDocument( + new ResourceIdentifierSet(new ResourceIdentifier('oranges', '1'), new ResourceIdentifier('oranges', '1')), + $included + ); + }, + ], + [ + function () use ($included) { + return new CompoundDocument( + new ResourceObjectSet(new ResourceObject('oranges', '1'), new ResourceObject('oranges', '1')), + $included + ); + }, + ], + ]; + } + + public function testIncludedResourceMayBeIdentifiedByLinkageInPrimaryData() + { + $author = new ResourceObject('people', '9'); + $article = new ResourceObject( + 'articles', + '1', + new Relationship('author', new SingleLinkage($author->identifier())) + ); + $doc = new CompoundDocument($article, new Included($author)); + $this->assertNotEmpty($doc); + } + + public function testIncludedResourceMayBeIdentifiedByAnotherLinkedResource() + { + $writer = new ResourceObject('writers', '3', new Attribute('name', 'Eric Evans')); + $book = new ResourceObject( + 'books', + '2', + new Attribute('name', 'Domain Driven Design'), + new Relationship('author', new SingleLinkage($writer->identifier())) + ); + $cart = new ResourceObject( + 'shopping-carts', + '1', + new Relationship('contents', new MultiLinkage($book->identifier())) + ); + $doc = new CompoundDocument($cart, new Included($book, $writer)); + $this->assertNotEmpty($doc); + } + + /** + * A compound document MUST NOT include more than one resource object for each type and id pair. + * @expectedException \LogicException + * @expectedExceptionMessage Resource {"type":"apples","id":"1"} is already included + */ + public function testCanNotBeManyIncludedResourcesWithEqualIdentifiers() + { + $apple = new ResourceObject('apples', '1'); + new CompoundDocument($apple->identifier(), new Included($apple, $apple)); + } +} diff --git a/test/DataDocument/ManyResourceIdentifiersTest.php b/test/DataDocument/ManyResourceIdentifiersTest.php new file mode 100644 index 0000000..6aaa5bf --- /dev/null +++ b/test/DataDocument/ManyResourceIdentifiersTest.php @@ -0,0 +1,72 @@ +assertEncodesTo( + ' + { + "data": [] + } + ', + new DataDocument( + new ResourceIdentifierSet() + ) + ); + } + + public function testExtendedDocument() + { + $this->assertEncodesTo( + ' + { + "data": [{ + "type": "apples", + "id": "1", + "meta": {"apple_meta": "foo"} + },{ + "type": "apples", + "id": "2", + "meta": {"apple_meta": "foo"} + }], + "links": { + "self": "/apples" + }, + "jsonapi": { + "version": "1.0" + }, + "meta": {"document_meta": "bar"} + } + ', + new DataDocument( + new ResourceIdentifierSet( + new ResourceIdentifier( + 'apples', + '1', + new Meta('apple_meta', 'foo') + ), + new ResourceIdentifier( + 'apples', + '2', + new Meta('apple_meta', 'foo') + ) + ), + new SelfLink(new Url('/apples')), + new JsonApi(), + new Meta('document_meta', 'bar') + ) + ); + } +} diff --git a/test/DataDocument/ManyResourceObjectsTest.php b/test/DataDocument/ManyResourceObjectsTest.php new file mode 100644 index 0000000..ef3da2c --- /dev/null +++ b/test/DataDocument/ManyResourceObjectsTest.php @@ -0,0 +1,85 @@ +assertEncodesTo( + ' + { + "data": [] + } + ', + new DataDocument( + new ResourceObjectSet() + ) + ); + } + + public function testExtendedDocument() + { + $this->assertEncodesTo( + ' + { + "data": [{ + "type": "apples", + "id": "1", + "attributes": { + "color": "red", + "sort": "Fuji" + }, + "meta": {"apple_meta": "foo"} + },{ + "type": "apples", + "id": "2", + "attributes": { + "color": "yellow", + "sort": "Gala" + }, + "meta": {"apple_meta": "foo"} + }], + "links": { + "self": "/apples" + }, + "jsonapi": { + "version": "1.0" + }, + "meta": {"document_meta": "bar"} + } + ', + new DataDocument( + new ResourceObjectSet( + new ResourceObject( + 'apples', + '1', + new Attribute('color', 'red'), + new Attribute('sort', 'Fuji'), + new Meta('apple_meta', 'foo') + ), + new ResourceObject( + 'apples', + '2', + new Attribute('color', 'yellow'), + new Attribute('sort', 'Gala'), + new Meta('apple_meta', 'foo') + ) + ), + new SelfLink(new Url('/apples')), + new JsonApi(), + new Meta('document_meta', 'bar') + ) + ); + } +} diff --git a/test/DataDocument/NullDataTest.php b/test/DataDocument/NullDataTest.php new file mode 100644 index 0000000..9d436e7 --- /dev/null +++ b/test/DataDocument/NullDataTest.php @@ -0,0 +1,53 @@ +assertEncodesTo( + ' + { + "data": null + } + ', + new DataDocument( + new NullData() + ) + ); + } + + public function testExtendedDocument() + { + $this->assertEncodesTo( + ' + { + "data": null, + "links": { + "self": "/apples/1" + }, + "jsonapi": { + "version": "1.0" + }, + "meta": {"document_meta": "bar"} + } + ', + new DataDocument( + new NullData(), + new SelfLink(new Url('/apples/1')), + new JsonApi(), + new Meta('document_meta', 'bar') + ) + ); + } +} diff --git a/test/DataDocument/SingleResourceIdentifierTest.php b/test/DataDocument/SingleResourceIdentifierTest.php new file mode 100644 index 0000000..becb005 --- /dev/null +++ b/test/DataDocument/SingleResourceIdentifierTest.php @@ -0,0 +1,63 @@ +assertEncodesTo( + ' + { + "data": { + "type": "apples", + "id": "1" + } + } + ', + new DataDocument( + new ResourceIdentifier('apples', '1') + ) + ); + } + + public function testExtendedDocument() + { + $this->assertEncodesTo( + ' + { + "data": { + "type": "apples", + "id": "1", + "meta": {"apple_meta": "foo"} + }, + "links": { + "self": "/apples/1" + }, + "jsonapi": { + "version": "1.0" + }, + "meta": {"document_meta": "bar"} + } + ', + new DataDocument( + new ResourceIdentifier( + 'apples', + '1', + new Meta('apple_meta', 'foo') + ), + new SelfLink(new Url('/apples/1')), + new JsonApi(), + new Meta('document_meta', 'bar') + ) + ); + } +} diff --git a/test/DataDocument/SingleResourceObjectTest.php b/test/DataDocument/SingleResourceObjectTest.php new file mode 100644 index 0000000..5d9af4b --- /dev/null +++ b/test/DataDocument/SingleResourceObjectTest.php @@ -0,0 +1,70 @@ +assertEncodesTo( + ' + { + "data": { + "type": "apples", + "id": "1" + } + } + ', + new DataDocument( + new ResourceObject('apples', '1') + ) + ); + } + + public function testExtendedDocument() + { + $this->assertEncodesTo( + ' + { + "data": { + "type": "apples", + "id": "1", + "attributes": { + "color": "red", + "sort": "Fuji" + }, + "meta": {"apple_meta": "foo"} + }, + "links": { + "self": "/apples/1" + }, + "jsonapi": { + "version": "1.0" + }, + "meta": {"document_meta": "bar"} + } + ', + new DataDocument( + new ResourceObject( + 'apples', + '1', + new Attribute('color', 'red'), + new Attribute('sort', 'Fuji'), + new Meta('apple_meta', 'foo') + ), + new SelfLink(new Url('/apples/1')), + new JsonApi(), + new Meta('document_meta', 'bar') + ) + ); + } +} diff --git a/test/Document/CompoundDocumentTest.php b/test/Document/CompoundDocumentTest.php deleted file mode 100644 index 5789f26..0000000 --- a/test/Document/CompoundDocumentTest.php +++ /dev/null @@ -1,294 +0,0 @@ -setAttribute('first-name', 'Dan'); - $dan->setAttribute('last-name', 'Gebhardt'); - $dan->setAttribute('twitter', 'dgeb'); - $dan->setLink('self', 'http://example.com/people/9'); - - $comment05 = new ResourceObject('comments', '5'); - $comment05->setAttribute('body', 'First!'); - $comment05->setLink('self', 'http://example.com/comments/5'); - $comment05->setRelationship( - 'author', - Relationship::fromLinkage(new SingleLinkage(new ResourceIdentifier('people', '2'))) - ); - - $comment12 = new ResourceObject('comments', '12'); - $comment12->setAttribute('body', 'I like XML better'); - $comment12->setLink('self', 'http://example.com/comments/12'); - $comment12->setRelationship( - 'author', - Relationship::fromLinkage(new SingleLinkage($dan->toIdentifier())) - ); - - $author = Relationship::fromLinkage(new SingleLinkage($dan->toIdentifier())); - $author->setLink('self', 'http://example.com/articles/1/relationships/author'); - $author->setLink('related', 'http://example.com/articles/1/author'); - - $comments = Relationship::fromLinkage(new MultiLinkage($comment05->toIdentifier(), $comment12->toIdentifier())); - $comments->setLink('self', 'http://example.com/articles/1/relationships/comments'); - $comments->setLink('related', 'http://example.com/articles/1/comments'); - - $article = new ResourceObject('articles', '1'); - $article->setAttribute('title', 'JSON API paints my bikeshed!'); - $article->setLink('self', 'http://example.com/articles/1'); - $article->setRelationship('author', $author); - $article->setRelationship('comments', $comments); - - $doc = Document::fromResources($article); - $doc->setIncluded($dan, $comment05, $comment12); - $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'); - - $this->assertEncodesTo( - ' - { - "links": { - "self": "http://example.com/articles", - "next": "http://example.com/articles?page[offset]=2", - "last": "http://example.com/articles?page[offset]=10" - }, - "data": [{ - "type": "articles", - "id": "1", - "attributes": { - "title": "JSON API paints my bikeshed!" - }, - "links": { - "self": "http://example.com/articles/1" - }, - "relationships": { - "author": { - "links": { - "self": "http://example.com/articles/1/relationships/author", - "related": "http://example.com/articles/1/author" - }, - "data": { "type": "people", "id": "9" } - }, - "comments": { - "links": { - "self": "http://example.com/articles/1/relationships/comments", - "related": "http://example.com/articles/1/comments" - }, - "data": [ - { "type": "comments", "id": "5" }, - { "type": "comments", "id": "12" } - ] - } - } - }], - "included": [{ - "type": "people", - "id": "9", - "attributes": { - "first-name": "Dan", - "last-name": "Gebhardt", - "twitter": "dgeb" - }, - "links": { - "self": "http://example.com/people/9" - } - }, { - "type": "comments", - "id": "5", - "attributes": { - "body": "First!" - }, - "relationships": { - "author": { - "data": { "type": "people", "id": "2" } - } - }, - "links": { - "self": "http://example.com/comments/5" - } - }, { - "type": "comments", - "id": "12", - "attributes": { - "body": "I like XML better" - }, - "relationships": { - "author": { - "data": { "type": "people", "id": "9" } - } - }, - "links": { - "self": "http://example.com/comments/12" - } - }] - } - ', - $doc - ); - } - - /** - * @expectedException \DomainException - * @expectedExceptionMessage Full linkage is required for apples:1 - * @dataProvider documentsWithoutFullLinkage - * @param Document $doc - */ - public function testFullLinkageIsRequired(Document $doc) - { - $doc->setIncluded(new ResourceObject('apples', '1')); - json_encode($doc); - } - - public function documentsWithoutFullLinkage(): array - { - return [ - [Document::nullDocument()], - [Document::fromIdentifier(new ResourceIdentifier('oranges', '1'))], - [Document::fromIdentifiers(new ResourceIdentifier('oranges', '1'), new ResourceIdentifier('oranges', '2'))], - [Document::fromResource(new ResourceObject('oranges', '1'))], - [Document::fromResources(new ResourceObject('oranges', '1'), new ResourceObject('oranges', '1'))], - ]; - } - - /** - * A compound document must be explicitly marked as sparse. In this case full linkage is not required. - */ - public function testFullLinkageIsNotRequiredIfSparse() - { - $doc = Document::nullDocument(); - $doc->markSparse(); - $doc->setIncluded(new ResourceObject('apples', '1')); - $this->assertEncodesTo( - ' - { - "data": null, - "included": [ - { - "type": "apples", - "id": "1" - } - ] - } - ', - $doc - ); - } - - /** - * Compound documents require “full linkage”, meaning that every included resource MUST be identified - * by at least one resource identifier object in the same document. - * These resource identifier objects could either be primary data or represent resource linkage - * contained within primary or included resources. - */ - public function testIncludedResourceMayBeIdentifiedByPrimaryData() - { - $apple = new ResourceObject('apples', '1'); - $apple->setAttribute('color', 'red'); - $doc = Document::fromIdentifier($apple->toIdentifier()); - $doc->setIncluded($apple); - $this->assertJson(json_encode($doc)); - } - - public function testIncludedResourceMayBeIdentifiedByLinkageInPrimaryData() - { - $author = new ResourceObject('people', '9'); - $author->setAttribute('first-name', 'Dan'); - - $article = new ResourceObject('articles', '1'); - $article->setAttribute('title', 'JSON API paints my bikeshed!'); - $article->setRelationship( - 'author', - Relationship::fromLinkage(new SingleLinkage($author->toIdentifier())) - ); - - $doc = Document::fromResource($article); - $doc->setIncluded($author); - $this->assertJson(json_encode($doc)); - } - - public function testIncludedResourceMayBeIdentifiedByAnotherLinkedResource() - { - $writer = new ResourceObject('writers', '3'); - $writer->setAttribute('name', 'Eric Evans'); - - $book = new ResourceObject('books', '2'); - $book->setAttribute('name', 'Domain Driven Design'); - $book->setRelationship( - 'author', - Relationship::fromLinkage(new SingleLinkage($writer->toIdentifier())) - ); - - $cart = new ResourceObject('shopping-carts', '1'); - $cart->setRelationship( - 'contents', - Relationship::fromLinkage(new MultiLinkage($book->toIdentifier())) - ); - - $this->assertTrue($book->identifies($writer)); - - $doc = Document::fromResource($cart); - $doc->setIncluded($book, $writer); - $this->assertJson(json_encode($doc)); - } - - /** - * A compound document MUST NOT include more than one resource object for each type and id pair. - * @expectedException \DomainException - * @expectedExceptionMessage Resource apples:1 is already included - */ - public function testCanNotBeManyIncludedResourcesWithEqualIdentifiers() - { - $apple = new ResourceObject('apples', '1'); - $apple->setAttribute('color', 'red'); - $doc = Document::fromIdentifier($apple->toIdentifier()); - $doc->setIncluded($apple, $apple); - $this->assertJson(json_encode($doc)); - } - - /** - * If a document does not contain a top-level data key, the included member MUST NOT be present either. - * @expectedException \DomainException - * @expectedExceptionMessage Document with no data cannot contain included resources - */ - public function testIncludedMustOnlyBePresentWithData() - { - $doc = Document::fromMeta(['foo' => 'bar']); - $doc->setIncluded(new ResourceObject('apples', '1')); - } -} diff --git a/test/Document/DocumentTest.php b/test/Document/DocumentTest.php deleted file mode 100644 index 705f769..0000000 --- a/test/Document/DocumentTest.php +++ /dev/null @@ -1,186 +0,0 @@ -assertEncodesTo( - ' - { - "meta": { - "foo": "bar" - } - } - ', - Document::fromMeta(['foo' => 'bar']) - ); - } - - /** - * A valid document may contain just an array of errors. - * The array of errors may even be empty, the documentation does not explicitly restrict it. - */ - public function testDocumentMayContainJustErrors() - { - $this->assertEncodesTo( - ' - { - "errors": [ - { - "id": "first" - } - ] - } - ', - Document::fromErrors(new Error('first')) - ); - - $this->assertEncodesTo( - ' - { - "errors": [] - } - ', - Document::fromErrors() - ); - } - - /** - * A valid document may contain just a primary data object. - * The primary data object is represented by ResourceInterface (@see ResourceObjectTest for details). - * Here is how a document can be created from different kinds of resources: - * - null resource - * - resource identifier - * - full-fledged resource object - * - an array of resource objects/identifiers - */ - public function testDocumentMayContainJustData() - { - $this->assertEncodesTo( - ' - { - "data": null - } - ', - Document::nullDocument(), - 'The simplest document possible contains null' - ); - - $this->assertEncodesTo( - ' - { - "data": { - "type": "books", - "id": "abc123" - } - } - ', - Document::fromIdentifier(new ResourceIdentifier('books', 'abc123')), - 'Resource identifier can be used as primary data' - ); - - $apple = new ResourceObject('apples', '007'); - $apple->setAttribute('color', 'red'); - $this->assertEncodesTo( - ' - { - "data": { - "type": "apples", - "id": "007", - "attributes": { - "color": "red" - } - } - } - ', - Document::fromResource($apple), - 'Full-fledged resource object' - ); - - $this->assertEncodesTo( - ' - { - "data": [ - { - "type": "books", - "id": "12" - }, - { - "type": "carrots", - "id": "42" - } - ] - } - ', - Document::fromIdentifiers( - new ResourceIdentifier('books', '12'), - new ResourceIdentifier('carrots', '42') - ), - 'An array of resource identifiers' - ); - } - - /** - * When a document is created, it is possible to add more stuff to it: - * - API details - * - meta - * - links (@see LinkageTest for details) - */ - public function testDocumentCanHaveExtraProperties() - { - $doc = Document::fromIdentifier( - new ResourceIdentifier('apples', '42') - ); - $doc->setApiVersion('1.0'); - $doc->setApiMeta(['a' => 'b']); - $doc->setMeta(['test' => 'test']); - $doc->setLink('self', 'http://example.com/self'); - $doc->setLink('related', 'http://example.com/rel', ['foo' => 'bar']); - $this->assertEncodesTo( - ' - { - "data": { - "type": "apples", - "id": "42" - }, - "meta": { - "test": "test" - }, - "jsonapi": { - "version": "1.0", - "meta": { - "a": "b" - } - }, - "links": { - "self": "http://example.com/self", - "related": { - "href": "http://example.com/rel", - "meta": { - "foo": "bar" - } - } - } - } - ', - $doc - ); - } -} diff --git a/test/Document/ErrorTest.php b/test/Document/ErrorTest.php deleted file mode 100644 index 995a8e9..0000000 --- a/test/Document/ErrorTest.php +++ /dev/null @@ -1,51 +0,0 @@ -assertEncodesTo('{}', new Error()); - } - - public function testErrorWithFullSetOfProperties() - { - $e = new Error(); - $e->setId('test_id'); - $e->setAboutLink('http://localhost'); - $e->setStatus('404'); - $e->setCode('OMG'); - $e->setTitle('Error'); - $e->setDetail('Nothing is found'); - $e->setSourcePointer('/data'); - $e->setSourceParameter('test_param'); - $e->setMeta(['foo' => 'bar']); - - $this->assertEncodesTo( - ' - { - "id": "test_id", - "links": { - "about":"http://localhost" - }, - "status": "404", - "code": "OMG", - "title": "Error", - "detail": "Nothing is found", - "source": { - "pointer": "/data", - "parameter": "test_param" - }, - "meta": { - "foo":"bar" - } - } - ', - $e - ); - } -} diff --git a/test/Document/Resource/Relationship/LinkageTest.php b/test/Document/Resource/Relationship/LinkageTest.php deleted file mode 100644 index 21671c6..0000000 --- a/test/Document/Resource/Relationship/LinkageTest.php +++ /dev/null @@ -1,119 +0,0 @@ -assertEncodesTo( - 'null', - new NullLinkage() - ); - } - - public function testCanCreateEmptyArrayLinkage() - { - $this->assertEncodesTo( - '[]', - new MultiLinkage() - ); - } - - public function testCanCreateFromSingleResourceId() - { - $this->assertEncodesTo( - ' - { - "type": "books", - "id": "abc" - } - ', - new SingleLinkage(new ResourceIdentifier('books', 'abc')) - ); - } - - public function testCanCreateFromArrayOfResourceIds() - { - $this->assertEncodesTo( - ' - [ - { - "type": "books", - "id": "abc" - }, - { - "type": "squirrels", - "id": "123" - } - ] - ', - new MultiLinkage( - new ResourceIdentifier('books', 'abc'), - new ResourceIdentifier('squirrels', '123') - ) - ); - } - - public function testNullLinkageIsLinkedToNothing() - { - $apple = new ResourceObject('apples', '1'); - $this->assertFalse((new NullLinkage())->isLinkedTo($apple)); - } - - public function testEmptyArrayLinkageIsLinkedToNothing() - { - $apple = new ResourceObject('apples', '1'); - $this->assertFalse((new MultiLinkage())->isLinkedTo($apple)); - } - - public function testSingleLinkageIsLinkedOnlyToItself() - { - $apple = new ResourceObject('apples', '1'); - $orange = new ResourceObject('oranges', '1'); - - $linkage = new SingleLinkage($apple->toIdentifier()); - - $this->assertTrue($linkage->isLinkedTo($apple)); - $this->assertFalse($linkage->isLinkedTo($orange)); - } - - public function testMultiLinkageIsLinkedOnlyToItsMembers() - { - $apple = new ResourceObject('apples', '1'); - $orange = new ResourceObject('oranges', '1'); - $banana = new ResourceObject('bananas', '1'); - - $linkage = new MultiLinkage($apple->toIdentifier(), $orange->toIdentifier()); - - $this->assertTrue($linkage->isLinkedTo($apple)); - $this->assertTrue($linkage->isLinkedTo($orange)); - $this->assertFalse($linkage->isLinkedTo($banana)); - } -} diff --git a/test/Document/Resource/Relationship/RelationshipTest.php b/test/Document/Resource/Relationship/RelationshipTest.php deleted file mode 100644 index fc406af..0000000 --- a/test/Document/Resource/Relationship/RelationshipTest.php +++ /dev/null @@ -1,95 +0,0 @@ -assertEncodesTo( - ' - { - "links": { - "self": "http://localhost" - } - } - ', - Relationship::fromSelfLink('http://localhost') - ); - } - - public function testCanCreateFromRelatedLink() - { - $this->assertEncodesTo( - ' - { - "links": { - "related": "http://localhost" - } - } - ', - Relationship::fromRelatedLink('http://localhost') - ); - } - - public function testCanCreateFromLinkage() - { - $this->assertEncodesTo( - ' - { - "data": null - } - ', - Relationship::fromLinkage(new NullLinkage()) - ); - } - - public function testCanCreateFromMeta() - { - $this->assertEncodesTo( - ' - { - "meta": { - "a": "b" - } - } - ', - Relationship::fromMeta(['a' => 'b']) - ); - } -} diff --git a/test/Document/Resource/ResourceFieldsTest.php b/test/Document/Resource/ResourceFieldsTest.php deleted file mode 100644 index 9e25d6f..0000000 --- a/test/Document/Resource/ResourceFieldsTest.php +++ /dev/null @@ -1,76 +0,0 @@ -setAttribute('foo', 'bar'); - $res->setRelationship('foo', Relationship::fromMeta(['a' => 'b'])); - } - - /** - * @expectedException \DomainException - * @expectedExceptionMessage Field 'foo' already exists in relationships - */ - public function testCanNotSetAttributeIfRelationshipExists() - { - $res = new ResourceObject('books', '1'); - $res->setRelationship('foo', Relationship::fromMeta(['a' => 'b'])); - $res->setAttribute('foo', 'bar'); - } - - /** - * @param string $name - * @expectedException \DomainException - * @expectedExceptionMessage Can not use a reserved name - * @dataProvider reservedAttributeNames - */ - public function testAttributeCanNotHaveReservedNames(string $name) - { - $res = new ResourceObject('books', 'abc'); - $res->setAttribute($name, 1); - } - - /** - * @param string $name - * @expectedException \DomainException - * @expectedExceptionMessage Can not use a reserved name - * @dataProvider reservedAttributeNames - */ - public function testRelationshipCanNotHaveReservedNames(string $name) - { - $res = new ResourceObject('books', 'abc'); - $res->setRelationship($name, Relationship::fromMeta(['a' => 'b'])); - } - - public function reservedAttributeNames(): array - { - return [ - ['id'], - ['type'], - ]; - } -} diff --git a/test/Document/Resource/ResourceIdentifierTest.php b/test/Document/Resource/ResourceIdentifierTest.php deleted file mode 100644 index 9911355..0000000 --- a/test/Document/Resource/ResourceIdentifierTest.php +++ /dev/null @@ -1,44 +0,0 @@ -assertEncodesTo( - ' - { - "type": "books", - "id": "1" - } - ', - new ResourceIdentifier('books', '1') - ); - } - - public function testResourceIdentifierMayContainMeta() - { - $this->assertEncodesTo( - ' - { - "type": "books", - "id": "1", - "meta": { - "foo":"bar" - } - } - ', - new ResourceIdentifier('books', '1', ['foo' => 'bar']) - ); - } -} diff --git a/test/Document/Resource/ResourceObjectTest.php b/test/Document/Resource/ResourceObjectTest.php deleted file mode 100644 index 978a77b..0000000 --- a/test/Document/Resource/ResourceObjectTest.php +++ /dev/null @@ -1,110 +0,0 @@ -assertEncodesTo('{"type": "books"}', new ResourceObject('books')); - } - - /** - * In addition, a resource object MAY contain any of these top-level members: - * - * - attributes: an attributes object representing some of the resource’s data. - * - * - relationships: a relationships object describing relationships - * between the resource and other JSON API resources. - * - * - links: a links object containing links related to the resource. - * - * - meta: a meta object containing non-standard meta-information about a resource - * that can not be represented as an attribute or relationship. - */ - public function testResourceObjectMayContainAttributes() - { - $apple = new ResourceObject('apples', '1'); - $apple->setAttribute('color', 'red'); - $this->assertEncodesTo('{"type":"apples", "id":"1", "attributes":{"color":"red"}}', $apple); - } - - public function testResourceObjectMayContainRelationships() - { - $article = new ResourceObject('articles', '1'); - $user = new ResourceIdentifier('users', '42'); - $article->setRelationship('author', Relationship::fromLinkage(new SingleLinkage($user))); - $this->assertEncodesTo( - ' - { - "type":"articles", - "id":"1", - "relationships":{ - "author":{ - "data":{ - "type":"users", - "id":"42" - } - } - } - } - ', - $article - ); - } - - public function testResourceObjectMayContainLinks() - { - $article = new ResourceObject('articles', '1'); - $article->setLink('self', 'https://example.com'); - $this->assertEncodesTo( - ' - { - "type":"articles", - "id":"1", - "links":{ - "self":"https://example.com" - } - } - ', - $article - ); - } - - public function testResourceObjectMayContainMeta() - { - $article = new ResourceObject('articles', '1'); - $article->setMeta(['tags' => ['cool', 'new']]); - $this->assertEncodesTo( - ' - { - "type":"articles", - "id":"1", - "meta":{ - "tags":[ - "cool", - "new" - ] - } - } - ', - $article - ); - } -} diff --git a/test/ErrorDocumentTest.php b/test/ErrorDocumentTest.php new file mode 100644 index 0000000..5ed715a --- /dev/null +++ b/test/ErrorDocumentTest.php @@ -0,0 +1,122 @@ +assertEncodesTo( + ' + { + "errors": [{}] + } + ', + new ErrorDocument( + new Error() + ) + ); + } + + public function testExtensiveExample() + { + $this->assertEncodesTo( + ' + { + "errors": [{ + "id": "1", + "links": { + "about":"/errors/not_found" + }, + "status": "404", + "code": "not_found", + "title": "Resource not found", + "detail": "We tried hard but could not find anything", + "source": { + "pointer": "/data", + "parameter": "query_string" + }, + "meta": { + "purpose":"test" + } + }], + "meta": {"purpose": "test"}, + "jsonapi": { + "version": "1.0" + } + } + ', + new ErrorDocument( + new Error( + new Id('1'), + new AboutLink( + new Url('/errors/not_found') + ), + new Status('404'), + new Code('not_found'), + new Title('Resource not found'), + new Detail('We tried hard but could not find anything'), + new Pointer('/data'), + new Parameter('query_string'), + new Meta('purpose', 'test') + ), + new Meta('purpose', 'test'), + new JsonApi() + ) + ); + } + + public function testMultipleErrors() + { + $this->assertEncodesTo( + ' + { + "errors": [{ + "id": "1", + "code": "invalid_parameter", + "source": { + "parameter": "foo" + } + },{ + "id": "2", + "code": "invalid_parameter", + "source": { + "parameter": "bar" + } + }] + } + ', + new ErrorDocument( + new Error( + new Id('1'), + new Code('invalid_parameter'), + new Parameter('foo') + ), + new Error( + new Id('2'), + new Code('invalid_parameter'), + new Parameter('bar') + ) + ) + ); + } +} diff --git a/test/ExamplesTest.php b/test/ExamplesTest.php new file mode 100644 index 0000000..ba7a1cf --- /dev/null +++ b/test/ExamplesTest.php @@ -0,0 +1,26 @@ +assertJson(`php $file`); + } + + public function examples() + { + return [ + [__DIR__.'/../examples/compound_doc.php'], + [__DIR__.'/../examples/simple_doc.php'], + ]; + } +} diff --git a/test/IntegrationTest.php b/test/IntegrationTest.php deleted file mode 100644 index a884d86..0000000 --- a/test/IntegrationTest.php +++ /dev/null @@ -1,58 +0,0 @@ -setLink('self', '/articles/1/relationships/author'); - $author->setLink('related', '/articles/1/author'); - $articles->setRelationship('author', $author); - $articles->setAttribute('title', 'Rails is Omakase'); - $doc = Document::fromResource($articles); - - $this->assertEquals( - $json, - json_encode($doc, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) - ); - } -} diff --git a/test/JsonApiTest.php b/test/JsonApiTest.php new file mode 100644 index 0000000..99731b0 --- /dev/null +++ b/test/JsonApiTest.php @@ -0,0 +1,25 @@ +assertEncodesTo( + ' + { + "version": "1.0", + "meta": { + "foo": "bar" + } + } + ', + new JsonApi('1.0', new Meta('foo', 'bar')) + ); + } +} diff --git a/test/LinkObjectTest.php b/test/LinkObjectTest.php new file mode 100644 index 0000000..6df8fff --- /dev/null +++ b/test/LinkObjectTest.php @@ -0,0 +1,34 @@ +assertEncodesTo( + '{ + "data": {"type": "apples", "id": "1"}, + "links": { + "self": { + "href": "http://example.com", + "meta": { + "foo": "bar" + } + } + } + } + ', + new DataDocument( + new ResourceIdentifier('apples', '1'), + new SelfLink(new LinkObject('http://example.com', new Meta('foo', 'bar'))) + ) + ); + } +} diff --git a/test/MemberNamesTest.php b/test/MemberNamesTest.php deleted file mode 100644 index 196c356..0000000 --- a/test/MemberNamesTest.php +++ /dev/null @@ -1,117 +0,0 @@ -invalidAttributeNames() as $attributeName) { - foreach ($this->memberNameCallbacks() as $memberNameCallback) { - yield [$attributeName, $memberNameCallback]; - } - foreach ($this->linksCallbacks() as $linksCallback) { - yield [$attributeName, $linksCallback]; - } - } - } - - public function invalidAttributesAndResourceTypeCallbacks() - { - foreach ($this->invalidAttributeNames() as $attributeName) { - foreach ($this->resourceTypeCallbacks() as $resourceTypeCallback) { - yield [$attributeName, $resourceTypeCallback]; - } - } - } - - private function invalidAttributeNames(): array - { - return [ - '_abcde', - 'abcd_', - 'abc$EDS', - '#abcde', - 'abcde(', - 'b_', - '_a', - '$ab_c-d', - '-abc', - ]; - } - - /** - * @return callable[] - */ - private function memberNameCallbacks() - { - return [ - function ($name) { - (new ResourceObject('apples', '0'))->setRelationship( - $name, - Relationship::fromSelfLink('https://example.com') - ); - }, - function ($name) { - (new ResourceObject('apples', '0'))->setAttribute($name, 'foo'); - }, - ]; - } - - private function resourceTypeCallbacks() - { - yield function ($type) { - new ResourceIdentifier($type, 'foo'); - }; - yield function ($type) { - new ResourceObject($type, 'foo'); - }; - } - - private function linksCallbacks() - { - $objects = [ - Document::nullDocument(), - Relationship::fromSelfLink('https://example.com'), - new ResourceObject('apples', '0'), - ]; - foreach ($objects as $object) { - yield function ($name) use ($object) { - $object->setLink($name, 'https://example.com'); - }; - } - } -} diff --git a/test/MetaDocumentTest.php b/test/MetaDocumentTest.php new file mode 100644 index 0000000..7893dae --- /dev/null +++ b/test/MetaDocumentTest.php @@ -0,0 +1,51 @@ +assertEncodesTo( + ' + { + "meta": { + "foo": "bar" + } + } + ', + new MetaDocument(new Meta('foo', 'bar')) + ); + } + + /** + * A meta document may contain jsonapi member + */ + public function testMetaDocumentWithExtraMembers() + { + $this->assertEncodesTo( + ' + { + "meta": { + "foo": "bar" + }, + "jsonapi": { + "version": "1.0" + } + } + ', + new MetaDocument( + new Meta('foo', 'bar'), + new JsonApi() + ) + ); + } +} diff --git a/test/MetaTest.php b/test/MetaTest.php new file mode 100644 index 0000000..8ea6b1b --- /dev/null +++ b/test/MetaTest.php @@ -0,0 +1,16 @@ +expectException(\DomainException::class); + $this->expectExceptionMessage("Invalid character in a member name 'this+name&is'"); + new Meta('this+name&is', '1'); + } +} diff --git a/test/PaginationLinksTest.php b/test/PaginationLinksTest.php new file mode 100644 index 0000000..f1774d6 --- /dev/null +++ b/test/PaginationLinksTest.php @@ -0,0 +1,45 @@ +assertEncodesTo( + ' + { + "data": [ + {"type": "apples", "id": "1"}, + {"type": "apples", "id": "2"} + ], + "links": { + "first": "http://example.com/fruits?page=first", + "last": "http://example.com/fruits?page=last", + "prev": "http://example.com/fruits?page=3", + "next": "http://example.com/fruits?page=5" + } + } + ', + new DataDocument( + new ResourceObjectSet( + new ResourceObject('apples', '1'), + new ResourceObject('apples', '2') + ), + new FirstLink(new Url('http://example.com/fruits?page=first')), + new LastLink(new Url('http://example.com/fruits?page=last')), + new PrevLink(new Url('http://example.com/fruits?page=3')), + new NextLink(new Url('http://example.com/fruits?page=5')) + ) + ); + } +} diff --git a/test/ResourceObjectTest.php b/test/ResourceObjectTest.php new file mode 100644 index 0000000..3548dcd --- /dev/null +++ b/test/ResourceObjectTest.php @@ -0,0 +1,215 @@ +assertEncodesTo( + ' + { + "type": "apples", + "id": "1", + "attributes": { + "title": "Rails is Omakase" + }, + "meta": {"foo": "bar"}, + "links": { + "self": "http://self" + }, + "relationships": { + "author": { + "meta": {"foo": "bar"}, + "links": { + "self": "http://rel/author", + "related": "http://author" + }, + "data": null + } + } + } + ', + new ResourceObject( + 'apples', + '1', + new Meta('foo', 'bar'), + new Attribute('title', 'Rails is Omakase'), + new SelfLink(new Url('http://self')), + new Relationship( + 'author', + new Meta('foo', 'bar'), + new SelfLink(new Url('http://rel/author')), + new RelatedLink(new Url('http://author')), + new SingleLinkage() + ) + ) + ); + } + + public function testRelationshipWithSingleIdLinkage() + { + $this->assertEncodesTo( + ' + { + "data": { + "type": "apples", + "id": "1" + } + } + ', + new Relationship( + 'fruits', + new SingleLinkage( + new ResourceIdentifier('apples', '1') + ) + ) + ); + } + + public function testRelationshipWithMultiIdLinkage() + { + $this->assertEncodesTo( + ' + { + "data": [{ + "type": "apples", + "id": "1" + },{ + "type": "pears", + "id": "2" + }] + } + ', + new Relationship( + 'fruits', + new MultiLinkage( + new ResourceIdentifier('apples', '1'), + new ResourceIdentifier('pears', '2') + ) + ) + ); + } + + public function testRelationshipWithEmptyMultiIdLinkage() + { + $this->assertEncodesTo( + ' + { + "data": [] + } + ', + new Relationship( + 'fruits', + new MultiLinkage() + ) + ); + } + + public function testCanNotCreateIdAttribute() + { + $this->expectException(\DomainException::class); + $this->expectExceptionMessage("Can not use 'id' as a resource field"); + new Attribute('id', 'foo'); + } + + public function testCanNotCreateTypeAttribute() + { + $this->expectException(\DomainException::class); + $this->expectExceptionMessage("Can not use 'type' as a resource field"); + new Attribute('type', 'foo'); + } + + public function testCanNotCreateIdRelationship() + { + $this->expectException(\DomainException::class); + $this->expectExceptionMessage("Can not use 'id' as a resource field"); + new Relationship('id', new SingleLinkage(new ResourceIdentifier('apples', '1'))); + } + + public function testCanNotCreateTypeRelationship() + { + $this->expectException(\DomainException::class); + $this->expectExceptionMessage("Can not use 'type' as a resource field"); + new Relationship('type', new SingleLinkage(new ResourceIdentifier('apples', '1'))); + } + + /** + * @dataProvider invalidCharacters + * @param string $invalid_char + */ + public function testAttributeMustOnlyHaveAllowedCharacters(string $invalid_char) + { + $this->expectException(\DomainException::class); + $this->expectExceptionMessage('Invalid character in a member name'); + new Attribute("foo{$invalid_char}bar", 'plus can not be used'); + } + + /** + * @dataProvider invalidCharacters + * @param string $invalid_char + */ + public function testRelationshipMustOnlyHaveAllowedCharacters(string $invalid_char) + { + $this->expectException(\DomainException::class); + $this->expectExceptionMessage('Invalid character in a member name'); + new Relationship("foo{$invalid_char}bar", new SingleLinkage()); + } + + public function invalidCharacters() + { + return [ + ['+'], + ['!'], + ['@'], + ['/'], + ['}'], + ]; + } + + public function testResourceFieldsMustBeUnique() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage("Field 'foo' already exists"); + new ResourceObject( + 'apples', + '1', + new Attribute('foo', 'bar'), + new Relationship('foo', new SingleLinkage(new ResourceIdentifier('apples', '1'))) + ); + } + + /** + * The id member is not required when the resource object originates at the client and represents + * a new resource to be created on the server. + */ + public function testResourceIdCanBeOmitted() + { + $this->assertEncodesTo( + '{ + "type": "apples", + "id": null, + "attributes": { + "color": "red" + } + }', + new ResourceObject('apples', null, new Attribute('color', 'red')) + ); + } + + public function testEmptySingleLinkageIdentifiesNothing() + { + $this->assertFalse((new SingleLinkage())->identifies(new ResourceObject('something', '1'))); + } +}