diff --git a/src/Illuminate/Http/Resources/Json/JsonResource.php b/src/Illuminate/Http/Resources/Json/JsonResource.php index 02ebce156d8c..7590a95d8eb0 100644 --- a/src/Illuminate/Http/Resources/Json/JsonResource.php +++ b/src/Illuminate/Http/Resources/Json/JsonResource.php @@ -111,7 +111,7 @@ protected static function newCollection($resource) */ public function resolve($request = null) { - $data = $this->toAttributes( + $data = $this->resolveResourceData( $request ?: $this->resolveRequestFromContainer() ); @@ -132,9 +132,24 @@ public function resolve($request = null) */ 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. * diff --git a/src/Illuminate/Http/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php b/src/Illuminate/Http/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php index 2b2a4ef4e5da..1ea9188f7792 100644 --- a/src/Illuminate/Http/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php +++ b/src/Illuminate/Http/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php @@ -2,6 +2,7 @@ namespace Illuminate\Http\Resources\JsonApi\Concerns; +use Generator; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\AsPivot; @@ -12,21 +13,32 @@ use Illuminate\Http\Resources\JsonApi\Exceptions\ResourceIdentificationException; use Illuminate\Http\Resources\JsonApi\JsonApiRequest; use Illuminate\Http\Resources\JsonApi\JsonApiResource; +use Illuminate\Http\Resources\JsonApi\RelationResolver; use Illuminate\Http\Resources\MissingValue; use Illuminate\Support\Arr; use Illuminate\Support\Collection; +use Illuminate\Support\LazyCollection; use Illuminate\Support\Str; use JsonSerializable; -use WeakMap; trait ResolvesJsonApiElements { + /** + * Determine whether resources respect inclusions and fields from the request. + */ + protected bool $usesRequestQueryString = true; + + /** + * Determine whether included relationship for the resource from eager loaded relationship. + */ + protected bool $includesPreviouslyLoadedRelationships = false; + /** * Cached loaded relationships map. * - * @var \WeakMap|null + * @var arrayresolveResourceType($request); @@ -59,7 +71,7 @@ public function resolveResourceData(JsonApiRequest $request): array * * @throws ResourceIdentificationException */ - protected function resolveResourceIdentifier(JsonApiRequest $request): string + public function resolveResourceIdentifier(JsonApiRequest $request): string { if (! is_null($resourceId = $this->toId($request))) { return $resourceId; @@ -78,7 +90,7 @@ protected function resolveResourceIdentifier(JsonApiRequest $request): string * * @throws ResourceIdentificationException */ - protected function resolveResourceType(JsonApiRequest $request): string + public function resolveResourceType(JsonApiRequest $request): string { if (! is_null($resourceType = $this->toType($request))) { return $resourceType; @@ -107,11 +119,15 @@ protected function resolveResourceAttributes(JsonApiRequest $request, string $re $data = $data->jsonSerialize(); } - $sparseFieldset = $request->sparseFields($resourceType); + $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)) + ->reject(fn ($value, $key) => $key === $this->resource->getKey()) ->transform(fn ($value) => value($value, $request)) ->all(); @@ -136,7 +152,7 @@ protected function resolveResourceRelationshipIdentifiers(JsonApiRequest $reques return [ ...(new Collection($this->filter($this->loadedRelationshipIdentifiers))) ->map(function ($relation) { - return ! is_null($relation) ? $relation : ['data' => []]; + return ! is_null($relation) ? $relation : ['data' => null]; })->all(), ]; } @@ -146,67 +162,135 @@ protected function resolveResourceRelationshipIdentifiers(JsonApiRequest $reques */ protected function compileResourceRelationships(JsonApiRequest $request): void { - if ($this->loadedRelationshipsMap instanceof WeakMap) { + if (! is_null($this->loadedRelationshipsMap)) { return; } - $sparseIncluded = $request->sparseIncluded(); + $sparseIncluded = match (true) { + $this->includesPreviouslyLoadedRelationships => array_keys($this->resource->getRelations()), + default => $request->sparseIncluded(), + }; $resourceRelationships = (new Collection($this->toRelationships($request))) - ->mapWithKeys(fn ($value, $key) => is_int($key) ? [$value => fn () => $this->resource->{$value}] : [$key => $value]) + ->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->resource->loadMissing($resourceRelationshipKeys->all() ?? []); - $this->loadedRelationshipsMap = new WeakMap; + $this->loadedRelationshipsMap = []; - $this->loadedRelationshipIdentifiers = $resourceRelationships->mapWithKeys(function ($relationResolver, $key) { - $relatedModels = value($relationResolver); + $this->loadedRelationshipIdentifiers = (new LazyCollection(function () use ($request, $resourceRelationships) { + foreach ($resourceRelationships as $relationName => $relationResolver) { + $relatedModels = $relationResolver->handle($this->resource); + $relatedResourceClass = $relationResolver->resourceClass(); - // Relationship is a collection of models... - if ($relatedModels instanceof Collection) { - $relatedModels = $relatedModels->values(); - - if ($relatedModels->isEmpty()) { - return [$key => ['data' => $relatedModels]]; + if (! is_null($relatedModels)) { + $relatedModels->loadMissing($request->sparseIncluded($relationName)); } - $relationship = $this->resource->{$key}(); + yield from $this->compileResourceRelationshipUsingResolver( + $request, + $this->resource, + $relationResolver, + $relatedModels, + ); + } + }))->all(); + } - $isUnique = ! $relationship instanceof BelongsToMany; + /** + * 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; + } - $key = static::resourceTypeFromModel($relatedModels->first()); + $relationship = $resource->{$relationName}(); - return [$key => ['data' => $relatedModels->map(function ($relation) use ($key, $isUnique) { - return transform([$key, static::resourceIdFromModel($relation)], function ($uniqueKey) use ($relation, $isUnique) { - $this->loadedRelationshipsMap[$relation] = [...$uniqueKey, $isUnique]; + $isUnique = ! $relationship instanceof BelongsToMany; - return ['id' => $uniqueKey[1], 'type' => $uniqueKey[0]]; - }); - })]]; - } + yield $relationName => ['data' => $relatedModels->map(function ($relatedModel) use ($request, $resourceClass, $isUnique) { + $relatedResource = rescue(fn () => $relatedModel->toResource($resourceClass), new JsonApiResource($relatedModel)); - // Relationship is a single model... - $relatedModel = $relatedModels; + return transform( + [$relatedResource->resolveResourceType($request), $relatedResource->resolveResourceIdentifier($request)], + function ($uniqueKey) use ($request, $relatedModel, $relatedResource, $isUnique) { + $this->loadedRelationshipsMap[] = [$relatedResource, ...$uniqueKey, $isUnique]; - if (is_null($relatedModel)) { - return [$key => null]; - } elseif ($relatedModel instanceof Pivot || - in_array(AsPivot::class, class_uses_recursive($relatedModel), true)) { - return [$key => new MissingValue]; - } + $this->compileIncludedNestedRelationshipsMap($request, $relatedModel, $relatedResource); - return [$key => ['data' => [transform( - [static::resourceTypeFromModel($relatedModel), static::resourceIdFromModel($relatedModel)], - function ($uniqueKey) use ($relatedModel) { - $this->loadedRelationshipsMap[$relatedModel] = [...$uniqueKey, true]; + return [ + 'id' => $uniqueKey[1], + 'type' => $uniqueKey[0], + ]; + } + ); + })->all()]; - 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)); + }); } /** @@ -222,22 +306,34 @@ public function resolveIncludedResources(JsonApiRequest $request): array $relations = new Collection; - foreach ($this->loadedRelationshipsMap as $relation => $value) { - $resourceInstance = rescue(fn () => $relation->toResource(), new JsonApiResource($relation), false); + $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); } - [$type, $id, $isUnique] = $value; + $relationsData = $resourceInstance + ->withoutRequestQueryString() + ->includePreviouslyLoadedRelationships() + ->resolve($request); - $relations->push([ + array_push($this->loadedRelationshipsMap, ...$resourceInstance->loadedRelationshipsMap); + + $relations->push(array_filter([ 'id' => $id, 'type' => $type, '_uniqueKey' => $isUnique === true ? [$id, $type] : [$id, $type, (string) Str::random()], - 'attributes' => Arr::get($resourceInstance->resolve($request), 'data.attributes', []), - ]); + '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->uniqueStrict(fn ($relation) => $relation['_uniqueKey']) @@ -268,7 +364,7 @@ protected function resolveResourceMetaInformation(JsonApiRequest $request): arra /** * Get the resource ID from the given Eloquent model. */ - protected static function resourceIdFromModel(Model $model): string + public static function resourceIdFromModel(Model $model): string { return $model->getKey(); } @@ -276,14 +372,56 @@ protected static function resourceIdFromModel(Model $model): string /** * Get the resource type from the given Eloquent model. */ - protected static function resourceTypeFromModel(Model $model): string + public static function resourceTypeFromModel(Model $model): string { $modelClassName = $model::class; $morphMap = Relation::getMorphAlias($modelClassName); - return Str::of( + return static::normalizeResourceType( $morphMap !== $modelClassName ? $morphMap : class_basename($modelClassName) - )->snake()->pluralStudly(); + ); + } + + /** + * Indicate that relationship loading should respect the request's "includes" query string. + * + * @return $this + */ + public function withRequestQueryString(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 withoutRequestQueryString() + { + return $this->withRequestQueryString(false); + } + + /** + * Determine relationship should include loaded relationships. + * + * @return $this + */ + public function includePreviouslyLoadedRelationships() + { + $this->includesPreviouslyLoadedRelationships = true; + + return $this; + } + + /** + * Normalize the resource type. + */ + public static function normalizeResourceType(string $value): string + { + return Str::of($value)->snake()->pluralStudly(); } } diff --git a/src/Illuminate/Http/Resources/JsonApi/JsonApiRequest.php b/src/Illuminate/Http/Resources/JsonApi/JsonApiRequest.php index c5f0f194b1bb..c869f1382578 100644 --- a/src/Illuminate/Http/Resources/JsonApi/JsonApiRequest.php +++ b/src/Illuminate/Http/Resources/JsonApi/JsonApiRequest.php @@ -3,31 +3,63 @@ namespace Illuminate\Http\Resources\JsonApi; use Illuminate\Http\Request; -use Illuminate\Support\Arr; +use Illuminate\Support\Collection; class JsonApiRequest extends Request { + /** + * Cached sparse fieldset. + */ + protected ?array $cachedSparseFields = null; + + /** + * Cached sparse included. + */ + protected ?array $cachedSparseIncluded = null; + /** * Get the request's included fields. */ public function sparseFields(string $key): array { - $fieldsets = Arr::get($this->array('fields'), $key, ''); + if (is_null($this->cachedSparseFields)) { + $this->cachedSparseFields = (new Collection($this->array('fields'))) + ->transform(fn ($fieldsets) => empty($fieldsets) ? [] : explode(',', $fieldsets)) + ->all(); + } - return empty($fieldsets) - ? [] - : explode(',', $fieldsets); + return $this->cachedSparseFields[$key] ?? []; } /** * Get the request's included relationships. */ - public function sparseIncluded(): array + public function sparseIncluded(?string $key = null): ?array { - $included = (string) $this->string('include', ''); + 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 empty($included) - ? [] - : explode(',', $included); + return transform($this->cachedSparseIncluded[$key] ?? null, function ($value) { + return array_filter($value); + }) ?? []; } } diff --git a/src/Illuminate/Http/Resources/JsonApi/JsonApiResource.php b/src/Illuminate/Http/Resources/JsonApi/JsonApiResource.php index 003ffa41a6d1..e764132d197e 100644 --- a/src/Illuminate/Http/Resources/JsonApi/JsonApiResource.php +++ b/src/Illuminate/Http/Resources/JsonApi/JsonApiResource.php @@ -153,6 +153,18 @@ public function resolve($request = null) ]; } + /** + * 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. */ 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/Integration/Http/Resources/JsonApi/Fixtures/AuthorApiResource.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/AuthorApiResource.php new file mode 100644 index 000000000000..227723bbc1b2 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/AuthorApiResource.php @@ -0,0 +1,29 @@ + $this->name, + 'email' => $this->email, + ]; + } + + #[\Override] + public function toType(Request $request) + { + return 'authors'; + } +} 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..aa9d4926db88 --- /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/CommentApiResource.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/CommentApiResource.php new file mode 100644 index 000000000000..d874417ebd30 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/CommentApiResource.php @@ -0,0 +1,23 @@ + UserApiResource::class, + ]; +} 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/Post.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/Post.php index 143758e2c966..1c583b9ce9cb 100644 --- a/tests/Integration/Http/Resources/JsonApi/Fixtures/Post.php +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/Post.php @@ -13,8 +13,13 @@ class Post extends Model { use HasFactory; - public function user() + public function comments() { - return $this->belongsTo(User::class); + return $this->hasMany(Comment::class); + } + + public function author() + { + return $this->belongsTo(User::class, 'user_id'); } } diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/PostApiResource.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/PostApiResource.php index 6309e6cc5c91..874b703577a3 100644 --- a/tests/Integration/Http/Resources/JsonApi/Fixtures/PostApiResource.php +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/PostApiResource.php @@ -10,4 +10,9 @@ class PostApiResource extends JsonApiResource 'title', 'content', ]; + + protected array $relationships = [ + 'author' => AuthorApiResource::class, + 'comments', + ]; } diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/User.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/User.php index ef7cac2097a2..a7adc43ba121 100644 --- a/tests/Integration/Http/Resources/JsonApi/Fixtures/User.php +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/User.php @@ -24,6 +24,11 @@ public function posts() return $this->hasMany(Post::class); } + public function comments() + { + return $this->hasMany(Comment::class); + } + public function teams() { return $this->belongsToMany(Team::class) diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/UserApiResource.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/UserApiResource.php index 7d1a7c7918c5..6fe76f81db1d 100644 --- a/tests/Integration/Http/Resources/JsonApi/Fixtures/UserApiResource.php +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/UserApiResource.php @@ -8,11 +8,13 @@ class UserApiResource extends JsonApiResource { protected array $relationships = [ + 'comments', 'profile', 'posts', 'teams', ]; + #[\Override] public function toAttributes(Request $request) { return [ @@ -20,4 +22,10 @@ public function toAttributes(Request $request) 'email' => $this->email, ]; } + + #[\Override] + public function toType(Request $request) + { + return 'users'; + } } diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/UserResource.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/UserResource.php index 7ddd2248a37b..05b5e843fccd 100644 --- a/tests/Integration/Http/Resources/JsonApi/Fixtures/UserResource.php +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/UserResource.php @@ -7,6 +7,7 @@ class UserResource extends JsonResource { + #[\Override] public function toArray(Request $request) { return [ diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/migrations.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/migrations.php index e560a3eb970a..26afadca7cb9 100644 --- a/tests/Integration/Http/Resources/JsonApi/Fixtures/migrations.php +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/migrations.php @@ -34,3 +34,11 @@ $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 index b295eddd36cb..0726a1259a0a 100644 --- a/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php @@ -107,7 +107,7 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl 'email' => $user->email, ], 'relationships' => [ - 'profile' => ['data' => []], + 'profile' => ['data' => null], 'posts' => ['data' => []], 'teams' => ['data' => []], ], @@ -122,7 +122,8 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl 'relationships' => [ 'profile' => [ 'data' => [ - ['id' => (string) $profile->getKey(), 'type' => 'profiles'], + 'id' => (string) $profile->getKey(), + 'type' => 'profiles', ], ], 'posts' => [ diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php index fe166c5c10ba..82909d11ffdc 100644 --- a/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php @@ -2,6 +2,7 @@ namespace Illuminate\Tests\Integration\Http\Resources\JsonApi; +use Illuminate\Tests\Integration\Http\Resources\JsonApi\Fixtures\Comment; use Illuminate\Tests\Integration\Http\Resources\JsonApi\Fixtures\Post; use Illuminate\Tests\Integration\Http\Resources\JsonApi\Fixtures\Profile; use Illuminate\Tests\Integration\Http\Resources\JsonApi\Fixtures\Team; @@ -112,7 +113,8 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl ], 'profile' => [ 'data' => [ - ['id' => (string) $profile->getKey(), 'type' => 'profiles'], + 'id' => (string) $profile->getKey(), + 'type' => 'profiles', ], ], 'teams' => [ @@ -187,4 +189,140 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl ], ]); } + + public function test_it_can_resolve_relationship_with_custom_name_and_resource_class() + { + $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 test_it_can_resolve_relationship_with_nested_relationship() + { + $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']); + } } diff --git a/tests/Integration/Http/Resources/JsonApi/TestCase.php b/tests/Integration/Http/Resources/JsonApi/TestCase.php index a1644383a7cb..aa87168d426a 100644 --- a/tests/Integration/Http/Resources/JsonApi/TestCase.php +++ b/tests/Integration/Http/Resources/JsonApi/TestCase.php @@ -2,9 +2,11 @@ namespace Illuminate\Tests\Integration\Http\Resources\JsonApi; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Testing\LazilyRefreshDatabase; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\JsonApi\JsonApiResource; +use Illuminate\Tests\Integration\Http\Resources\JsonApi\Fixtures\Post; use Illuminate\Tests\Integration\Http\Resources\JsonApi\Fixtures\User; use Orchestra\Testbench\Attributes\WithConfig; use Orchestra\Testbench\Attributes\WithMigration; @@ -15,6 +17,15 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase { use LazilyRefreshDatabase; + /** {@inheritdoc} */ + #[\Override] + protected function setUp(): void + { + Model::shouldBeStrict(true); + + parent::setUp(); + } + /** {@inheritdoc} */ #[\Override] protected function tearDown(): void @@ -36,6 +47,14 @@ protected function defineRoutes($router) $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} */