diff --git a/.gitignore b/.gitignore index 0af2889..f9869df 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,4 @@ Homestead.json /.vagrant .phpunit.result.cache coverage/ -.php_cs.cache \ No newline at end of file +.php_cs.cache diff --git a/src/Apitizer/JsonApi/Document.php b/src/Apitizer/JsonApi/Document.php new file mode 100644 index 0000000..2ae8fb4 --- /dev/null +++ b/src/Apitizer/JsonApi/Document.php @@ -0,0 +1,39 @@ +resources[] = $object; + } + + public function toArray(): array + { + $resources = collect($this->resources); + + 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 new file mode 100644 index 0000000..7cab366 --- /dev/null +++ b/src/Apitizer/JsonApi/ResourceObject.php @@ -0,0 +1,231 @@ +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/Rendering/AbstractRenderer.php b/src/Apitizer/Rendering/AbstractRenderer.php index f9268ed..655da84 100644 --- a/src/Apitizer/Rendering/AbstractRenderer.php +++ b/src/Apitizer/Rendering/AbstractRenderer.php @@ -2,13 +2,51 @@ 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\Types\FetchSpec; use Illuminate\Support\Arr; abstract class AbstractRenderer { use FetchesValueFromRow; + /** + * @param QueryBuilder $queryBuilder + * @param mixed $data + * @param FetchSpec $fetchSpec + * @return array + */ + abstract public function render(QueryBuilder $queryBuilder, $data, FetchSpec $fetchSpec): array; + + /** + * @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); + + 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 39db1ef..9adb526 100644 --- a/src/Apitizer/Rendering/BasicRenderer.php +++ b/src/Apitizer/Rendering/BasicRenderer.php @@ -2,15 +2,22 @@ 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 { + + /** + * @param QueryBuilder $queryBuilder + * @param mixed $data + * @param FetchSpec $fetchSpec + * @return array + */ public function render(QueryBuilder $queryBuilder, $data, FetchSpec $fetchSpec): array { return $this->doRender( @@ -22,14 +29,14 @@ 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( + * @param QueryBuilder $queryBuilder + * @param mixed $data + * @param AbstractField[] $fields + * @param Association[] $associations + * + * @return array|array> + */ + public function doRender( QueryBuilder $queryBuilder, $data, array $fields, @@ -50,7 +57,7 @@ protected function doRender( * * @return array> */ - protected function renderMany( + public function renderMany( $data, QueryBuilder $queryBuilder, array $fields, @@ -88,30 +95,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..e9915fb 100644 --- a/src/Apitizer/Rendering/JsonApiRenderer.php +++ b/src/Apitizer/Rendering/JsonApiRenderer.php @@ -3,84 +3,109 @@ 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; 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 +{ + /** @var Document */ + protected $document; + + public function __construct() + { + $this->document = new Document(); + } + + /** + * @param QueryBuilder $queryBuilder + * @param mixed $data + * @param FetchSpec $fetchSpec + * @return array + */ + public function render(QueryBuilder $queryBuilder, $data, FetchSpec $fetchSpec): array + { + $attributes = []; + if ($this->isSingleRowOfData($data)) { + $data = collect([$data]); + } + + 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(); + } + + /** + * @param QueryBuilder $queryBuilder + * @param mixed $row + * @return string + */ + protected function getResourceType(QueryBuilder $queryBuilder, $row): string + { + if ($row instanceof Resource) { + return $row->getResourceType(); + } + + $className = (new ReflectionClass($queryBuilder->model()))->getShortName(); + + 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) { + 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/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/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/Rendering/JsonApiTest.php b/tests/Feature/Rendering/JsonApiTest.php new file mode 100644 index 0000000..2718e89 --- /dev/null +++ b/tests/Feature/Rendering/JsonApiTest.php @@ -0,0 +1,92 @@ +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() + { + // 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', + ]); + + $request = $this->request()->fields('name, email')->make(); + $result = UserBuilder::make($request)->setRenderer(new JsonApiRenderer)->render($user); + + $this->assertEquals([ + "data" => [ + 'type' => 'users', + 'id' => 1, + 'attributes' => [ + 'name' => 'Daan Hage', + 'email' => 'daan@atabix.nl' + ], + ] + ], $result); + } + + /** @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->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 b008ae6..80bcbcf 100644 --- a/tests/Feature/database/factories/UserFactory.php +++ b/tests/Feature/database/factories/UserFactory.php @@ -1,8 +1,8 @@ define(User::class, function (Faker $faker, array $attributes) { return [ 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