diff --git a/src/Illuminate/Foundation/Console/ResourceMakeCommand.php b/src/Illuminate/Foundation/Console/ResourceMakeCommand.php index 51d96120cc00..9b57b818c9a1 100644 --- a/src/Illuminate/Foundation/Console/ResourceMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ResourceMakeCommand.php @@ -51,9 +51,11 @@ public function handle() */ protected function getStub() { - return $this->collection() - ? $this->resolveStubPath('/stubs/resource-collection.stub') - : $this->resolveStubPath('/stubs/resource.stub'); + return match (true) { + $this->collection() => $this->resolveStubPath('/stubs/resource-collection.stub'), + $this->option('json-api') => $this->resolveStubPath('/stubs/resource-json-api.stub'), + default => $this->resolveStubPath('/stubs/resource.stub'), + }; } /** @@ -100,6 +102,7 @@ protected function getOptions() { return [ ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the resource already exists'], + ['json-api', 'j', InputOption::VALUE_NONE, 'Create a JSON:API resource'], ['collection', 'c', InputOption::VALUE_NONE, 'Create a resource collection'], ]; } diff --git a/src/Illuminate/Foundation/Console/stubs/resource-json-api.stub b/src/Illuminate/Foundation/Console/stubs/resource-json-api.stub new file mode 100644 index 000000000000..fe1137702506 --- /dev/null +++ b/src/Illuminate/Foundation/Console/stubs/resource-json-api.stub @@ -0,0 +1,23 @@ +toArray( - $request ?: Container::getInstance()->make('request') + $data = $this->resolveResourceData( + $request ?: $this->resolveRequestFromContainer() ); if ($data instanceof Arrayable) { @@ -124,6 +124,32 @@ public function resolve($request = null) return $this->filter((array) $data); } + /** + * Transform the resource into an array. + * + * @param \Illuminate\Http\Request $request + * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable + */ + public function toAttributes(Request $request) + { + if (property_exists($this, 'attributes')) { + return $this->attributes; + } + + return $this->toArray($request); + } + + /** + * Resolve the resource data to an array. + * + * @param \Illuminate\Http\Request $request + * @return array + */ + public function resolveResourceData(Request $request) + { + return $this->toAttributes($request); + } + /** * Transform the resource into an array. * @@ -219,6 +245,16 @@ public function withResponse(Request $request, JsonResponse $response) // } + /** + * Resolve the HTTP request instance from container. + * + * @return \Illuminate\Http\Request + */ + protected function resolveRequestFromContainer() + { + return Container::getInstance()->make('request'); + } + /** * Set the string that should wrap the outer-most resource array. * @@ -249,7 +285,7 @@ public static function withoutWrapping() public function response($request = null) { return $this->toResponse( - $request ?: Container::getInstance()->make('request') + $request ?: $this->resolveRequestFromContainer() ); } @@ -271,6 +307,17 @@ public function toResponse($request) */ public function jsonSerialize(): array { - return $this->resolve(Container::getInstance()->make('request')); + return $this->resolve($this->resolveRequestFromContainer()); + } + + /** + * Flush the resource's global state. + * + * @return void + */ + public static function flushState() + { + static::$wrap = 'data'; + static::$forceWrapping = false; } } diff --git a/src/Illuminate/Http/Resources/Json/ResourceCollection.php b/src/Illuminate/Http/Resources/Json/ResourceCollection.php index 1df2efdf4e6b..dc7a6f023146 100644 --- a/src/Illuminate/Http/Resources/Json/ResourceCollection.php +++ b/src/Illuminate/Http/Resources/Json/ResourceCollection.php @@ -96,9 +96,9 @@ public function count(): int * @param \Illuminate\Http\Request $request * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable */ - public function toArray(Request $request) + public function toAttributes(Request $request) { - return $this->collection->map->toArray($request)->all(); + return $this->collection->map->resolve($request)->all(); } /** diff --git a/src/Illuminate/Http/Resources/JsonApi/AnonymousResourceCollection.php b/src/Illuminate/Http/Resources/JsonApi/AnonymousResourceCollection.php new file mode 100644 index 000000000000..9bb21a8d8f73 --- /dev/null +++ b/src/Illuminate/Http/Resources/JsonApi/AnonymousResourceCollection.php @@ -0,0 +1,86 @@ + $this->collection + ->map(fn ($resource) => $resource->resolveIncludedResourceObjects($request)) + ->flatten(depth: 1) + ->uniqueStrict('_uniqueKey') + ->map(fn ($included) => Arr::except($included, ['_uniqueKey'])) + ->values() + ->all(), + ...($implementation = JsonApiResource::$jsonApiInformation) + ? ['jsonapi' => $implementation] + : [], + ]); + } + + /** + * Transform the resource into a JSON array. + * + * @param \Illuminate\Http\Request $request + * @return array + */ + #[\Override] + public function toAttributes(Request $request) + { + return $this->collection + ->map(fn ($resource) => $resource->resolveResourceData($request)) + ->all(); + } + + /** + * Customize the outgoing response for the resource. + * + * @param \Illuminate\Http\Request $request + * @param \Illuminate\Http\JsonResponse $response + * @return void + */ + #[\Override] + public function withResponse(Request $request, JsonResponse $response): void + { + $response->header('Content-Type', 'application/vnd.api+json'); + } + + /** + * Create an HTTP response that represents the object. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + */ + #[\Override] + public function toResponse($request) + { + return parent::toResponse($this->resolveJsonApiRequestFrom($request)); + } + + /** + * Resolve the HTTP request instance from container. + * + * @return \Illuminate\Http\Resources\JsonApi\SparseRequest + */ + #[\Override] + protected function resolveRequestFromContainer() + { + return $this->resolveJsonApiRequestFrom(Container::getInstance()->make('request')); + } +} diff --git a/src/Illuminate/Http/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php b/src/Illuminate/Http/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php new file mode 100644 index 000000000000..d5b62ccda67e --- /dev/null +++ b/src/Illuminate/Http/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php @@ -0,0 +1,418 @@ +resolveResourceType($request); + + return [ + 'id' => $this->resolveResourceIdentifier($request), + 'type' => $resourceType, + ...(new Collection([ + 'attributes' => $this->resolveResourceAttributes($request, $resourceType), + 'relationships' => $this->resolveResourceRelationshipIdentifiers($request), + 'links' => $this->resolveResourceLinks($request), + 'meta' => $this->resolveResourceMetaInformation($request), + ]))->filter()->map(fn ($value) => (object) $value), + ]; + } + + /** + * Resolve the resource's identifier. + * + * @return string|int + * + * @throws ResourceIdentificationException + */ + public function resolveResourceIdentifier(JsonApiRequest $request): string + { + if (! is_null($resourceId = $this->toId($request))) { + return $resourceId; + } + + if (! ($this->resource instanceof Model || method_exists($this->resource, 'getKey'))) { + throw ResourceIdentificationException::attemptingToDetermineIdFor($this); + } + + return (string) $this->resource->getKey(); + } + + /** + * Resolve the resource's type. + * + * + * @throws ResourceIdentificationException + */ + public function resolveResourceType(JsonApiRequest $request): string + { + if (! is_null($resourceType = $this->toType($request))) { + return $resourceType; + } + + if (static::class !== JsonApiResource::class) { + return Str::of(static::class)->classBasename()->basename('Resource')->snake()->pluralStudly(); + } + + if (! $this->resource instanceof Model) { + throw ResourceIdentificationException::attemptingToDetermineTypeFor($this); + } + + $modelClassName = $this->resource::class; + + $morphMap = Relation::getMorphAlias($modelClassName); + + return Str::of( + $morphMap !== $modelClassName ? $morphMap : class_basename($modelClassName) + )->snake()->pluralStudly(); + } + + /** + * Resolve the resource's attributes. + * + * + * @throws \RuntimeException + */ + protected function resolveResourceAttributes(JsonApiRequest $request, string $resourceType): array + { + $data = $this->toAttributes($request); + + if ($data instanceof Arrayable) { + $data = $data->toArray(); + } elseif ($data instanceof JsonSerializable) { + $data = $data->jsonSerialize(); + } + + $sparseFieldset = match ($this->usesRequestQueryString) { + true => $request->sparseFields($resourceType), + default => [], + }; + + $data = (new Collection($data)) + ->mapWithKeys(fn ($value, $key) => is_int($key) ? [$value => $this->resource->{$value}] : [$key => $value]) + ->when(! empty($sparseFieldset), fn ($attributes) => $attributes->only($sparseFieldset)) + ->transform(fn ($value) => value($value, $request)) + ->all(); + + return $this->filter($data); + } + + /** + * Resolves `relationships` for the resource's data object. + * + * @return string|int + * + * @throws \RuntimeException + */ + protected function resolveResourceRelationshipIdentifiers(JsonApiRequest $request): array + { + if (! $this->resource instanceof Model) { + return []; + } + + $this->compileResourceRelationships($request); + + return [ + ...(new Collection($this->filter($this->loadedRelationshipIdentifiers))) + ->map(function ($relation) { + return ! is_null($relation) ? $relation : ['data' => null]; + })->all(), + ]; + } + + /** + * Compile resource relationships. + */ + protected function compileResourceRelationships(JsonApiRequest $request): void + { + if (! is_null($this->loadedRelationshipsMap)) { + return; + } + + $sparseIncluded = match (true) { + $this->includesPreviouslyLoadedRelationships => array_keys($this->resource->getRelations()), + default => $request->sparseIncluded(), + }; + + $resourceRelationships = (new Collection($this->toRelationships($request))) + ->transform(fn ($value, $key) => is_int($key) ? new RelationResolver($value) : new RelationResolver($key, $value)) + ->mapWithKeys(fn ($relationResolver) => [$relationResolver->relationName => $relationResolver]) + ->filter(fn ($value, $key) => in_array($key, $sparseIncluded)); + + $resourceRelationshipKeys = $resourceRelationships->keys(); + + $this->resource->loadMissing($resourceRelationshipKeys->all() ?? []); + + $this->loadedRelationshipsMap = []; + + $this->loadedRelationshipIdentifiers = (new LazyCollection(function () use ($request, $resourceRelationships) { + foreach ($resourceRelationships as $relationName => $relationResolver) { + $relatedModels = $relationResolver->handle($this->resource); + $relatedResourceClass = $relationResolver->resourceClass(); + + if (! is_null($relatedModels) && $this->includesPreviouslyLoadedRelationships === false) { + if (! empty($relations = $request->sparseIncluded($relationName))) { + $relatedModels->loadMissing($relations); + } + } + + yield from $this->compileResourceRelationshipUsingResolver( + $request, + $this->resource, + $relationResolver, + $relatedModels, + ); + } + }))->all(); + } + + /** + * Compile resource relations. + */ + protected function compileResourceRelationshipUsingResolver( + JsonApiRequest $request, + mixed $resource, + RelationResolver $relationResolver, + Collection|Model|null $relatedModels + ): Generator { + $relationName = $relationResolver->relationName; + $resourceClass = $relationResolver->resourceClass(); + + // Relationship is a collection of models... + if ($relatedModels instanceof Collection) { + $relatedModels = $relatedModels->values(); + + if ($relatedModels->isEmpty()) { + yield $relationName => ['data' => $relatedModels]; + + return; + } + + $relationship = $resource->{$relationName}(); + + $isUnique = ! $relationship instanceof BelongsToMany; + + yield $relationName => ['data' => $relatedModels->map(function ($relatedModel) use ($request, $resourceClass, $isUnique) { + $relatedResource = rescue(fn () => $relatedModel->toResource($resourceClass), new JsonApiResource($relatedModel)); + + return transform( + [$relatedResource->resolveResourceType($request), $relatedResource->resolveResourceIdentifier($request)], + function ($uniqueKey) use ($request, $relatedModel, $relatedResource, $isUnique) { + $this->loadedRelationshipsMap[] = [$relatedResource, ...$uniqueKey, $isUnique]; + + $this->compileIncludedNestedRelationshipsMap($request, $relatedModel, $relatedResource); + + return [ + 'id' => $uniqueKey[1], + 'type' => $uniqueKey[0], + ]; + } + ); + })->all()]; + + return; + } + + // Relationship is a single model... + $relatedModel = $relatedModels; + + if (is_null($relatedModel)) { + yield $relationName => null; + + return; + } elseif ($relatedModel instanceof Pivot || + in_array(AsPivot::class, class_uses_recursive($relatedModel), true)) { + yield $relationName => new MissingValue; + + return; + } + + $relatedResource = rescue(fn () => $relatedModel->toResource($resourceClass), new JsonApiResource($relatedModel)); + + yield $relationName => ['data' => transform( + [$relatedResource->resolveResourceType($request), $relatedResource->resolveResourceIdentifier($request)], + function ($uniqueKey) use ($relatedModel, $relatedResource, $request) { + $this->loadedRelationshipsMap[] = [$relatedResource, ...$uniqueKey, true]; + + $this->compileIncludedNestedRelationshipsMap($request, $relatedModel, $relatedResource); + + return [ + 'id' => $uniqueKey[1], + 'type' => $uniqueKey[0], + ]; + } + )]; + } + + /** + * Compile included relationships map. + */ + protected function compileIncludedNestedRelationshipsMap(JsonApiRequest $request, Model $relation, JsonApiResource $resource): void + { + (new Collection($resource->toRelationships($request))) + ->transform(fn ($value, $key) => is_int($key) ? new RelationResolver($value) : new RelationResolver($key, $value)) + ->mapWithKeys(fn ($relationResolver) => [$relationResolver->relationName => $relationResolver]) + ->filter(fn ($value, $key) => in_array($key, array_keys($relation->getRelations()))) + ->each(function ($relationResolver, $key) use ($relation, $request) { + $this->compileResourceRelationshipUsingResolver($request, $relation, $relationResolver, $relation->getRelation($key)); + }); + } + + /** + * Resolves `included` for the resource. + */ + public function resolveIncludedResourceObjects(JsonApiRequest $request): Collection + { + if (! $this->resource instanceof Model) { + return []; + } + + $this->compileResourceRelationships($request); + + $relations = new Collection; + + $index = 0; + + while ($index < count($this->loadedRelationshipsMap)) { + [$resourceInstance, $type, $id, $isUnique] = $this->loadedRelationshipsMap[$index]; + + if (! $resourceInstance instanceof JsonApiResource && + $resourceInstance instanceof JsonResource) { + $resourceInstance = new JsonApiResource($resourceInstance->resource); + } + + $relationsData = $resourceInstance + ->includePreviouslyLoadedRelationships() + ->resolve($request); + + array_push($this->loadedRelationshipsMap, ...$resourceInstance->loadedRelationshipsMap); + + $relations->push(array_filter([ + 'id' => $id, + 'type' => $type, + '_uniqueKey' => implode(':', $isUnique === true ? [$id, $type] : [$id, $type, (string) Str::random()]), + 'attributes' => Arr::get($relationsData, 'data.attributes'), + 'relationships' => Arr::get($relationsData, 'data.relationships'), + 'links' => Arr::get($relationsData, 'data.links'), + 'meta' => Arr::get($relationsData, 'data.meta'), + ])); + + $index++; + } + + return $relations; + } + + /** + * Resolve the links for the resource. + * + * @return array + */ + protected function resolveResourceLinks(JsonApiRequest $request): array + { + return $this->toLinks($request); + } + + /** + * Resolve the meta information for the resource. + * + * @return array + */ + protected function resolveResourceMetaInformation(JsonApiRequest $request): array + { + return $this->toMeta($request); + } + + /** + * Indicate that relationship loading should respect the request's "includes" query string. + * + * @return $this + */ + public function respectFieldsAndIncludesInQueryString(bool $value = true) + { + $this->usesRequestQueryString = $value; + + return $this; + } + + /** + * Indicate that relationship loading should not rely on the request's "includes" query string. + * + * @return $this + */ + public function ignoreFieldsAndIncludesInQueryString() + { + return $this->respectFieldsAndIncludesFromQueryString(false); + } + + /** + * Determine relationship should include loaded relationships. + * + * @return $this + */ + public function includePreviouslyLoadedRelationships() + { + $this->includesPreviouslyLoadedRelationships = true; + + return $this; + } +} diff --git a/src/Illuminate/Http/Resources/JsonApi/Concerns/ResolvesJsonApiRequest.php b/src/Illuminate/Http/Resources/JsonApi/Concerns/ResolvesJsonApiRequest.php new file mode 100644 index 000000000000..c24dd927d925 --- /dev/null +++ b/src/Illuminate/Http/Resources/JsonApi/Concerns/ResolvesJsonApiRequest.php @@ -0,0 +1,21 @@ +cachedSparseFields)) { + $this->cachedSparseFields = (new Collection($this->array('fields'))) + ->transform(fn ($fieldsets) => empty($fieldsets) ? [] : explode(',', $fieldsets)) + ->all(); + } + + return $this->cachedSparseFields[$key] ?? []; + } + + /** + * Get the request's included relationships. + */ + public function sparseIncluded(?string $key = null): ?array + { + if (is_null($this->cachedSparseIncluded)) { + $included = (string) $this->string('include', ''); + + $this->cachedSparseIncluded = (new Collection(empty($included) ? [] : explode(',', $included))) + ->transform(function ($item) { + $with = null; + + if (str_contains($item, '.')) { + [$relation, $with] = explode('.', $item, 2); + } else { + $relation = $item; + } + + return ['relation' => $relation, 'with' => $with]; + })->mapToGroups(fn ($item) => [$item['relation'] => $item['with']]) + ->toArray(); + } + + if (is_null($key)) { + return array_keys($this->cachedSparseIncluded); + } + + return transform($this->cachedSparseIncluded[$key] ?? null, function ($value) { + return (new Collection(Arr::wrap($value))) + ->transform(function ($item) { + $item = implode('.', Arr::take(explode('.', $item), JsonApiResource::$maxRelationshipDepth - 1)); + + return ! empty($item) ? $item : null; + })->filter()->all(); + }) ?? []; + } +} diff --git a/src/Illuminate/Http/Resources/JsonApi/JsonApiResource.php b/src/Illuminate/Http/Resources/JsonApi/JsonApiResource.php new file mode 100644 index 000000000000..06100158d7f1 --- /dev/null +++ b/src/Illuminate/Http/Resources/JsonApi/JsonApiResource.php @@ -0,0 +1,255 @@ + $version, + 'ext' => $ext, + 'profile' => $profile, + 'meta' => $meta, + ]); + } + + /** + * Get the resource's ID. + * + * @return string|null + */ + public function toId(Request $request) + { + return null; + } + + /** + * Get the resource's type. + * + * @return string|null + */ + public function toType(Request $request) + { + return null; + } + + /** + * Transform the resource into an array. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Contracts\Support\Arrayable|\JsonSerializable|array + */ + #[\Override] + public function toAttributes(Request $request) + { + if (property_exists($this, 'attributes')) { + return $this->attributes; + } + + return $this->toArray($request); + } + + /** + * Get the resource's relationships. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Contracts\Support\Arrayable|array + */ + public function toRelationships(Request $request) + { + if (property_exists($this, 'relationships')) { + return $this->relationships; + } + + return []; + } + + /** + * Get the resource's links. + * + * @return array + */ + public function toLinks(Request $request) + { + return $this->jsonApiLinks; + } + + /** + * Get the resource's meta information. + * + * @return array + */ + public function toMeta(Request $request) + { + return $this->jsonApiMeta; + } + + /** + * Get any additional data that should be returned with the resource array. + * + * @param \Illuminate\Http\Request $request + * @return array + */ + #[\Override] + public function with($request) + { + return array_filter([ + 'included' => $this->resolveIncludedResourceObjects($request) + ->uniqueStrict('_uniqueKey') + ->map(fn ($included) => Arr::except($included, ['_uniqueKey'])) + ->values() + ->all(), + ...($implementation = static::$jsonApiInformation) + ? ['jsonapi' => $implementation] + : [], + ]); + } + + /** + * Resolve the resource to an array. + * + * @param \Illuminate\Http\Request|null $request + * @return array + */ + #[\Override] + public function resolve($request = null) + { + return [ + 'data' => $this->resolveResourceData($this->resolveJsonApiRequestFrom($request ?? $this->resolveRequestFromContainer())), + ]; + } + + /** + * Resolve the resource data to an array. + * + * @param \Illuminate\Http\Request $request + * @return array + */ + #[\Override] + public function resolveResourceData(Request $request) + { + return $this->resolveResourceObject($request); + } + + /** + * Customize the outgoing response for the resource. + */ + #[\Override] + public function withResponse(Request $request, JsonResponse $response): void + { + $response->header('Content-Type', 'application/vnd.api+json'); + } + + /** + * Create an HTTP response that represents the object. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + */ + #[\Override] + public function toResponse($request) + { + return parent::toResponse($this->resolveJsonApiRequestFrom($request)); + } + + /** + * Resolve the HTTP request instance from container. + * + * @return \Illuminate\Http\Resources\JsonApi\JsonApiRequest + */ + #[\Override] + protected function resolveRequestFromContainer() + { + return $this->resolveJsonApiRequestFrom(parent::resolveRequestFromContainer()); + } + + /** + * Create a new resource collection instance. + * + * @param mixed $resource + * @return \Illuminate\Http\Resources\JsonApi\AnonymousResourceCollection + */ + #[\Override] + protected static function newCollection($resource) + { + return new AnonymousResourceCollection($resource, static::class); + } + + /** + * Set the string that should wrap the outer-most resource array. + * + * @param string $value + * @return never + * + * @throws \RuntimeException + */ + #[\Override] + public static function wrap($value) + { + throw new BadMethodCallException(sprintf('Using %s() method is not allowed.', __METHOD__)); + } + + /** + * Disable wrapping of the outer-most resource array. + * + * @return never + */ + #[\Override] + public static function withoutWrapping() + { + throw new BadMethodCallException(sprintf('Using %s() method is not allowed.', __METHOD__)); + } + + /** + * Flush the resource's global state. + * + * @return void + */ + #[\Override] + public static function flushState() + { + parent::flushState(); + + static::$jsonApiInformation = []; + static::$maxRelationshipDepth = 3; + } +} diff --git a/src/Illuminate/Http/Resources/JsonApi/RelationResolver.php b/src/Illuminate/Http/Resources/JsonApi/RelationResolver.php new file mode 100644 index 000000000000..772acff826a9 --- /dev/null +++ b/src/Illuminate/Http/Resources/JsonApi/RelationResolver.php @@ -0,0 +1,62 @@ +|null + */ + public ?string $relationResourceClass = null; + + /** + * Construct a new resource relationship resolver. + * + * @param \Closure(mixed):(\Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Eloquent\Model|null)|class-string<\Illuminate\Http\Resources\JsonApi\JsonApiResource>|null $resolver + */ + public function __construct(public string $relationName, Closure|string|null $resolver = null) + { + $this->relationResolver = match (true) { + $resolver instanceof Closure => $resolver, + default => fn ($resource) => $resource->getRelation($this->relationName), + }; + + if (is_string($resolver) && class_exists($resolver)) { + $this->relationResourceClass = $resolver; + } + } + + /** + * Resolve the relation for a resource. + */ + public function handle(mixed $resource): Collection|Model|null + { + return value($this->relationResolver, $resource); + } + + /** + * Get the resource class. + * + * @return class-string<\Illuminate\Http\Resources\JsonApi\JsonApiResource>|null + */ + public function resourceClass(): ?string + { + return $this->relationResourceClass; + } +} diff --git a/tests/Http/Resources/JsonApi/JsonApiResourceTest.php b/tests/Http/Resources/JsonApi/JsonApiResourceTest.php new file mode 100644 index 000000000000..9f3fc8a94d6f --- /dev/null +++ b/tests/Http/Resources/JsonApi/JsonApiResourceTest.php @@ -0,0 +1,40 @@ +assertSame('data', JsonApiResource::$wrap); + } + + public function testUnableToSetWrapper() + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Using Illuminate\Http\Resources\JsonApi\JsonApiResource::wrap() method is not allowed.'); + + JsonApiResource::wrap('laravel'); + } + + public function testUnableToUnsetWrapper() + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Using Illuminate\Http\Resources\JsonApi\JsonApiResource::withoutWrapping() method is not allowed.'); + + JsonApiResource::withoutWrapping(); + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/AuthorResource.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/AuthorResource.php new file mode 100644 index 000000000000..90ae724af652 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/AuthorResource.php @@ -0,0 +1,23 @@ + $this->name, + 'email' => $this->email, + ]; + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/Comment.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/Comment.php new file mode 100644 index 000000000000..68e2c99b9168 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/Comment.php @@ -0,0 +1,25 @@ +belongsTo(Post::class); + } + + public function commenter() + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/CommentFactory.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/CommentFactory.php new file mode 100644 index 000000000000..3d6c26308cf7 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/CommentFactory.php @@ -0,0 +1,18 @@ + PostFactory::new(), + 'user_id' => UserFactory::new(), + 'content' => $this->faker->words(10, true), + ]; + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/CommentResource.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/CommentResource.php new file mode 100644 index 000000000000..91f619310e85 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/CommentResource.php @@ -0,0 +1,23 @@ + UserApiResource::class, + ]; +} diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/Membership.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/Membership.php new file mode 100644 index 000000000000..c3af1de45292 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/Membership.php @@ -0,0 +1,10 @@ +hasMany(Comment::class); + } + + public function author() + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/PostFactory.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/PostFactory.php new file mode 100644 index 000000000000..925ae03ca2c6 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/PostFactory.php @@ -0,0 +1,24 @@ + UserFactory::new(), + 'title' => $this->faker->word(), + 'content' => $this->faker->words(10, true), + ]; + } + + #[\Override] + public function modelName() + { + return Post::class; + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/PostResource.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/PostResource.php new file mode 100644 index 000000000000..c45a916fd675 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/PostResource.php @@ -0,0 +1,18 @@ + AuthorResource::class, + 'comments', + ]; +} diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/Profile.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/Profile.php new file mode 100644 index 000000000000..49e575643c73 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/Profile.php @@ -0,0 +1,22 @@ +belongsTo(User::class); + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/ProfileFactory.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/ProfileFactory.php new file mode 100644 index 000000000000..0ab93b048914 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/ProfileFactory.php @@ -0,0 +1,22 @@ + UserFactory::new(), + ]; + } + + #[\Override] + public function modelName() + { + return Profile::class; + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/ProfileResource.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/ProfileResource.php new file mode 100644 index 000000000000..a0e16dcc4de6 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/ProfileResource.php @@ -0,0 +1,22 @@ + UserResource::class, + ]; + + #[\Override] + public function toAttributes(Request $request) + { + return [ + 'timezone' => $this->timezone, + 'date_of_birth' => $this->date_of_birth, + ]; + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/Team.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/Team.php new file mode 100644 index 000000000000..24060ced1eb0 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/Team.php @@ -0,0 +1,31 @@ + 'boolean', + ]; + } + + public function users() + { + return $this->belongsToMany(User::class) + ->withPivot('role') + ->withTimestamps() + ->using(Membership::class) + ->as('membership'); + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/TeamFactory.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/TeamFactory.php new file mode 100644 index 000000000000..0d960f8c6916 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/TeamFactory.php @@ -0,0 +1,24 @@ + $this->faker->unique()->company(), + 'user_id' => UserFactory::new(), + 'personal_team' => true, + ]; + } + + #[\Override] + public function modelName() + { + return Team::class; + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/User.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/User.php new file mode 100644 index 000000000000..3127bdd09e82 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/User.php @@ -0,0 +1,40 @@ +hasOne(Profile::class); + } + + public function posts() + { + return $this->hasMany(Post::class); + } + + public function comments() + { + return $this->hasMany(Comment::class); + } + + public function teams() + { + return $this->belongsToMany(Team::class) + ->withPivot('role') + ->withTimestamps() + ->using(Membership::class) + ->as('membership'); + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/UserResource.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/UserResource.php new file mode 100644 index 000000000000..55d8e411ff3f --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/UserResource.php @@ -0,0 +1,25 @@ + $this->name, + 'email' => $this->email, + ]; + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/migrations.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/migrations.php new file mode 100644 index 000000000000..26afadca7cb9 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/migrations.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('user_id')->index(); + $table->string('title'); + $table->text('content'); + $table->timestamps(); +}); + +Schema::create('profiles', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->unique(); + $table->date('date_of_birth')->nullable(); + $table->string('timezone')->nullable(); +}); + +Schema::create('teams', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->index(); + $table->string('name'); + $table->boolean('personal_team'); +}); + +Schema::create('team_user', function (Blueprint $table) { + $table->id(); + $table->foreignId('team_id'); + $table->foreignId('user_id'); + $table->string('role')->nullable(); + $table->timestamps(); + + $table->index(['team_id', 'user_id']); +}); + +Schema::create('comments', function (Blueprint $table) { + $table->id(); + $table->foreignId('post_id')->unique(); + $table->foreignId('user_id')->index()->nullable(); + $table->text('content'); + $table->timestamps(); +}); diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php new file mode 100644 index 000000000000..4a81ff4ff37f --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php @@ -0,0 +1,208 @@ +times(5)->create(); + + $this->getJson('/users') + ->assertHeader('Content-type', 'application/vnd.api+json') + ->assertJsonPath( + 'data', + $users->transform(fn ($user) => [ + 'id' => (string) $user->getKey(), + 'type' => 'users', + 'attributes' => [ + 'name' => $user->name, + 'email' => $user->email, + ], + ])->all() + )->assertJsonMissing(['jsonapi', 'included']); + } + + public function testItCanGenerateJsonApiResponseWithSparseFieldsets() + { + $users = User::factory()->times(5)->create(); + + $this->getJson('/users/?'.http_build_query(['fields' => ['users' => 'name']])) + ->assertHeader('Content-type', 'application/vnd.api+json') + ->assertJsonPath( + 'data', + $users->transform(fn ($user) => [ + 'id' => (string) $user->getKey(), + 'type' => 'users', + 'attributes' => [ + 'name' => $user->name, + ], + ])->all() + )->assertJsonMissing(['jsonapi', 'included']); + } + + public function testItCanGenerateJsonApiResponseWithEmptyRelationshipsUsingSparseIncluded() + { + $users = User::factory()->times(5)->create(); + + $this->getJson('/users/?'.http_build_query(['include' => 'posts'])) + ->assertHeader('Content-type', 'application/vnd.api+json') + ->assertJsonPath( + 'data', + $users->transform(fn ($user) => [ + 'id' => (string) $user->getKey(), + 'type' => 'users', + 'attributes' => [ + 'name' => $user->name, + 'email' => $user->email, + ], + 'relationships' => [ + 'posts' => [ + 'data' => [], + ], + ], + ])->all() + )->assertJsonMissing(['jsonapi', 'included']); + } + + public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncluded() + { + $now = $this->freezeSecond(); + + $users = User::factory()->times(4)->create(); + $user = User::factory()->create(); + + $profile = Profile::factory()->create([ + 'user_id' => $user->getKey(), + 'date_of_birth' => '2011-06-09', + 'timezone' => 'America/Chicago', + ]); + + $team = Team::factory()->create([ + 'name' => 'Laravel Team', + ]); + + $user->teams()->attach($team, ['role' => 'Admin']); + $user->teams()->attach($team, ['role' => 'Member']); + + $posts = Post::factory()->times(2)->create([ + 'user_id' => $user->getKey(), + ]); + + $this->getJson('/users?'.http_build_query(['include' => 'profile,posts,teams'])) + ->assertHeader('Content-type', 'application/vnd.api+json') + ->assertJsonPath( + 'data', + [ + ...$users->transform(fn ($user) => [ + 'id' => (string) $user->getKey(), + 'type' => 'users', + 'attributes' => [ + 'name' => $user->name, + 'email' => $user->email, + ], + 'relationships' => [ + 'profile' => ['data' => null], + 'posts' => ['data' => []], + 'teams' => ['data' => []], + ], + ])->all(), + [ + 'id' => (string) $user->getKey(), + 'type' => 'users', + 'attributes' => [ + 'name' => $user->name, + 'email' => $user->email, + ], + 'relationships' => [ + 'profile' => [ + 'data' => [ + 'id' => (string) $profile->getKey(), + 'type' => 'profiles', + ], + ], + 'posts' => [ + 'data' => [ + ['id' => (string) $posts[0]->getKey(), 'type' => 'posts'], + ['id' => (string) $posts[1]->getKey(), 'type' => 'posts'], + ], + ], + 'teams' => [ + 'data' => [ + ['id' => (string) $team->getKey(), 'type' => 'teams'], + ['id' => (string) $team->getKey(), 'type' => 'teams'], + ], + ], + ], + ], + ] + )->assertJsonPath( + 'included', + [ + [ + 'id' => (string) $profile->getKey(), + 'type' => 'profiles', + 'attributes' => [ + 'timezone' => 'America/Chicago', + 'date_of_birth' => '2011-06-09', + ], + ], + [ + 'id' => (string) $posts[0]->getKey(), + 'type' => 'posts', + 'attributes' => [ + 'title' => $posts[0]->title, + 'content' => $posts[0]->content, + ], + ], + [ + 'id' => (string) $posts[1]->getKey(), + 'type' => 'posts', + 'attributes' => [ + 'title' => $posts[1]->title, + 'content' => $posts[1]->content, + ], + ], + [ + 'id' => (string) $team->getKey(), + 'type' => 'teams', + 'attributes' => [ + 'id' => $team->getKey(), + 'user_id' => $team->user_id, + 'name' => 'Laravel Team', + 'personal_team' => true, + 'membership' => [ + 'user_id' => $user->getKey(), + 'team_id' => $team->getKey(), + 'role' => 'Admin', + 'created_at' => $now->toISOString(), + 'updated_at' => $now->toISOString(), + ], + ], + ], + [ + 'id' => (string) $team->getKey(), + 'type' => 'teams', + 'attributes' => [ + 'id' => $team->getKey(), + 'user_id' => $team->user_id, + 'name' => 'Laravel Team', + 'personal_team' => true, + 'membership' => [ + 'user_id' => $user->getKey(), + 'team_id' => $team->getKey(), + 'role' => 'Member', + 'created_at' => $now->toISOString(), + 'updated_at' => $now->toISOString(), + ], + ], + ], + ] + ); + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiRequestTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiRequestTest.php new file mode 100644 index 000000000000..ff96ec1f7879 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiRequestTest.php @@ -0,0 +1,65 @@ + [ + 'users' => 'name,email', + 'teams' => 'name', + ], + ])); + + $this->assertSame(['name', 'email'], $request->sparseFields('users')); + $this->assertSame(['name'], $request->sparseFields('teams')); + $this->assertSame([], $request->sparseFields('posts')); + } + + public function testItCanResolveEmptySparseFields() + { + $request = JsonApiRequest::create(uri: '/'); + + $this->assertSame([], $request->sparseFields('users')); + $this->assertSame([], $request->sparseFields('teams')); + $this->assertSame([], $request->sparseFields('posts')); + } + + public function testItCanResolveSparseIncluded() + { + $request = JsonApiRequest::create(uri: '/?'.http_build_query([ + 'include' => 'teams,posts.author,posts.comments,profile.user.profile', + ])); + + $this->assertSame(['teams', 'posts', 'profile'], $request->sparseIncluded()); + $this->assertSame([], $request->sparseIncluded('teams')); + $this->assertSame(['author', 'comments'], $request->sparseIncluded('posts')); + $this->assertSame(['user.profile'], $request->sparseIncluded('profile')); + } + + public function testItCanREsolveSparseIncludedWithMaxRelationshipNesting() + { + JsonApiResource::maxRelationshipDepth(2); + + $request = JsonApiRequest::create(uri: '/?'.http_build_query([ + 'include' => 'teams,posts.author,posts.comments,profile.user.profile', + ])); + + $this->assertSame(['teams', 'posts', 'profile'], $request->sparseIncluded()); + $this->assertSame([], $request->sparseIncluded('teams')); + $this->assertSame(['author', 'comments'], $request->sparseIncluded('posts')); + $this->assertSame(['user'], $request->sparseIncluded('profile')); + } + + public function testItCanResolveEmptySparseIncluded() + { + $request = JsonApiRequest::create(uri: '/'); + + $this->assertSame([], $request->sparseIncluded()); + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php new file mode 100644 index 000000000000..1d73038fcbfd --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php @@ -0,0 +1,607 @@ +create(); + + $this->getJson("/users/{$user->getKey()}") + ->assertHeader('Content-type', 'application/vnd.api+json') + ->assertExactJson([ + 'data' => [ + 'id' => (string) $user->getKey(), + 'type' => 'users', + 'attributes' => [ + 'name' => $user->name, + 'email' => $user->email, + ], + ], + ]) + ->assertJsonMissing(['jsonapi', 'included']); + } + + public function testItCanGenerateJsonApiResponseWithSparseFieldsets() + { + $user = User::factory()->create(); + + $this->getJson("/users/{$user->getKey()}?".http_build_query(['fields' => ['users' => 'name']])) + ->assertHeader('Content-type', 'application/vnd.api+json') + ->assertExactJson([ + 'data' => [ + 'id' => (string) $user->getKey(), + 'type' => 'users', + 'attributes' => [ + 'name' => $user->name, + ], + ], + ]) + ->assertJsonMissing(['jsonapi', 'included']); + } + + public function testItCanGenerateJsonApiResponseWithEmptyRelationshipsUsingSparseIncluded() + { + $user = User::factory()->create(); + + $this->getJson("/users/{$user->getKey()}?".http_build_query(['include' => 'posts'])) + ->assertHeader('Content-type', 'application/vnd.api+json') + ->assertExactJson([ + 'data' => [ + 'id' => (string) $user->getKey(), + 'type' => 'users', + 'attributes' => [ + 'name' => $user->name, + 'email' => $user->email, + ], + 'relationships' => [ + 'posts' => [ + 'data' => [], + ], + ], + ], + ]) + ->assertJsonMissing(['jsonapi', 'included']); + } + + public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncluded() + { + $now = $this->freezeSecond(); + + $user = User::factory()->create(); + + $profile = Profile::factory()->create([ + 'user_id' => $user->getKey(), + 'date_of_birth' => '2011-06-09', + 'timezone' => 'America/Chicago', + ]); + + $team = Team::factory()->create([ + 'name' => 'Laravel Team', + ]); + + $user->teams()->attach($team, ['role' => 'Admin']); + $user->teams()->attach($team, ['role' => 'Member']); + + $posts = Post::factory()->times(2)->create([ + 'user_id' => $user->getKey(), + ]); + + $this->getJson("/users/{$user->getKey()}?".http_build_query(['include' => 'profile,posts,teams'])) + ->assertHeader('Content-type', 'application/vnd.api+json') + ->assertExactJson([ + 'data' => [ + 'id' => (string) $user->getKey(), + 'type' => 'users', + 'attributes' => [ + 'name' => $user->name, + 'email' => $user->email, + ], + 'relationships' => [ + 'posts' => [ + 'data' => [ + ['id' => (string) $posts[0]->getKey(), 'type' => 'posts'], + ['id' => (string) $posts[1]->getKey(), 'type' => 'posts'], + ], + ], + 'profile' => [ + 'data' => [ + 'id' => (string) $profile->getKey(), + 'type' => 'profiles', + ], + ], + 'teams' => [ + 'data' => [ + ['id' => (string) $team->getKey(), 'type' => 'teams'], + ['id' => (string) $team->getKey(), 'type' => 'teams'], + ], + ], + ], + ], + 'included' => [ + [ + 'id' => (string) $profile->getKey(), + 'type' => 'profiles', + 'attributes' => [ + 'date_of_birth' => '2011-06-09', + 'timezone' => 'America/Chicago', + ], + ], + [ + 'id' => (string) $posts[0]->getKey(), + 'type' => 'posts', + 'attributes' => [ + 'title' => $posts[0]->title, + 'content' => $posts[0]->content, + ], + ], + [ + 'id' => (string) $posts[1]->getKey(), + 'type' => 'posts', + 'attributes' => [ + 'title' => $posts[1]->title, + 'content' => $posts[1]->content, + ], + ], + [ + 'id' => (string) $team->getKey(), + 'type' => 'teams', + 'attributes' => [ + 'id' => $team->getKey(), + 'user_id' => $team->user_id, + 'name' => 'Laravel Team', + 'personal_team' => true, + 'membership' => [ + 'created_at' => $now->toISOString(), + 'role' => 'Admin', + 'team_id' => $team->getKey(), + 'user_id' => $user->getKey(), + 'updated_at' => $now->toISOString(), + ], + ], + ], + [ + 'id' => (string) $team->getKey(), + 'type' => 'teams', + 'attributes' => [ + 'id' => $team->getKey(), + 'user_id' => $team->user_id, + 'name' => 'Laravel Team', + 'personal_team' => true, + 'membership' => [ + 'created_at' => $now->toISOString(), + 'role' => 'Member', + 'team_id' => $team->getKey(), + 'user_id' => $user->getKey(), + 'updated_at' => $now->toISOString(), + ], + ], + ], + ], + ]); + } + + public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncludedAndSparseFieldsets() + { + $now = $this->freezeSecond(); + + $user = User::factory()->create(); + + [$post1, $post2] = Post::factory()->times(2)->create([ + 'user_id' => $user->getKey(), + ]); + + $this->getJson('/posts?'.http_build_query(['include' => 'author', 'fields' => ['authors' => 'name']])) + ->assertHeader('Content-type', 'application/vnd.api+json') + ->assertExactJson([ + 'data' => [ + [ + 'attributes' => [ + 'content' => $post1->content, + 'title' => $post1->title, + ], + 'type' => 'posts', + 'id' => (string) $post1->getKey(), + 'relationships' => [ + 'author' => [ + 'data' => [ + 'id' => (string) $user->getKey(), + 'type' => 'authors', + ], + ], + ], + ], + [ + 'attributes' => [ + 'content' => $post2->content, + 'title' => $post2->title, + ], + 'type' => 'posts', + 'id' => (string) $post2->getKey(), + 'relationships' => [ + 'author' => [ + 'data' => [ + 'id' => (string) $user->getKey(), + 'type' => 'authors', + ], + ], + ], + ], + ], + 'included' => [ + [ + 'attributes' => [ + 'name' => $user->name, + ], + 'id' => (string) $user->getKey(), + 'type' => 'authors', + ], + ], + 'links' => [ + 'first' => url('/posts?page=1'), + 'last' => url('/posts?page=1'), + 'next' => null, + 'prev' => null, + ], + 'meta' => [ + 'current_page' => 1, + 'from' => 1, + 'last_page' => 1, + 'links' => [ + ['active' => false, 'label' => '« Previous', 'page' => null, 'url' => null], + ['active' => true, 'label' => '1', 'page' => 1, 'url' => url('/posts?page=1')], + ['active' => false, 'label' => 'Next »', 'page' => null, 'url' => null], + ], + 'path' => url('/posts'), + 'per_page' => 5, + 'to' => 2, + 'total' => 2, + ], + ]) + ->assertJsonCount(1, 'included') + ->assertJsonMissing(['jsonapi']); + } + + public function testItCanResolveRelationshipWithCustomNameAndResourceClass() + { + $now = $this->freezeSecond(); + + $user = User::factory()->create(); + + $profile = Profile::factory()->create([ + 'user_id' => $user->getKey(), + 'date_of_birth' => '2011-06-09', + 'timezone' => 'America/Chicago', + ]); + + [$post1, $post2] = Post::factory()->times(2)->create([ + 'user_id' => $user->getKey(), + ]); + + $comment = Comment::factory()->create([ + 'post_id' => $post1->getKey(), + 'user_id' => $user->getKey(), + ]); + + $this->getJson("/posts/{$post1->getKey()}?".http_build_query(['include' => 'author'])) + ->assertHeader('Content-type', 'application/vnd.api+json') + ->assertExactJson([ + 'data' => [ + 'attributes' => [ + 'content' => $post1->content, + 'title' => $post1->title, + ], + 'type' => 'posts', + 'id' => (string) $post1->getKey(), + 'relationships' => [ + 'author' => [ + 'data' => [ + 'id' => (string) $user->getKey(), + 'type' => 'authors', + ], + ], + ], + ], + 'included' => [ + [ + 'attributes' => [ + 'email' => $user->email, + 'name' => $user->name, + ], + 'id' => (string) $user->getKey(), + 'type' => 'authors', + ], + ], + ]) + ->assertJsonMissing(['jsonapi']); + } + + public function testItCanResolveRelationshipWithNestedRelationship() + { + $now = $this->freezeSecond(); + + $user = User::factory()->create(); + + $profile = Profile::factory()->create([ + 'user_id' => $user->getKey(), + 'date_of_birth' => '2011-06-09', + 'timezone' => 'America/Chicago', + ]); + + [$post1, $post2] = Post::factory()->times(2)->create([ + 'user_id' => $user->getKey(), + ]); + + $comment = Comment::factory()->create([ + 'post_id' => $post1->getKey(), + 'user_id' => $user->getKey(), + ]); + + $this->getJson("/posts/{$post1->getKey()}?".http_build_query(['include' => 'author,comments.commenter'])) + ->assertHeader('Content-type', 'application/vnd.api+json') + ->assertExactJson([ + 'data' => [ + 'attributes' => [ + 'content' => $post1->content, + 'title' => $post1->title, + ], + 'type' => 'posts', + 'id' => (string) $post1->getKey(), + 'relationships' => [ + 'author' => [ + 'data' => [ + 'id' => (string) $user->getKey(), + 'type' => 'authors', + ], + ], + 'comments' => [ + 'data' => [ + ['id' => (string) $comment->getKey(), 'type' => 'comments'], + ], + ], + ], + ], + 'included' => [ + [ + 'attributes' => [ + 'email' => $user->email, + 'name' => $user->name, + ], + 'id' => (string) $user->getKey(), + 'type' => 'authors', + ], + [ + 'attributes' => [ + 'content' => $comment->content, + ], + 'id' => (string) $comment->getKey(), + 'type' => 'comments', + 'relationships' => [ + 'commenter' => [ + 'data' => [ + 'id' => (string) $user->getKey(), + 'type' => 'users', + ], + ], + ], + ], + [ + 'attributes' => [ + 'email' => $user->email, + 'name' => $user->name, + ], + 'id' => (string) $user->getKey(), + 'type' => 'users', + ], + ], + ]) + ->assertJsonMissing(['jsonapi']); + } + + public function testItCanResolveRelationshipWithRecursiveNestedRelationship() + { + $now = $this->freezeSecond(); + + $user = User::factory()->create(); + + $profile = Profile::factory()->create([ + 'user_id' => $user->getKey(), + 'date_of_birth' => '2011-06-09', + 'timezone' => 'America/Chicago', + ]); + + $this->getJson("/users/{$user->getKey()}?".http_build_query(['include' => 'profile.user.profile'])) + ->assertHeader('Content-type', 'application/vnd.api+json') + ->assertExactJson([ + 'data' => [ + 'attributes' => [ + 'email' => $user->email, + 'name' => $user->name, + ], + 'id' => (string) $user->getKey(), + 'type' => 'users', + 'relationships' => [ + 'profile' => [ + 'data' => ['id' => (string) $profile->getKey(), 'type' => 'profiles'], + ], + ], + ], + 'included' => [ + [ + 'attributes' => [ + 'date_of_birth' => '2011-06-09', + 'timezone' => 'America/Chicago', + ], + 'id' => (string) $profile->getKey(), + 'type' => 'profiles', + 'relationships' => [ + 'user' => [ + 'data' => ['id' => (string) $user->getKey(), 'type' => 'users'], + ], + ], + ], + [ + 'attributes' => [ + 'email' => $user->email, + 'name' => $user->name, + ], + 'id' => (string) $user->getKey(), + 'type' => 'users', + 'relationships' => [ + 'profile' => [ + 'data' => ['id' => (string) $profile->getKey(), 'type' => 'profiles'], + ], + ], + ], + ], + ]) + ->assertJsonMissing(['jsonapi']); + } + + public function testItCanResolveRelationshipWithRecursiveNestedRelationshipLimitedToDepthConfiguration() + { + JsonApiResource::maxRelationshipDepth(2); + + $now = $this->freezeSecond(); + + $user = User::factory()->create(); + + $profile = Profile::factory()->create([ + 'user_id' => $user->getKey(), + 'date_of_birth' => '2011-06-09', + 'timezone' => 'America/Chicago', + ]); + + $this->getJson("/users/{$user->getKey()}?".http_build_query(['include' => 'profile.user.profile'])) + ->assertHeader('Content-type', 'application/vnd.api+json') + ->assertExactJson([ + 'data' => [ + 'attributes' => [ + 'email' => $user->email, + 'name' => $user->name, + ], + 'id' => (string) $user->getKey(), + 'type' => 'users', + 'relationships' => [ + 'profile' => [ + 'data' => ['id' => (string) $profile->getKey(), 'type' => 'profiles'], + ], + ], + ], + 'included' => [ + [ + 'attributes' => [ + 'date_of_birth' => '2011-06-09', + 'timezone' => 'America/Chicago', + ], + 'id' => (string) $profile->getKey(), + 'type' => 'profiles', + 'relationships' => [ + 'user' => [ + 'data' => ['id' => (string) $user->getKey(), 'type' => 'users'], + ], + ], + ], + [ + 'attributes' => [ + 'email' => $user->email, + 'name' => $user->name, + ], + 'id' => (string) $user->getKey(), + 'type' => 'users', + ], + ], + ]) + ->assertJsonMissing(['jsonapi']); + } + + public function testItCanResolveRelationshipWithoutRedundantIncludedRelationship() + { + $now = $this->freezeSecond(); + + $user = User::factory()->create(); + + [$post1, $post2] = Post::factory()->times(2)->create([ + 'user_id' => $user->getKey(), + ]); + + $this->getJson('/posts?'.http_build_query(['include' => 'author'])) + ->assertHeader('Content-type', 'application/vnd.api+json') + ->assertExactJson([ + 'data' => [ + [ + 'attributes' => [ + 'content' => $post1->content, + 'title' => $post1->title, + ], + 'type' => 'posts', + 'id' => (string) $post1->getKey(), + 'relationships' => [ + 'author' => [ + 'data' => [ + 'id' => (string) $user->getKey(), + 'type' => 'authors', + ], + ], + ], + ], + [ + 'attributes' => [ + 'content' => $post2->content, + 'title' => $post2->title, + ], + 'type' => 'posts', + 'id' => (string) $post2->getKey(), + 'relationships' => [ + 'author' => [ + 'data' => [ + 'id' => (string) $user->getKey(), + 'type' => 'authors', + ], + ], + ], + ], + ], + 'included' => [ + [ + 'attributes' => [ + 'email' => $user->email, + 'name' => $user->name, + ], + 'id' => (string) $user->getKey(), + 'type' => 'authors', + ], + ], + 'links' => [ + 'first' => url('/posts?page=1'), + 'last' => url('/posts?page=1'), + 'next' => null, + 'prev' => null, + ], + 'meta' => [ + 'current_page' => 1, + 'from' => 1, + 'last_page' => 1, + 'links' => [ + ['active' => false, 'label' => '« Previous', 'page' => null, 'url' => null], + ['active' => true, 'label' => '1', 'page' => 1, 'url' => url('/posts?page=1')], + ['active' => false, 'label' => 'Next »', 'page' => null, 'url' => null], + ], + 'path' => url('/posts'), + 'per_page' => 5, + 'to' => 2, + 'total' => 2, + ], + ]) + ->assertJsonCount(1, 'included') + ->assertJsonMissing(['jsonapi']); + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/TestCase.php b/tests/Integration/Http/Resources/JsonApi/TestCase.php new file mode 100644 index 000000000000..aa87168d426a --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/TestCase.php @@ -0,0 +1,65 @@ +get('users', function () { + return User::paginate(5)->toResourceCollection(); + }); + + $router->get('users/{userId}', function ($userId) { + return User::find($userId)->toResource(); + }); + + $router->get('posts', function () { + return Post::paginate(5)->toResourceCollection(); + }); + + $router->get('posts/{postId}', function ($postId) { + return Post::find($postId)->toResource(); + }); + } + + /** {@inheritdoc} */ + protected function afterRefreshingDatabase() + { + require __DIR__.'/Fixtures/migrations.php'; + } +}