From dc5de3289bab85a899b042c62864f7b568cf2fa8 Mon Sep 17 00:00:00 2001 From: Randall Theuns Date: Sat, 29 Feb 2020 18:16:02 +0100 Subject: [PATCH 1/3] WIP: Add JSON-API renderer --- src/Apitizer/JsonApi/ResourceContainer.php | 40 +++++ src/Apitizer/Rendering/AbstractRenderer.php | 68 +++++++++ src/Apitizer/Rendering/BasicRenderer.php | 68 +-------- src/Apitizer/Rendering/JsonApiRenderer.php | 153 ++++++++++--------- tests/Unit/Rendering/JsonApiRendererTest.php | 84 ++++++++++ 5 files changed, 276 insertions(+), 137 deletions(-) create mode 100644 src/Apitizer/JsonApi/ResourceContainer.php create mode 100644 tests/Unit/Rendering/JsonApiRendererTest.php diff --git a/src/Apitizer/JsonApi/ResourceContainer.php b/src/Apitizer/JsonApi/ResourceContainer.php new file mode 100644 index 0000000..2d78a39 --- /dev/null +++ b/src/Apitizer/JsonApi/ResourceContainer.php @@ -0,0 +1,40 @@ +>|array $data + */ + public function __construct(string $id, string $type, array $data) + { + $this->id = $id; + $this->type = $type; + $this->items = $data; + } + + public function getResourceId(): string + { + return $this->id; + } + + public function getResourceType(): string + { + return $this->type; + } +} diff --git a/src/Apitizer/Rendering/AbstractRenderer.php b/src/Apitizer/Rendering/AbstractRenderer.php index f9268ed..9541b17 100644 --- a/src/Apitizer/Rendering/AbstractRenderer.php +++ b/src/Apitizer/Rendering/AbstractRenderer.php @@ -2,13 +2,81 @@ namespace Apitizer\Rendering; +use Apitizer\QueryBuilder; +use Apitizer\Types\AbstractField; +use Apitizer\Types\Association; use Apitizer\Types\Concerns\FetchesValueFromRow; +use Apitizer\Policies\PolicyFailed; use Illuminate\Support\Arr; abstract class AbstractRenderer { use FetchesValueFromRow; + /** + * @param QueryBuilder $queryBuilder + * @param mixed $data + * @param AbstractField[] $fields + * @param Association[] $associations + * + * @return array|array> + */ + public function doRender( + QueryBuilder $queryBuilder, + $data, + array $fields, + array $associations + ): array { + if ($this->isSingleRowOfData($data)) { + return $this->renderSingleRow($data, $queryBuilder, $fields, $associations); + } else { + return $this->renderMany($data, $queryBuilder, $fields, $associations); + } + } + + /** + * @param mixed $data + * @param QueryBuilder $queryBuilder + * @param AbstractField[] $fields + * @param Association[] $associations + * + * @return array> + */ + public function renderMany( + $data, + QueryBuilder $queryBuilder, + array $fields, + array $associations + ): array { + return collect($data)->map(function ($row) use ($queryBuilder, $fields, $associations) { + return $this->renderSingleRow($row, $queryBuilder, $fields, $associations); + })->all(); + } + + /** + * @param mixed $row + * @param AbstractField $field + * @param array $renderedData + * + * @throws InvalidOutputException if the value does not adhere to the + * requirements set by the field. For example, if the field is not + * nullable but the value is null, this will throw an error. Enum + * field may also throw an error if the value is not in the enum. + */ + protected function addRenderedField( + $row, + AbstractField $field, + array &$renderedData + ): void { + $value = $field->render($row, $this); + + if ($value instanceof PolicyFailed) { + return; + } + + $renderedData[$field->getName()] = $value; + } + /** * Check if we're dealing with a single row of data or a collection of rows. * diff --git a/src/Apitizer/Rendering/BasicRenderer.php b/src/Apitizer/Rendering/BasicRenderer.php index b85c0ea..c627d39 100644 --- a/src/Apitizer/Rendering/BasicRenderer.php +++ b/src/Apitizer/Rendering/BasicRenderer.php @@ -2,12 +2,12 @@ namespace Apitizer\Rendering; +use Apitizer\Exceptions\InvalidOutputException; use Apitizer\Policies\PolicyFailed; use Apitizer\QueryBuilder; -use Apitizer\Types\FetchSpec; use Apitizer\Types\AbstractField; use Apitizer\Types\Association; -use Apitizer\Exceptions\InvalidOutputException; +use Apitizer\Types\FetchSpec; class BasicRenderer extends AbstractRenderer implements Renderer { @@ -20,46 +20,6 @@ public function render(QueryBuilder $queryBuilder, $data, FetchSpec $fetchSpec): ); } - /** - * @param QueryBuilder $queryBuilder - * @param mixed $data - * @param AbstractField[] $fields - * @param Association[] $associations - * - * @return array|array> - */ - protected function doRender( - QueryBuilder $queryBuilder, - $data, - array $fields, - array $associations - ): array { - if ($this->isSingleRowOfData($data)) { - return $this->renderSingleRow($data, $queryBuilder, $fields, $associations); - } else { - return $this->renderMany($data, $queryBuilder, $fields, $associations); - } - } - - /** - * @param mixed $data - * @param QueryBuilder $queryBuilder - * @param AbstractField[] $fields - * @param Association[] $associations - * - * @return array> - */ - protected function renderMany( - $data, - QueryBuilder $queryBuilder, - array $fields, - array $associations - ): array { - return collect($data)->map(function ($row) use ($queryBuilder, $fields, $associations) { - return $this->renderSingleRow($row, $queryBuilder, $fields, $associations); - })->all(); - } - /** * @param mixed $row * @param QueryBuilder $queryBuilder @@ -87,30 +47,6 @@ protected function renderSingleRow( return $renderedData; } - /** - * @param mixed $row - * @param AbstractField $field - * @param array $renderedData - * - * @throws InvalidOutputException if the value does not adhere to the - * requirements set by the field. For example, if the field is not - * nullable but the value is null, this will throw an error. Enum - * field may also throw an error if the value is not in the enum. - */ - protected function addRenderedField( - $row, - AbstractField $field, - array &$renderedData - ): void { - $value = $field->render($row, $this); - - if ($value instanceof PolicyFailed) { - return; - } - - $renderedData[$field->getName()] = $value; - } - /** * @param mixed $row * @param Association $association diff --git a/src/Apitizer/Rendering/JsonApiRenderer.php b/src/Apitizer/Rendering/JsonApiRenderer.php index 8be76b9..0622ef4 100644 --- a/src/Apitizer/Rendering/JsonApiRenderer.php +++ b/src/Apitizer/Rendering/JsonApiRenderer.php @@ -8,79 +8,90 @@ use Apitizer\Policies\PolicyFailed; use Apitizer\Types\AbstractField; use Apitizer\Types\Association; +use Apitizer\Types\FetchSpec; use ArrayAccess; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; use ReflectionClass; -// class JsonApiRenderer extends AbstractRenderer implements Renderer -// { - // public function render(QueryBuilder $queryBuilder, $data, array $selectedFields): array - // { - // // We're only concerned with the happy path in this renderer. If any - // // errors occur, such as an invalid filter, they will be caught - // // beforehand during the processing of those filters. - // $response = []; - - // if ($this->isSingleRowOfData($data)) { - // $response['data'] = $this->renderOne($data, $selectedFields); - // return $response; - // } - - // return []; - // } - - // protected function renderOne(QueryBuilder $queryBuilder, $row, array $selectedFields): array - // { - // $resource = [ - // 'id' => $this->getResourceId($queryBuilder, $row), - // 'type' => $this->getResourceType($queryBuilder, $row), - // ]; - - // return []; - // } - - // protected function getResourceType(QueryBuilder $queryBuilder, $row) - // { - // if ($row instanceof Resource) { - // return $row->getResourceType(); - // } - - // $className = (new ReflectionClass($queryBuilder->model()))->getShortName(); - - // return Str::snake($className); - // } - - // protected function getResourceId(QueryBuilder $queryBuilder, $row): string - // { - // if ($row instanceof Resource) { - // return $row->getResourceId(); - // } - - // if ($row instanceof Model) { - // return (string) $row->getKey(); - // } - - // if (is_array($row) || $row instanceof ArrayAccess) { - // if (isset($row['id'])) { - // return (string) $row['id']; - // } - - // if (isset($row['uuid'])) { - // return (string) $row['uuid']; - // } - // } - - // if (is_object($row)) { - // if (isset($row->{'id'})) { - // return (string) $row->{'id'}; - // } - - // if (isset($row->{'uuid'})) { - // return (string) $row->{'uuid'}; - // } - // } - - // throw InvalidOutputException::noJsonApiIdentifier($queryBuilder, $row); - // } -// } +class JsonApiRenderer extends AbstractRenderer implements Renderer +{ + public function render(QueryBuilder $queryBuilder, $data, FetchSpec $fetchSpec): array + { + return $this->doRender( + $queryBuilder, $data, + $fetchSpec->getFields(), + $fetchSpec->getAssociations() + ); + } + + /** + * @param mixed $row + * @param QueryBuilder $queryBuilder + * @param AbstractField[] $fields + * @param Association[] $associations + * + * @return array + */ + public function renderSingleRow( + $row, + QueryBuilder $queryBuilder, + array $fields, + array $associations + ): array { + $attributes = []; + foreach ($fields as $field) { + $this->addRenderedField($row, $field, $attributes); + } + + return [ + 'id' => $this->getResourceId($queryBuilder, $row), + 'type' => $this->getResourceType($queryBuilder, $row), + 'attributes' => $attributes, + ]; + } + + protected function getResourceType(QueryBuilder $queryBuilder, $row) + { + if ($row instanceof Resource) { + return $row->getResourceType(); + } + + $className = (new ReflectionClass($queryBuilder->model()))->getShortName(); + + return Str::snake($className); + } + + protected function getResourceId(QueryBuilder $queryBuilder, $row): string + { + if ($row instanceof Resource) { + return $row->getResourceId(); + } + + if ($row instanceof Model) { + return (string) $row->getKey(); + } + + if (is_array($row) || $row instanceof ArrayAccess) { + if (isset($row['id'])) { + return (string) $row['id']; + } + + if (isset($row['uuid'])) { + return (string) $row['uuid']; + } + } + + if (is_object($row)) { + if (isset($row->{'id'})) { + return (string) $row->{'id'}; + } + + if (isset($row->{'uuid'})) { + return (string) $row->{'uuid'}; + } + } + + throw InvalidOutputException::noJsonApiIdentifier($queryBuilder, $row); + } +} diff --git a/tests/Unit/Rendering/JsonApiRendererTest.php b/tests/Unit/Rendering/JsonApiRendererTest.php new file mode 100644 index 0000000..784354c --- /dev/null +++ b/tests/Unit/Rendering/JsonApiRendererTest.php @@ -0,0 +1,84 @@ + 'John Doe', + 'email' => 'john.doe@example.com', + ]); + + $rendered = $this->renderOne($user, ['name', 'email']); + + $this->assertEquals([ + 'id' => '1', + 'type' => 'user', + 'attributes' => $attributes, + ], $rendered); + } + + /** @test */ + public function it_renders_many_json_api_resources() + { + $attributes = [ + 'name' => 'John Doe', + 'email' => 'john.doe@example.com', + ]; + $users = []; + $users[] = new ResourceContainer('1', 'user', $attributes); + $users[] = new ResourceContainer('2', 'user', $attributes); + + $rendered = $this->renderMany($users, ['name', 'email']); + + $this->assertEquals( + [ + [ + 'id' => '1', + 'type' => 'user', + 'attributes' => $attributes, + ], + [ + 'id' => '2', + 'type' => 'user', + 'attributes' => $attributes, + ] + ], + $rendered + ); + } + + private function renderMany($resource, array $fields) + { + $renderer = new JsonApiRenderer(); + $builder = new EmptyBuilder(); + $fields = $this->castFields($fields, $builder); + + return $renderer->renderMany($resource, $builder, $fields, []); + } + + private function renderOne($resource, array $fields) + { + $renderer = new JsonApiRenderer(); + $builder = new EmptyBuilder(); + $fields = $this->castFields($fields, $builder); + + return $renderer->renderSingleRow($resource, $builder, $fields, []); + } + + private function castFields(array $fields, $builder) + { + return collect($fields)->map(function (string $field) use ($builder) { + return (new Field($builder, $field, 'string'))->setName($field); + })->all(); + } +} From b2c0bfd33eb0e1c053c1bbaa1c255b5394d8794c Mon Sep 17 00:00:00 2001 From: Daan Hage Date: Mon, 2 Mar 2020 22:06:01 +0100 Subject: [PATCH 2/3] Basic jsonapi rendering --- .gitignore | 3 +- phpstan.neon | 2 + src/Apitizer/ExceptionStrategy/Ignore.php | 3 +- src/Apitizer/ExceptionStrategy/Raise.php | 3 +- .../Exceptions/DefinitionException.php | 16 +- src/Apitizer/JsonApi/Document.php | 57 +++++ src/Apitizer/JsonApi/ResourceContainer.php | 40 --- src/Apitizer/JsonApi/ResourceObject.php | 229 ++++++++++++++++++ src/Apitizer/Parser/Context.php | 3 +- src/Apitizer/QueryBuilder.php | 13 +- src/Apitizer/Rendering/AbstractRenderer.php | 44 +--- src/Apitizer/Rendering/BasicRenderer.php | 58 ++++- src/Apitizer/Rendering/JsonApiRenderer.php | 71 +++--- src/Apitizer/Support/DefinitionHelper.php | 23 +- src/Apitizer/Support/FetchSpecFactory.php | 11 +- src/Apitizer/Types/AbstractField.php | 2 +- .../Types/Concerns/FetchesValueFromRow.php | 2 +- src/Apitizer/Validation/Rules.php | 3 +- .../Commands/ValidateSchemaCommandTest.php | 4 +- tests/Feature/Models/User.php | 26 +- tests/Feature/QueryBuilder/PaginationTest.php | 3 +- tests/Feature/QueryBuilder/SelectTest.php | 2 +- tests/Feature/Rendering/JsonApiTest.php | 59 +++++ tests/Feature/Support/SchemaValidatorTest.php | 4 +- .../database/factories/UserFactory.php | 3 + tests/Unit/Rendering/JsonApiRendererTest.php | 84 ------- tests/Unit/Types/ApidocTest.php | 8 +- tests/Unit/Validation/RulesTest.php | 9 +- tests/assets/jsonapi/compount_document.json | 92 +++++++ tests/assets/jsonapi/multiple_users.json | 20 ++ tests/assets/jsonapi/simple_user.json | 12 + 31 files changed, 670 insertions(+), 239 deletions(-) create mode 100644 src/Apitizer/JsonApi/Document.php delete mode 100644 src/Apitizer/JsonApi/ResourceContainer.php create mode 100644 src/Apitizer/JsonApi/ResourceObject.php create mode 100644 tests/Feature/Rendering/JsonApiTest.php delete mode 100644 tests/Unit/Rendering/JsonApiRendererTest.php create mode 100644 tests/assets/jsonapi/compount_document.json create mode 100644 tests/assets/jsonapi/multiple_users.json create mode 100644 tests/assets/jsonapi/simple_user.json diff --git a/.gitignore b/.gitignore index e6346eb..04b7de6 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ Homestead.yaml Homestead.json /.vagrant .phpunit.result.cache -coverage/ \ No newline at end of file +coverage/ +/.php_cs.cache diff --git a/phpstan.neon b/phpstan.neon index 699c359..af0430b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -10,3 +10,5 @@ parameters: # This class does string-building with class-strings, which is more hassle # to type-hint and annotate than it's worth. - src/Apitizer/Support/ClassFilter.php + + checkMissingIterableValueType: false diff --git a/src/Apitizer/ExceptionStrategy/Ignore.php b/src/Apitizer/ExceptionStrategy/Ignore.php index fffa278..2d4e9d9 100644 --- a/src/Apitizer/ExceptionStrategy/Ignore.php +++ b/src/Apitizer/ExceptionStrategy/Ignore.php @@ -10,8 +10,7 @@ class Ignore implements Strategy public function handle( QueryBuilder $queryBuilder, ApitizerException $apitizerException - ): void - { + ): void { // } } diff --git a/src/Apitizer/ExceptionStrategy/Raise.php b/src/Apitizer/ExceptionStrategy/Raise.php index 1eca945..3501513 100644 --- a/src/Apitizer/ExceptionStrategy/Raise.php +++ b/src/Apitizer/ExceptionStrategy/Raise.php @@ -10,8 +10,7 @@ class Raise implements Strategy public function handle( QueryBuilder $queryBuilder, ApitizerException $apitizerException - ): void - { + ): void { throw $apitizerException; } } diff --git a/src/Apitizer/Exceptions/DefinitionException.php b/src/Apitizer/Exceptions/DefinitionException.php index 630e672..7d64610 100644 --- a/src/Apitizer/Exceptions/DefinitionException.php +++ b/src/Apitizer/Exceptions/DefinitionException.php @@ -47,7 +47,7 @@ public function __construct( * @param string $key * @param mixed $given */ - static function builderClassExpected(QueryBuilder $queryBuilder, string $key, $given): self + public static function builderClassExpected(QueryBuilder $queryBuilder, string $key, $given): self { $class = get_class($queryBuilder); $message = "Expected association by [$key] on [$class] to be a " @@ -56,7 +56,7 @@ static function builderClassExpected(QueryBuilder $queryBuilder, string $key, $g return new static($message, $queryBuilder, 'association'); } - static function associationDoesNotExist(QueryBuilder $queryBuilder, Association $associaton): self + public static function associationDoesNotExist(QueryBuilder $queryBuilder, Association $associaton): self { $name = $associaton->getName(); $class = get_class($queryBuilder); @@ -74,7 +74,7 @@ static function associationDoesNotExist(QueryBuilder $queryBuilder, Association * @param string $name * @param mixed $given */ - static function fieldDefinitionExpected(QueryBuilder $queryBuilder, string $name, $given): self + public static function fieldDefinitionExpected(QueryBuilder $queryBuilder, string $name, $given): self { $class = get_class($queryBuilder); $type = is_object($given) ? get_class($given) : gettype($given); @@ -89,7 +89,7 @@ static function fieldDefinitionExpected(QueryBuilder $queryBuilder, string $name * @param string $name * @param mixed $given */ - static function associationDefinitionExpected( + public static function associationDefinitionExpected( QueryBuilder $queryBuilder, string $name, $given @@ -107,7 +107,7 @@ static function associationDefinitionExpected( * @param string $name * @param mixed $given */ - static function filterDefinitionExpected(QueryBuilder $queryBuilder, string $name, $given): self + public static function filterDefinitionExpected(QueryBuilder $queryBuilder, string $name, $given): self { $class = get_class($queryBuilder); $type = is_object($given) ? get_class($given) : gettype($given); @@ -117,7 +117,7 @@ static function filterDefinitionExpected(QueryBuilder $queryBuilder, string $nam return new static($message, $queryBuilder, 'filter', $name); } - static function filterHandlerNotDefined(QueryBuilder $queryBuilder, Filter $filter): self + public static function filterHandlerNotDefined(QueryBuilder $queryBuilder, Filter $filter): self { $class = get_class($queryBuilder); $name = $filter->getName(); @@ -131,7 +131,7 @@ static function filterHandlerNotDefined(QueryBuilder $queryBuilder, Filter $filt * @param string $name * @param mixed $given */ - static function sortDefinitionExpected(QueryBuilder $queryBuilder, string $name, $given): self + public static function sortDefinitionExpected(QueryBuilder $queryBuilder, string $name, $given): self { $class = get_class($queryBuilder); $type = is_object($given) ? get_class($given) : gettype($given); @@ -141,7 +141,7 @@ static function sortDefinitionExpected(QueryBuilder $queryBuilder, string $name, return new static($message, $queryBuilder, 'sort', $name); } - static function sortHandlerNotDefined(QueryBuilder $queryBuilder, string $name): self + public static function sortHandlerNotDefined(QueryBuilder $queryBuilder, string $name): self { $class = get_class($queryBuilder); $message = "Expected a callable handler to be defined for sort [$name] on [$class]"; diff --git a/src/Apitizer/JsonApi/Document.php b/src/Apitizer/JsonApi/Document.php new file mode 100644 index 0000000..6a22091 --- /dev/null +++ b/src/Apitizer/JsonApi/Document.php @@ -0,0 +1,57 @@ +resources[] = $object; + } + + // /** + // * @inheritDoc + // */ + // public function setPaginationLinks($previousHref=null, $nextHref=null, $firstHref=null, $lastHref=null) { + // if ($previousHref !== null) { + // $this->addLink('prev', $previousHref); + // } + // if ($nextHref !== null) { + // $this->addLink('next', $nextHref); + // } + // if ($firstHref !== null) { + // $this->addLink('first', $firstHref); + // } + // if ($lastHref !== null) { + // $this->addLink('last', $lastHref); + // } + // } + + public function toArray(): array + { + $data = [ + 'data' => [] + ]; + + foreach ($this->resources as $resource) { + $data['data'][] = $resource->toArray(); + } + + return $data; + } +} diff --git a/src/Apitizer/JsonApi/ResourceContainer.php b/src/Apitizer/JsonApi/ResourceContainer.php deleted file mode 100644 index 2d78a39..0000000 --- a/src/Apitizer/JsonApi/ResourceContainer.php +++ /dev/null @@ -1,40 +0,0 @@ ->|array $data - */ - public function __construct(string $id, string $type, array $data) - { - $this->id = $id; - $this->type = $type; - $this->items = $data; - } - - public function getResourceId(): string - { - return $this->id; - } - - public function getResourceType(): string - { - return $this->type; - } -} diff --git a/src/Apitizer/JsonApi/ResourceObject.php b/src/Apitizer/JsonApi/ResourceObject.php new file mode 100644 index 0000000..211e4e0 --- /dev/null +++ b/src/Apitizer/JsonApi/ResourceObject.php @@ -0,0 +1,229 @@ +type = $type; + $this->id = $id; + } + + /** + * @param array $attributes + * @param string $type + * @param string $id + * @return ResourceObject + */ + public static function factory(array $attributes, string $type, string $id) + { + $resourceObject = new self($type, $id); + $resourceObject->attributes = $attributes; + + return $resourceObject; + } + + public function toArray(): array + { + $array = []; + $array['type'] = $this->type; + $array['id'] = $this->id; + if (! empty($this->attributes)) { + $array['attributes'] = $this->attributes; + } + + // if ($this->meta !== null && $this->meta->isEmpty() === false) { + // $array['meta'] = $this->meta->toArray(); + // } + // if ($this->relationships !== null && $this->relationships->isEmpty() === false) { + // $array['relationships'] = $this->relationships->toArray(); + // } + // if ($this->links !== null && $this->links->isEmpty() === false) { + // $array['links'] = $this->links->toArray(); + // } + + return $array; + } + + + // /** + // * @param object $attributes + // * @param string $type optional + // * @param string|int $id optional + // * @param array $options optional {@see ResourceObject::$defaults} + // * @return ResourceObject + // */ + // public static function fromObject($attributes, $type=null, $id=null, array $options=[]) { + // $array = Converter::objectToArray($attributes); + + // return self::fromArray($array, $type, $id, $options); + // } + + // /** + // * add key-value pairs to attributes + // * + // * @param string $key + // * @param mixed $value + // * @param array $options optional {@see ResourceObject::$defaults} + // */ + // public function add($key, $value, array $options=[]) { + // $options = array_merge(self::$defaults, $options); + + // if ($this->attributes === null) { + // $this->attributes = new AttributesObject(); + // } + + // $this->validator->claimUsedFields([$key], Validator::OBJECT_CONTAINER_ATTRIBUTES, $options); + + // $this->attributes->add($key, $value); + // } + + // /** + // * @param string $key + // * @param mixed $relation ResourceInterface | ResourceInterface[] | CollectionDocument + // * @param array $links optional + // * @param array $meta optional + // * @param array $options optional {@see ResourceObject::$defaults} + // * @return RelationshipObject + // */ + // public function addRelationship($key, $relation, array $links=[], array $meta=[], array $options=[]) { + // $relationshipObject = RelationshipObject::fromAnything($relation, $links, $meta); + + // $this->addRelationshipObject($key, $relationshipObject, $options); + + // return $relationshipObject; + // } + + // /** + // * @param string $href + // * @param array $meta optional, if given a LinkObject is added, otherwise a link string is added + // */ + // public function setSelfLink($href, array $meta=[]) { + // $this->addLink('self', $href, $meta); + // } + + + // /** + // * @param string $key + // * @param RelationshipObject $relationshipObject + // * @param array $options optional {@see ResourceObject::$defaults} + // * + // * @throws DuplicateException if the resource is contained as a resource in the relationship + // */ + // public function addRelationshipObject($key, RelationshipObject $relationshipObject, array $options=[]) { + // if ($relationshipObject->hasResource($this)) { + // throw new DuplicateException('can not add relation to self'); + // } + + // if ($this->relationships === null) { + // $this->setRelationshipsObject(new RelationshipsObject()); + // } + + // $this->validator->claimUsedFields([$key], Validator::OBJECT_CONTAINER_RELATIONSHIPS, $options); + + // $this->relationships->addRelationshipObject($key, $relationshipObject); + // } + + // /** + // * @param RelationshipsObject $relationshipsObject + // */ + // public function setRelationshipsObject(RelationshipsObject $relationshipsObject) { + // $newKeys = $relationshipsObject->getKeys(); + // $this->validator->clearUsedFields(Validator::OBJECT_CONTAINER_RELATIONSHIPS); + // $this->validator->claimUsedFields($newKeys, Validator::OBJECT_CONTAINER_RELATIONSHIPS); + + // $this->relationships = $relationshipsObject; + // } + + // /** + // * internal api + // */ + + // /** + // * whether the ResourceObject is empty except for the ResourceIdentifierObject + // * + // * this can be used to determine if a Relationship's resource could be added as included resource + // * + // * @internal + // * + // * @return boolean + // */ + // public function hasIdentifierPropertiesOnly() { + // if ($this->attributes !== null && $this->attributes->isEmpty() === false) { + // return false; + // } + // if ($this->relationships !== null && $this->relationships->isEmpty() === false) { + // return false; + // } + // if ($this->links !== null && $this->links->isEmpty() === false) { + // return false; + // } + + // return true; + // } + + // /** + // * ResourceInterface + // */ + + // /** + // * @inheritDoc + // */ + // public function getResource($identifierOnly=false) { + // if ($identifierOnly) { + // return ResourceIdentifierObject::fromResourceObject($this); + // } + + // return $this; + // } + + // /** + // * ObjectInterface + // */ + + // /** + // * @inheritDoc + // */ + // public function isEmpty() { + // if (parent::isEmpty() === false) { + // return false; + // } + // if ($this->attributes !== null && $this->attributes->isEmpty() === false) { + // return false; + // } + // if ($this->relationships !== null && $this->relationships->isEmpty() === false) { + // return false; + // } + // if ($this->links !== null && $this->links->isEmpty() === false) { + // return false; + // } + + // return true; + // } + + + // /** + // * @inheritDoc + // */ + // public function getNestedContainedResourceObjects() { + // if ($this->relationships === null) { + // return []; + // } + + // return $this->relationships->getNestedContainedResourceObjects(); + // } +} diff --git a/src/Apitizer/Parser/Context.php b/src/Apitizer/Parser/Context.php index 6918c00..106f679 100644 --- a/src/Apitizer/Parser/Context.php +++ b/src/Apitizer/Parser/Context.php @@ -46,7 +46,8 @@ class Context /** * @param Relation|ParsedInput $stack */ - public function __construct($stack, Context $parent = null) { + public function __construct($stack, Context $parent = null) + { $this->stack = $stack; $this->parent = $parent; } diff --git a/src/Apitizer/QueryBuilder.php b/src/Apitizer/QueryBuilder.php index dcec702..34367fb 100644 --- a/src/Apitizer/QueryBuilder.php +++ b/src/Apitizer/QueryBuilder.php @@ -240,7 +240,8 @@ public function afterQuery(Builder $query, FetchSpec $fetchSpec): Builder return $query; } - public function __construct(Request $request = null) { + public function __construct(Request $request = null) + { $this->setRequest($request); } @@ -382,7 +383,9 @@ public function paginate(int $perPage = null, $pageName = 'page', $page = null): return tap($paginator, function (AbstractPaginator $paginator) use ($fetchSpec) { $renderedData = $this->getRenderer()->render( - $this, $paginator->getCollection(), $fetchSpec + $this, + $paginator->getCollection(), + $fetchSpec ); $paginator->setCollection(collect($renderedData)); @@ -474,7 +477,8 @@ protected function makeFetchSpecification(): FetchSpec : RawInput::fromArray($this->specification); return FetchSpecFactory::fromRequestInput( - $this->getParser()->parse($rawInput), $this + $this->getParser()->parse($rawInput), + $this ); } @@ -497,7 +501,8 @@ public function getAssociations(): array { if (is_null($this->availableAssociations)) { $this->availableAssociations = DefinitionHelper::validateAssociations( - $this, $this->associations() + $this, + $this->associations() ); } diff --git a/src/Apitizer/Rendering/AbstractRenderer.php b/src/Apitizer/Rendering/AbstractRenderer.php index 9541b17..655da84 100644 --- a/src/Apitizer/Rendering/AbstractRenderer.php +++ b/src/Apitizer/Rendering/AbstractRenderer.php @@ -2,11 +2,13 @@ namespace Apitizer\Rendering; +use Apitizer\Exceptions\InvalidOutputException; +use Apitizer\Policies\PolicyFailed; use Apitizer\QueryBuilder; use Apitizer\Types\AbstractField; use Apitizer\Types\Association; use Apitizer\Types\Concerns\FetchesValueFromRow; -use Apitizer\Policies\PolicyFailed; +use Apitizer\Types\FetchSpec; use Illuminate\Support\Arr; abstract class AbstractRenderer @@ -16,42 +18,10 @@ abstract class AbstractRenderer /** * @param QueryBuilder $queryBuilder * @param mixed $data - * @param AbstractField[] $fields - * @param Association[] $associations - * - * @return array|array> - */ - public function doRender( - QueryBuilder $queryBuilder, - $data, - array $fields, - array $associations - ): array { - if ($this->isSingleRowOfData($data)) { - return $this->renderSingleRow($data, $queryBuilder, $fields, $associations); - } else { - return $this->renderMany($data, $queryBuilder, $fields, $associations); - } - } - - /** - * @param mixed $data - * @param QueryBuilder $queryBuilder - * @param AbstractField[] $fields - * @param Association[] $associations - * - * @return array> + * @param FetchSpec $fetchSpec + * @return array */ - public function renderMany( - $data, - QueryBuilder $queryBuilder, - array $fields, - array $associations - ): array { - return collect($data)->map(function ($row) use ($queryBuilder, $fields, $associations) { - return $this->renderSingleRow($row, $queryBuilder, $fields, $associations); - })->all(); - } + abstract public function render(QueryBuilder $queryBuilder, $data, FetchSpec $fetchSpec): array; /** * @param mixed $row @@ -68,7 +38,7 @@ protected function addRenderedField( AbstractField $field, array &$renderedData ): void { - $value = $field->render($row, $this); + $value = $field->render($row); if ($value instanceof PolicyFailed) { return; diff --git a/src/Apitizer/Rendering/BasicRenderer.php b/src/Apitizer/Rendering/BasicRenderer.php index c627d39..9adb526 100644 --- a/src/Apitizer/Rendering/BasicRenderer.php +++ b/src/Apitizer/Rendering/BasicRenderer.php @@ -11,15 +11,63 @@ class BasicRenderer extends AbstractRenderer implements Renderer { + + /** + * @param QueryBuilder $queryBuilder + * @param mixed $data + * @param FetchSpec $fetchSpec + * @return array + */ public function render(QueryBuilder $queryBuilder, $data, FetchSpec $fetchSpec): array { return $this->doRender( - $queryBuilder, $data, + $queryBuilder, + $data, $fetchSpec->getFields(), $fetchSpec->getAssociations() ); } + /** + * @param QueryBuilder $queryBuilder + * @param mixed $data + * @param AbstractField[] $fields + * @param Association[] $associations + * + * @return array|array> + */ + public function doRender( + QueryBuilder $queryBuilder, + $data, + array $fields, + array $associations + ): array { + if ($this->isSingleRowOfData($data)) { + return $this->renderSingleRow($data, $queryBuilder, $fields, $associations); + } else { + return $this->renderMany($data, $queryBuilder, $fields, $associations); + } + } + + /** + * @param mixed $data + * @param QueryBuilder $queryBuilder + * @param AbstractField[] $fields + * @param Association[] $associations + * + * @return array> + */ + public function renderMany( + $data, + QueryBuilder $queryBuilder, + array $fields, + array $associations + ): array { + return collect($data)->map(function ($row) use ($queryBuilder, $fields, $associations) { + return $this->renderSingleRow($row, $queryBuilder, $fields, $associations); + })->all(); + } + /** * @param mixed $row * @param QueryBuilder $queryBuilder @@ -56,7 +104,7 @@ protected function addRenderedAssociation( $row, Association $association, array &$renderedData - ): void{ + ): void { $associationData = $this->valueFromRow($row, $association->getKey()); if (! $association->passesPolicy($associationData, $row)) { @@ -64,8 +112,10 @@ protected function addRenderedAssociation( } $renderedData[$association->getName()] = $this->doRender( - $association->getRelatedQueryBuilder(), $associationData, - $association->getFields() ?? [], $association->getAssociations() ?? [] + $association->getRelatedQueryBuilder(), + $associationData, + $association->getFields() ?? [], + $association->getAssociations() ?? [] ); } } diff --git a/src/Apitizer/Rendering/JsonApiRenderer.php b/src/Apitizer/Rendering/JsonApiRenderer.php index 0622ef4..d689ca6 100644 --- a/src/Apitizer/Rendering/JsonApiRenderer.php +++ b/src/Apitizer/Rendering/JsonApiRenderer.php @@ -3,9 +3,10 @@ namespace Apitizer\Rendering; use Apitizer\Exceptions\InvalidOutputException; +use Apitizer\JsonApi\Document; use Apitizer\JsonApi\Resource; -use Apitizer\QueryBuilder; use Apitizer\Policies\PolicyFailed; +use Apitizer\QueryBuilder; use Apitizer\Types\AbstractField; use Apitizer\Types\Association; use Apitizer\Types\FetchSpec; @@ -16,42 +17,48 @@ class JsonApiRenderer extends AbstractRenderer implements Renderer { - public function render(QueryBuilder $queryBuilder, $data, FetchSpec $fetchSpec): array + /** @var Document */ + protected $document; + + public function __construct() { - return $this->doRender( - $queryBuilder, $data, - $fetchSpec->getFields(), - $fetchSpec->getAssociations() - ); + $this->document = new Document(); } /** - * @param mixed $row - * @param QueryBuilder $queryBuilder - * @param AbstractField[] $fields - * @param Association[] $associations - * - * @return array - */ - public function renderSingleRow( - $row, - QueryBuilder $queryBuilder, - array $fields, - array $associations - ): array { + * @param QueryBuilder $queryBuilder + * @param mixed $data + * @param FetchSpec $fetchSpec + * @return array + */ + public function render(QueryBuilder $queryBuilder, $data, FetchSpec $fetchSpec): array + { $attributes = []; - foreach ($fields as $field) { - $this->addRenderedField($row, $field, $attributes); + if ($this->isSingleRowOfData($data)) { + $data = collect([$data]); } - return [ - 'id' => $this->getResourceId($queryBuilder, $row), - 'type' => $this->getResourceType($queryBuilder, $row), - 'attributes' => $attributes, - ]; + foreach ($data as $row) { + foreach ($fetchSpec->getFields() as $field) { + $this->addRenderedField($row, $field, $attributes); + } + + $this->document->addResource( + $this->getResourceType($queryBuilder, $row), + $this->getResourceId($queryBuilder, $row), + $attributes, + ); + } + + return $this->document->toArray(); } - protected function getResourceType(QueryBuilder $queryBuilder, $row) + /** + * @param QueryBuilder $queryBuilder + * @param mixed $row + * @return string + */ + protected function getResourceType(QueryBuilder $queryBuilder, $row): string { if ($row instanceof Resource) { return $row->getResourceType(); @@ -59,9 +66,15 @@ protected function getResourceType(QueryBuilder $queryBuilder, $row) $className = (new ReflectionClass($queryBuilder->model()))->getShortName(); - return Str::snake($className); + return Str::snake(Str::plural($className)); } + /** + * @throws InvalidOutputException + * @param QueryBuilder $queryBuilder + * @param mixed $row + * @return string + */ protected function getResourceId(QueryBuilder $queryBuilder, $row): string { if ($row instanceof Resource) { diff --git a/src/Apitizer/Support/DefinitionHelper.php b/src/Apitizer/Support/DefinitionHelper.php index 75b33ab..ccc0a93 100644 --- a/src/Apitizer/Support/DefinitionHelper.php +++ b/src/Apitizer/Support/DefinitionHelper.php @@ -24,7 +24,7 @@ class DefinitionHelper * * @return array */ - static function validateFields(QueryBuilder $queryBuilder, array $fields): array + public static function validateFields(QueryBuilder $queryBuilder, array $fields): array { $castFields = []; @@ -44,7 +44,7 @@ static function validateFields(QueryBuilder $queryBuilder, array $fields): array * * @return AbstractField */ - static function validateField(QueryBuilder $queryBuilder, string $name, $field) + public static function validateField(QueryBuilder $queryBuilder, string $name, $field) { if (is_string($field)) { $field = new Field($queryBuilder, $field, 'any'); @@ -65,7 +65,7 @@ static function validateField(QueryBuilder $queryBuilder, string $name, $field) * * @return array */ - static function validateAssociations(QueryBuilder $queryBuilder, array $associations): array + public static function validateAssociations(QueryBuilder $queryBuilder, array $associations): array { $castFields = []; @@ -81,14 +81,16 @@ static function validateAssociations(QueryBuilder $queryBuilder, array $associat * @param string $name * @param Association|mixed $association */ - static function validateAssociation( + public static function validateAssociation( QueryBuilder $queryBuilder, string $name, $association ): Association { if (! $association instanceof Association) { throw DefinitionException::associationDefinitionExpected( - $queryBuilder, $name, $association + $queryBuilder, + $name, + $association ); } @@ -104,8 +106,7 @@ static function validateAssociation( private static function isValidAssociation( QueryBuilder $queryBuilder, Association $association - ): bool - { + ): bool { $key = $association->getKey(); $model = $queryBuilder->model(); @@ -120,7 +121,7 @@ private static function isValidAssociation( * * @return array */ - static function validateSorts(QueryBuilder $queryBuilder, array $sorts): array + public static function validateSorts(QueryBuilder $queryBuilder, array $sorts): array { foreach ($sorts as $name => $sort) { static::validateSort($queryBuilder, $name, $sort); @@ -134,7 +135,7 @@ static function validateSorts(QueryBuilder $queryBuilder, array $sorts): array * @param string $name * @param Sort|mixed $sort */ - static function validateSort(QueryBuilder $queryBuilder, string $name, $sort): void + public static function validateSort(QueryBuilder $queryBuilder, string $name, $sort): void { if (! $sort instanceof Sort) { throw DefinitionException::sortDefinitionExpected($queryBuilder, $name, $sort); @@ -155,7 +156,7 @@ static function validateSort(QueryBuilder $queryBuilder, string $name, $sort): v * * @return array */ - static function validateFilters(QueryBuilder $queryBuilder, array $filters): array + public static function validateFilters(QueryBuilder $queryBuilder, array $filters): array { foreach ($filters as $name => $filter) { static::validateFilter($queryBuilder, $name, $filter); @@ -171,7 +172,7 @@ static function validateFilters(QueryBuilder $queryBuilder, array $filters): arr * * @throws DefinitionException */ - static function validateFilter(QueryBuilder $queryBuilder, string $name, $filter): void + public static function validateFilter(QueryBuilder $queryBuilder, string $name, $filter): void { if (! $filter instanceof Filter) { throw DefinitionException::filterDefinitionExpected($queryBuilder, $name, $filter); diff --git a/src/Apitizer/Support/FetchSpecFactory.php b/src/Apitizer/Support/FetchSpecFactory.php index 230d3dc..58fa20f 100644 --- a/src/Apitizer/Support/FetchSpecFactory.php +++ b/src/Apitizer/Support/FetchSpecFactory.php @@ -44,7 +44,9 @@ public function make(): FetchSpec { $fields = $this->selectedFields($this->queryBuilder, $this->input->fields); $associations = $this->selectedAssociations( - $this->queryBuilder, $this->input->associations, $this->input->fields + $this->queryBuilder, + $this->input->associations, + $this->input->fields ); // If nothing was (correct) was selected, return all the default fields @@ -107,7 +109,9 @@ protected function selectedAssociations( ); $association->setAssociations( $this->selectedAssociations( - $relatedBuilder, $relation->associations, $relation->fields + $relatedBuilder, + $relation->associations, + $relation->fields ) ); @@ -168,7 +172,8 @@ protected function selectedFilters(): array } } catch (InvalidInputException $e) { $this->queryBuilder->getExceptionStrategy()->handle( - $this->queryBuilder, $e + $this->queryBuilder, + $e ); } } diff --git a/src/Apitizer/Types/AbstractField.php b/src/Apitizer/Types/AbstractField.php index 682477d..ea3a89f 100644 --- a/src/Apitizer/Types/AbstractField.php +++ b/src/Apitizer/Types/AbstractField.php @@ -92,7 +92,7 @@ public function nullable(bool $isNullable = true): self * * @return mixed the transformed value. */ - public function render($row, Renderer $renderer = null) + public function render($row) { $value = $this->validateValue($this->getValue($row), $row); diff --git a/src/Apitizer/Types/Concerns/FetchesValueFromRow.php b/src/Apitizer/Types/Concerns/FetchesValueFromRow.php index 0113d85..956877d 100644 --- a/src/Apitizer/Types/Concerns/FetchesValueFromRow.php +++ b/src/Apitizer/Types/Concerns/FetchesValueFromRow.php @@ -19,7 +19,7 @@ protected function valueFromRow($row, string $key) if ($row instanceof ArrayAccess || is_array($row)) { $value = $row[$key]; - } else if (is_object($row)) { + } elseif (is_object($row)) { $value = $row->{$key}; } diff --git a/src/Apitizer/Validation/Rules.php b/src/Apitizer/Validation/Rules.php index 31dcce9..b851f6b 100644 --- a/src/Apitizer/Validation/Rules.php +++ b/src/Apitizer/Validation/Rules.php @@ -120,7 +120,8 @@ public function hasRulesFor(string $actionMethod): bool protected function resolveRulesFor(string $actionMethod): ObjectRules { if (! $this->hasRulesFor($actionMethod)) { - return new ObjectRules(null, function () {}); + return new ObjectRules(null, function () { + }); } $object = $this->rules[$actionMethod]; diff --git a/tests/Feature/Commands/ValidateSchemaCommandTest.php b/tests/Feature/Commands/ValidateSchemaCommandTest.php index 7dc46b1..32f2502 100644 --- a/tests/Feature/Commands/ValidateSchemaCommandTest.php +++ b/tests/Feature/Commands/ValidateSchemaCommandTest.php @@ -98,7 +98,9 @@ public function it_should_list_any_unexpected_exceptions_that_occurred() } } -class NotABuilder{} +class NotABuilder +{ +} class AssociationDoesNotExist extends EmptyBuilder { public function associations(): array diff --git a/tests/Feature/Models/User.php b/tests/Feature/Models/User.php index 27cdb00..b2ef5af 100644 --- a/tests/Feature/Models/User.php +++ b/tests/Feature/Models/User.php @@ -2,12 +2,36 @@ namespace Tests\Feature\Models; +use Apitizer\JsonApi\Resource; use Illuminate\Database\Eloquent\Model; -class User extends Model +class User extends Model implements Resource { use HasUuid; + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected $fillable = ['name', 'email']; + + /** + * Get the value that should be used for the "type" field. + */ + public function getResourceType(): string + { + return 'users'; + } + + /** + * Get the value that should be used for the "id" field. + */ + public function getResourceId(): string + { + return $this->id; + } + public function posts() { return $this->hasMany(Post::class, 'author_id'); diff --git a/tests/Feature/QueryBuilder/PaginationTest.php b/tests/Feature/QueryBuilder/PaginationTest.php index e909926..a20a69e 100644 --- a/tests/Feature/QueryBuilder/PaginationTest.php +++ b/tests/Feature/QueryBuilder/PaginationTest.php @@ -42,7 +42,8 @@ public function pagination_links_contain_all_supported_query_parameters() $this->paginatorLinkContainsString($paginator, 'limit=1'); } - private function paginatorLinkContainsString(LengthAwarePaginator $paginator, string $string) { + private function paginatorLinkContainsString(LengthAwarePaginator $paginator, string $string) + { $this->assertStringContainsStringIgnoringCase($string, urldecode($paginator->nextPageUrl())); $this->assertStringContainsStringIgnoringCase($string, urldecode($paginator->url(1))); } diff --git a/tests/Feature/QueryBuilder/SelectTest.php b/tests/Feature/QueryBuilder/SelectTest.php index 88398da..c6857d8 100644 --- a/tests/Feature/QueryBuilder/SelectTest.php +++ b/tests/Feature/QueryBuilder/SelectTest.php @@ -12,7 +12,7 @@ class SelectTest extends TestCase { - /** @test */ + /** @test */ public function it_can_select_the_specified_fields() { $post = factory(Post::class)->create(); diff --git a/tests/Feature/Rendering/JsonApiTest.php b/tests/Feature/Rendering/JsonApiTest.php new file mode 100644 index 0000000..532918b --- /dev/null +++ b/tests/Feature/Rendering/JsonApiTest.php @@ -0,0 +1,59 @@ + 1], [ + 'name' => 'Daan Hage', + 'email' => 'daan@atabix.nl', + ]); + + $request = $this->request()->fields('name, email')->make(); + $result = UserBuilder::make($request)->setRenderer(new JsonApiRenderer)->render($user); + + $this->assertJsonStringEqualsJsonFile( + 'tests/assets/jsonapi/simple_user.json', + json_encode($result, JSON_PRETTY_PRINT) + ); + } + + /** @test */ + public function it_renders_multiple_eloquent_users_to_jsonapi_format() + { + // need to do this because DB unreliable since it doesn't always delete entire DB before test + $user = User::updateOrCreate(['id' => 1], [ + 'name' => 'Daan Hage', + 'email' => 'daan@atabix.nl', + ]); + $user2 = User::updateOrCreate(['id' => 2], [ + 'name' => 'Randall Theuns', + 'email' => 'randall@atabix.nl', + ]); + + $collection = collect([$user, $user2]); + + $request = $this->request()->fields('name, email')->make(); + $result = UserBuilder::make($request)->setRenderer(new JsonApiRenderer)->render($collection); + + $this->assertJsonStringEqualsJsonFile( + 'tests/assets/jsonapi/multiple_users.json', + json_encode($result, JSON_PRETTY_PRINT) + ); + } +} diff --git a/tests/Feature/Support/SchemaValidatorTest.php b/tests/Feature/Support/SchemaValidatorTest.php index 277b647..6e79efc 100644 --- a/tests/Feature/Support/SchemaValidatorTest.php +++ b/tests/Feature/Support/SchemaValidatorTest.php @@ -64,7 +64,9 @@ private function assertHasErrors(QueryBuilder $queryBuilder, $count = 1) } } -class NotABuilder {} +class NotABuilder +{ +} class BuilderClassExpected extends EmptyBuilder { public function fields(): array diff --git a/tests/Feature/database/factories/UserFactory.php b/tests/Feature/database/factories/UserFactory.php index b008ae6..fc68110 100644 --- a/tests/Feature/database/factories/UserFactory.php +++ b/tests/Feature/database/factories/UserFactory.php @@ -8,6 +8,9 @@ return [ 'name' => $faker->name, 'email' => $faker->email, + 'should_reset_password' => rand(0, 1), + 'created_at' => $faker->dateTime('now'), + 'updated_at' => $faker->dateTime('now'), ]; }); diff --git a/tests/Unit/Rendering/JsonApiRendererTest.php b/tests/Unit/Rendering/JsonApiRendererTest.php deleted file mode 100644 index 784354c..0000000 --- a/tests/Unit/Rendering/JsonApiRendererTest.php +++ /dev/null @@ -1,84 +0,0 @@ - 'John Doe', - 'email' => 'john.doe@example.com', - ]); - - $rendered = $this->renderOne($user, ['name', 'email']); - - $this->assertEquals([ - 'id' => '1', - 'type' => 'user', - 'attributes' => $attributes, - ], $rendered); - } - - /** @test */ - public function it_renders_many_json_api_resources() - { - $attributes = [ - 'name' => 'John Doe', - 'email' => 'john.doe@example.com', - ]; - $users = []; - $users[] = new ResourceContainer('1', 'user', $attributes); - $users[] = new ResourceContainer('2', 'user', $attributes); - - $rendered = $this->renderMany($users, ['name', 'email']); - - $this->assertEquals( - [ - [ - 'id' => '1', - 'type' => 'user', - 'attributes' => $attributes, - ], - [ - 'id' => '2', - 'type' => 'user', - 'attributes' => $attributes, - ] - ], - $rendered - ); - } - - private function renderMany($resource, array $fields) - { - $renderer = new JsonApiRenderer(); - $builder = new EmptyBuilder(); - $fields = $this->castFields($fields, $builder); - - return $renderer->renderMany($resource, $builder, $fields, []); - } - - private function renderOne($resource, array $fields) - { - $renderer = new JsonApiRenderer(); - $builder = new EmptyBuilder(); - $fields = $this->castFields($fields, $builder); - - return $renderer->renderSingleRow($resource, $builder, $fields, []); - } - - private function castFields(array $fields, $builder) - { - return collect($fields)->map(function (string $field) use ($builder) { - return (new Field($builder, $field, 'string'))->setName($field); - })->all(); - } -} diff --git a/tests/Unit/Types/ApidocTest.php b/tests/Unit/Types/ApidocTest.php index 26aa764..52e5d61 100644 --- a/tests/Unit/Types/ApidocTest.php +++ b/tests/Unit/Types/ApidocTest.php @@ -45,5 +45,9 @@ public function arbitrary_metadata_can_be_attached_to_the_documenation() } // Used as examples for name guessing. -class UserQueryBuilder extends EmptyBuilder {} -class NonBuilderName extends EmptyBuilder {} +class UserQueryBuilder extends EmptyBuilder +{ +} +class NonBuilderName extends EmptyBuilder +{ +} diff --git a/tests/Unit/Validation/RulesTest.php b/tests/Unit/Validation/RulesTest.php index 616a9cd..6ba5141 100644 --- a/tests/Unit/Validation/RulesTest.php +++ b/tests/Unit/Validation/RulesTest.php @@ -35,8 +35,10 @@ public function it_returns_an_empty_object_when_none_is_defined() public function it_resolves_all_builders_when_all_rules_are_requested() { $rules = new Rules(); - $rules->storeRules(function (ObjectRules $builder) {}); - $rules->updateRules(function (ObjectRules $builder) {}); + $rules->storeRules(function (ObjectRules $builder) { + }); + $rules->updateRules(function (ObjectRules $builder) { + }); $rules = $rules->getValidationRules(); @@ -51,7 +53,8 @@ public function it_resolves_all_builders_when_all_rules_are_requested() public function it_resolves_a_single_builder_when_only_one_is_requested() { $rules = new Rules(); - $rules->storeRules(function (ObjectRules $builder) {}); + $rules->storeRules(function (ObjectRules $builder) { + }); $rules->updateRules(function (ObjectRules $builder) { $this->fail("The update rules should not be resolved"); }); diff --git a/tests/assets/jsonapi/compount_document.json b/tests/assets/jsonapi/compount_document.json new file mode 100644 index 0000000..113ca38 --- /dev/null +++ b/tests/assets/jsonapi/compount_document.json @@ -0,0 +1,92 @@ +{ + "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" + } + } + ] +} \ No newline at end of file diff --git a/tests/assets/jsonapi/multiple_users.json b/tests/assets/jsonapi/multiple_users.json new file mode 100644 index 0000000..3c4c525 --- /dev/null +++ b/tests/assets/jsonapi/multiple_users.json @@ -0,0 +1,20 @@ +{ + "data": [ + { + "type": "users", + "id": "1", + "attributes": { + "name": "Daan Hage", + "email": "daan@atabix.nl" + } + }, + { + "type": "users", + "id": "2", + "attributes": { + "name": "Randall Theuns", + "email": "randall@atabix.nl" + } + } + ] +} \ No newline at end of file diff --git a/tests/assets/jsonapi/simple_user.json b/tests/assets/jsonapi/simple_user.json new file mode 100644 index 0000000..51b429f --- /dev/null +++ b/tests/assets/jsonapi/simple_user.json @@ -0,0 +1,12 @@ +{ + "data": [ + { + "type": "users", + "id": "1", + "attributes": { + "name": "Daan Hage", + "email": "daan@atabix.nl" + } + } + ] +} \ No newline at end of file From 04e1fb07a2fddf36ba0a4af5d372ad40d55cbf16 Mon Sep 17 00:00:00 2001 From: Daan Hage Date: Thu, 5 Mar 2020 20:25:05 +0100 Subject: [PATCH 3/3] multiple result parser --- phpstan.neon | 2 - src/Apitizer/JsonApi/Document.php | 42 +++++----------- src/Apitizer/JsonApi/ResourceObject.php | 4 +- src/Apitizer/Rendering/JsonApiRenderer.php | 3 +- tests/Feature/Rendering/JsonApiTest.php | 49 ++++++++++++++++--- .../database/factories/UserFactory.php | 5 +- 6 files changed, 59 insertions(+), 46 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index af0430b..699c359 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -10,5 +10,3 @@ parameters: # This class does string-building with class-strings, which is more hassle # to type-hint and annotate than it's worth. - src/Apitizer/Support/ClassFilter.php - - checkMissingIterableValueType: false diff --git a/src/Apitizer/JsonApi/Document.php b/src/Apitizer/JsonApi/Document.php index 6a22091..2ae8fb4 100644 --- a/src/Apitizer/JsonApi/Document.php +++ b/src/Apitizer/JsonApi/Document.php @@ -10,48 +10,30 @@ class Document /** @var ResourceObject[] */ protected $resources = []; + /** @var array */ + protected $includes = []; + /** * add a resource to the collection - * adds included resources if found inside the resource's relationships, unless $options['includeContainedResources'] is set to false - * @param string $type + * adds included resources if found inside the resource's relationships, unless $options['includeContainedResources'] is set to false. + * + * @param string $type * @param string $id - * @param array $attributes optional, if given a ResourceObject is added, otherwise a ResourceIdentifierObject is added + * @param array $attributes optional, if given a ResourceObject is added, otherwise a ResourceIdentifierObject is added */ - public function addResource(string $type, string $id, array $attributes=[]): void + public function addResource(string $type, string $id, array $attributes = []): void { $object = ResourceObject::factory($attributes, $type, $id); $this->resources[] = $object; } - // /** - // * @inheritDoc - // */ - // public function setPaginationLinks($previousHref=null, $nextHref=null, $firstHref=null, $lastHref=null) { - // if ($previousHref !== null) { - // $this->addLink('prev', $previousHref); - // } - // if ($nextHref !== null) { - // $this->addLink('next', $nextHref); - // } - // if ($firstHref !== null) { - // $this->addLink('first', $firstHref); - // } - // if ($lastHref !== null) { - // $this->addLink('last', $lastHref); - // } - // } - public function toArray(): array { - $data = [ - 'data' => [] - ]; - - foreach ($this->resources as $resource) { - $data['data'][] = $resource->toArray(); - } + $resources = collect($this->resources); - return $data; + return $resources->count() == 1 + ? ['data' => $resources->first()->toArray()] + : ['data' => $resources->toArray()]; } } diff --git a/src/Apitizer/JsonApi/ResourceObject.php b/src/Apitizer/JsonApi/ResourceObject.php index 211e4e0..7cab366 100644 --- a/src/Apitizer/JsonApi/ResourceObject.php +++ b/src/Apitizer/JsonApi/ResourceObject.php @@ -2,7 +2,9 @@ namespace Apitizer\JsonApi; -class ResourceObject +use Illuminate\Contracts\Support\Arrayable; + +class ResourceObject implements Arrayable { /** @var array */ diff --git a/src/Apitizer/Rendering/JsonApiRenderer.php b/src/Apitizer/Rendering/JsonApiRenderer.php index d689ca6..e9915fb 100644 --- a/src/Apitizer/Rendering/JsonApiRenderer.php +++ b/src/Apitizer/Rendering/JsonApiRenderer.php @@ -43,10 +43,11 @@ public function render(QueryBuilder $queryBuilder, $data, FetchSpec $fetchSpec): $this->addRenderedField($row, $field, $attributes); } + $this->document->addResource( $this->getResourceType($queryBuilder, $row), $this->getResourceId($queryBuilder, $row), - $attributes, + $attributes ); } diff --git a/tests/Feature/Rendering/JsonApiTest.php b/tests/Feature/Rendering/JsonApiTest.php index 532918b..2718e89 100644 --- a/tests/Feature/Rendering/JsonApiTest.php +++ b/tests/Feature/Rendering/JsonApiTest.php @@ -15,6 +15,18 @@ */ class JsonApiTest extends TestCase { + + /** @test */ + public function it_renders_nothing_with_empty_collection() + { + $request = $this->request()->fields('name, email')->make(); + $result = UserBuilder::make($request)->setRenderer(new JsonApiRenderer)->render([]); + + $this->assertEquals([ + "data" => [] + ], $result); + } + /** @test */ public function it_renders_existing_eloquent_model_to_jsonapi_format() { @@ -27,10 +39,16 @@ public function it_renders_existing_eloquent_model_to_jsonapi_format() $request = $this->request()->fields('name, email')->make(); $result = UserBuilder::make($request)->setRenderer(new JsonApiRenderer)->render($user); - $this->assertJsonStringEqualsJsonFile( - 'tests/assets/jsonapi/simple_user.json', - json_encode($result, JSON_PRETTY_PRINT) - ); + $this->assertEquals([ + "data" => [ + 'type' => 'users', + 'id' => 1, + 'attributes' => [ + 'name' => 'Daan Hage', + 'email' => 'daan@atabix.nl' + ], + ] + ], $result); } /** @test */ @@ -51,9 +69,24 @@ public function it_renders_multiple_eloquent_users_to_jsonapi_format() $request = $this->request()->fields('name, email')->make(); $result = UserBuilder::make($request)->setRenderer(new JsonApiRenderer)->render($collection); - $this->assertJsonStringEqualsJsonFile( - 'tests/assets/jsonapi/multiple_users.json', - json_encode($result, JSON_PRETTY_PRINT) - ); + $this->assertEquals([ + 'data' => [ + [ + 'type' => 'users', + 'id' => 1, + 'attributes' => [ + 'name' => 'Daan Hage', + 'email' => 'daan@atabix.nl', + ], + ], [ + 'type' => 'users', + 'id' => 2, + 'attributes' => [ + 'name' => 'Randall Theuns', + 'email' => 'randall@atabix.nl', + ], + ], + ], + ], $result); } } diff --git a/tests/Feature/database/factories/UserFactory.php b/tests/Feature/database/factories/UserFactory.php index fc68110..80bcbcf 100644 --- a/tests/Feature/database/factories/UserFactory.php +++ b/tests/Feature/database/factories/UserFactory.php @@ -1,16 +1,13 @@ define(User::class, function (Faker $faker, array $attributes) { return [ 'name' => $faker->name, 'email' => $faker->email, - 'should_reset_password' => rand(0, 1), - 'created_at' => $faker->dateTime('now'), - 'updated_at' => $faker->dateTime('now'), ]; });