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->toAttributes( + $request ?: $this->resolveRequestFromContainer() ); if ($data instanceof Arrayable) { @@ -124,6 +124,17 @@ 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) + { + return $this->toArray($request); + } + /** * Transform the resource into an array. * @@ -219,6 +230,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 +270,7 @@ public static function withoutWrapping() public function response($request = null) { return $this->toResponse( - $request ?: Container::getInstance()->make('request') + $request ?: $this->resolveRequestFromContainer() ); } @@ -271,6 +292,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 81cfc1bd3181..025ab9b95f42 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..08247507ca36 --- /dev/null +++ b/src/Illuminate/Http/Resources/JsonApi/AnonymousResourceCollection.php @@ -0,0 +1,82 @@ + $this->collection + ->map(fn ($resource) => $resource->resolveIncludedResources($request)) + ->flatten(depth: 1) + ->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..2b2a4ef4e5da --- /dev/null +++ b/src/Illuminate/Http/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php @@ -0,0 +1,289 @@ +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 + */ + protected function resolveResourceIdentifier(JsonApiRequest $request): string + { + if (! is_null($resourceId = $this->toId($request))) { + return $resourceId; + } + + if (! $this->resource instanceof Model) { + throw ResourceIdentificationException::attemptingToDetermineIdFor($this); + } + + return static::resourceIdFromModel($this->resource); + } + + /** + * Resolve the resource's type. + * + * + * @throws ResourceIdentificationException + */ + protected function resolveResourceType(JsonApiRequest $request): string + { + if (! is_null($resourceType = $this->toType($request))) { + return $resourceType; + } + + if (! $this->resource instanceof Model) { + throw ResourceIdentificationException::attemptingToDetermineTypeFor($this); + } + + return static::resourceTypeFromModel($this->resource); + } + + /** + * 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 = $request->sparseFields($resourceType); + + $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' => []]; + })->all(), + ]; + } + + /** + * Compile resource relationships. + */ + protected function compileResourceRelationships(JsonApiRequest $request): void + { + if ($this->loadedRelationshipsMap instanceof WeakMap) { + return; + } + + $sparseIncluded = $request->sparseIncluded(); + + $resourceRelationships = (new Collection($this->toRelationships($request))) + ->mapWithKeys(fn ($value, $key) => is_int($key) ? [$value => fn () => $this->resource->{$value}] : [$key => $value]) + ->filter(fn ($value, $key) => in_array($key, $sparseIncluded)); + + $resourceRelationshipKeys = $resourceRelationships->keys(); + + $this->resource->loadMissing($resourceRelationshipKeys->all()); + + $this->loadedRelationshipsMap = new WeakMap; + + $this->loadedRelationshipIdentifiers = $resourceRelationships->mapWithKeys(function ($relationResolver, $key) { + $relatedModels = value($relationResolver); + + // Relationship is a collection of models... + if ($relatedModels instanceof Collection) { + $relatedModels = $relatedModels->values(); + + if ($relatedModels->isEmpty()) { + return [$key => ['data' => $relatedModels]]; + } + + $relationship = $this->resource->{$key}(); + + $isUnique = ! $relationship instanceof BelongsToMany; + + $key = static::resourceTypeFromModel($relatedModels->first()); + + 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]; + + return ['id' => $uniqueKey[1], 'type' => $uniqueKey[0]]; + }); + })]]; + } + + // Relationship is a single model... + $relatedModel = $relatedModels; + + if (is_null($relatedModel)) { + return [$key => null]; + } elseif ($relatedModel instanceof Pivot || + in_array(AsPivot::class, class_uses_recursive($relatedModel), true)) { + return [$key => new MissingValue]; + } + + 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(); + } + + /** + * Resolves `included` for the resource. + */ + public function resolveIncludedResources(JsonApiRequest $request): array + { + if (! $this->resource instanceof Model) { + return []; + } + + $this->compileResourceRelationships($request); + + $relations = new Collection; + + foreach ($this->loadedRelationshipsMap as $relation => $value) { + $resourceInstance = rescue(fn () => $relation->toResource(), new JsonApiResource($relation), false); + + if (! $resourceInstance instanceof JsonApiResource && + $resourceInstance instanceof JsonResource) { + $resourceInstance = new JsonApiResource($resourceInstance->resource); + } + + [$type, $id, $isUnique] = $value; + + $relations->push([ + 'id' => $id, + 'type' => $type, + '_uniqueKey' => $isUnique === true ? [$id, $type] : [$id, $type, (string) Str::random()], + 'attributes' => Arr::get($resourceInstance->resolve($request), 'data.attributes', []), + ]); + } + + return $relations->uniqueStrict(fn ($relation) => $relation['_uniqueKey']) + ->map(fn ($relation) => Arr::except($relation, ['_uniqueKey'])) + ->all(); + } + + /** + * 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); + } + + /** + * Get the resource ID from the given Eloquent model. + */ + protected static function resourceIdFromModel(Model $model): string + { + return $model->getKey(); + } + + /** + * Get the resource type from the given Eloquent model. + */ + protected static function resourceTypeFromModel(Model $model): string + { + $modelClassName = $model::class; + + $morphMap = Relation::getMorphAlias($modelClassName); + + return Str::of( + $morphMap !== $modelClassName ? $morphMap : class_basename($modelClassName) + )->snake()->pluralStudly(); + } +} 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 @@ +array('fields'), $key, ''); + + return empty($fieldsets) + ? [] + : explode(',', $fieldsets); + } + + /** + * Get the request's included relationships. + */ + public function sparseIncluded(): array + { + $included = (string) $this->string('include', ''); + + return empty($included) + ? [] + : explode(',', $included); + } +} diff --git a/src/Illuminate/Http/Resources/JsonApi/JsonApiResource.php b/src/Illuminate/Http/Resources/JsonApi/JsonApiResource.php new file mode 100644 index 000000000000..003ffa41a6d1 --- /dev/null +++ b/src/Illuminate/Http/Resources/JsonApi/JsonApiResource.php @@ -0,0 +1,237 @@ + $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->resolveIncludedResources($request), + ...($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())), + ]; + } + + /** + * 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 = []; + } +} 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/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 @@ +belongsTo(User::class); + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/Fixtures/PostApiResource.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/PostApiResource.php new file mode 100644 index 000000000000..6309e6cc5c91 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/PostApiResource.php @@ -0,0 +1,13 @@ + 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/Profile.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/Profile.php new file mode 100644 index 000000000000..d8c29e2fa5b1 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/Profile.php @@ -0,0 +1,20 @@ +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/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..ef7cac2097a2 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/User.php @@ -0,0 +1,35 @@ +hasOne(Profile::class); + } + + public function posts() + { + return $this->hasMany(Post::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/UserApiResource.php b/tests/Integration/Http/Resources/JsonApi/Fixtures/UserApiResource.php new file mode 100644 index 000000000000..7d1a7c7918c5 --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/UserApiResource.php @@ -0,0 +1,23 @@ + $this->name, + 'email' => $this->email, + ]; + } +} 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..7ddd2248a37b --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/UserResource.php @@ -0,0 +1,17 @@ + $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..e560a3eb970a --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/Fixtures/migrations.php @@ -0,0 +1,36 @@ +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']); +}); diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php new file mode 100644 index 000000000000..b295eddd36cb --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php @@ -0,0 +1,209 @@ +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' => []], + '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' => [ + 'id' => $profile->getKey(), + 'user_id' => $user->getKey(), + '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' => [ + '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..39af5098153a --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiRequestTest.php @@ -0,0 +1,47 @@ + [ + '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,users', + ])); + + $this->assertSame(['teams', 'users'], $request->sparseIncluded()); + } + + 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..fe166c5c10ba --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php @@ -0,0 +1,190 @@ +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', + 'id' => $profile->getKey(), + 'timezone' => 'America/Chicago', + 'user_id' => $user->getKey(), + ], + ], + [ + '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(), + ], + ], + ], + ], + ]); + } +} diff --git a/tests/Integration/Http/Resources/JsonApi/TestCase.php b/tests/Integration/Http/Resources/JsonApi/TestCase.php new file mode 100644 index 000000000000..a1644383a7cb --- /dev/null +++ b/tests/Integration/Http/Resources/JsonApi/TestCase.php @@ -0,0 +1,46 @@ +get('users', function () { + return User::paginate(5)->toResourceCollection(); + }); + + $router->get('users/{userId}', function ($userId) { + return User::find($userId)->toResource(); + }); + } + + /** {@inheritdoc} */ + protected function afterRefreshingDatabase() + { + require __DIR__.'/Fixtures/migrations.php'; + } +}