diff --git a/CHANGELOG-1.5.md b/CHANGELOG-1.5.md new file mode 100644 index 0000000..e02c317 --- /dev/null +++ b/CHANGELOG-1.5.md @@ -0,0 +1,5 @@ +Release note +============ +# v1.5.0 +### Added +- Struct value type diff --git a/readme.md b/readme.md index eeef3db..06e0b80 100644 --- a/readme.md +++ b/readme.md @@ -399,7 +399,8 @@ UserResource::collection(User::all()); // => JsonApiCollection | `date` | Cast to date, allow to use custom format | | `array` | Cast to array | | `mixed` | Don't cast, return as is | -| `enum` | Get enum value. | +| `enum` | Get enum value | +| `struct` | Custom struct. Accept an array of values | ### Relation methods | Method | Description | diff --git a/src/Descriptors/Describer.php b/src/Descriptors/Describer.php index 7d2855a..4d06905 100644 --- a/src/Descriptors/Describer.php +++ b/src/Descriptors/Describer.php @@ -151,12 +151,12 @@ abstract public function retriever(): null|string|Closure; * * @return int|string */ - public static function retrieveName(mixed $value, int|string $key): int|string + public static function retrieveName(mixed $value, int|string $key, null|string $prefix = null): int|string { if (is_int($key) && $value instanceof self && is_string($retriever = $value->retriever())) { - return $retriever; + return $prefix ? $prefix . '.' . $retriever : $retriever; } - return $key; + return $prefix ? $prefix . '.' . $key : $key; } } diff --git a/src/Descriptors/Resolver.php b/src/Descriptors/Resolver.php index 217aee5..3adea66 100644 --- a/src/Descriptors/Resolver.php +++ b/src/Descriptors/Resolver.php @@ -11,7 +11,7 @@ trait Resolver * @param \Illuminate\Http\Request $request * @param iterable|null $values * - * @return array|null + * @return array|null */ protected function resolveValues(Request $request, ?iterable $values): ?array { diff --git a/src/Descriptors/Values.php b/src/Descriptors/Values.php index eb7beff..051fe82 100644 --- a/src/Descriptors/Values.php +++ b/src/Descriptors/Values.php @@ -2,16 +2,17 @@ namespace Ark4ne\JsonApi\Descriptors; -use Ark4ne\JsonApi\Descriptors\Relations\RelationMany; -use Ark4ne\JsonApi\Descriptors\Relations\RelationOne; -use Ark4ne\JsonApi\Descriptors\Values\{ValueArray, +use Ark4ne\JsonApi\Descriptors\Values\{ + ValueArray, ValueBool, ValueDate, ValueEnum, ValueFloat, ValueInteger, ValueMixed, - ValueString}; + ValueString, + ValueStruct +}; use Closure; /** @@ -98,4 +99,14 @@ protected function enum(null|string|Closure $attribute = null): ValueEnum { return new ValueEnum($attribute); } + + /** + * @param Closure(T):iterable $attribute + * + * @return \Ark4ne\JsonApi\Descriptors\Values\ValueStruct + */ + protected function struct(Closure $attribute): ValueStruct + { + return new ValueStruct($attribute); + } } diff --git a/src/Descriptors/Values/Value.php b/src/Descriptors/Values/Value.php index 62969ef..5337d8c 100644 --- a/src/Descriptors/Values/Value.php +++ b/src/Descriptors/Values/Value.php @@ -108,7 +108,7 @@ public function resolveFor(Request $request, mixed $model, string $field): mixed return $value === null && $this->nullable ? null - : $this->value($value); + : $this->value($value, $request); } protected function check(Request $request, mixed $model, string $attribute): bool @@ -120,5 +120,5 @@ protected function check(Request $request, mixed $model, string $attribute): boo return parent::check($request, $model, $attribute); } - abstract protected function value(mixed $of): mixed; + abstract protected function value(mixed $of, Request $request): mixed; } diff --git a/src/Descriptors/Values/ValueArray.php b/src/Descriptors/Values/ValueArray.php index 3d6addf..b1aa5a7 100644 --- a/src/Descriptors/Values/ValueArray.php +++ b/src/Descriptors/Values/ValueArray.php @@ -2,6 +2,7 @@ namespace Ark4ne\JsonApi\Descriptors\Values; +use Illuminate\Http\Request; use Illuminate\Support\Collection; /** @@ -12,10 +13,10 @@ class ValueArray extends Value { /** * @param mixed $of - * + * @param Request $request * @return array */ - public function value(mixed $of): array + public function value(mixed $of, Request $request): array { return (new Collection($of))->toArray(); } diff --git a/src/Descriptors/Values/ValueBool.php b/src/Descriptors/Values/ValueBool.php index 631916a..64b1900 100644 --- a/src/Descriptors/Values/ValueBool.php +++ b/src/Descriptors/Values/ValueBool.php @@ -2,13 +2,15 @@ namespace Ark4ne\JsonApi\Descriptors\Values; +use Illuminate\Http\Request; + /** * @template T * @extends Value */ class ValueBool extends Value { - public function value(mixed $of): bool + public function value(mixed $of, Request $request): bool { return (bool)$of; } diff --git a/src/Descriptors/Values/ValueDate.php b/src/Descriptors/Values/ValueDate.php index 6191d48..40e639d 100644 --- a/src/Descriptors/Values/ValueDate.php +++ b/src/Descriptors/Values/ValueDate.php @@ -6,6 +6,7 @@ use Closure; use DateTime; use DateTimeInterface; +use Illuminate\Http\Request; /** * @template T @@ -31,7 +32,7 @@ public function format(string $format): static /** * @throws \Exception */ - public function value(mixed $of): string + public function value(mixed $of, Request $request): string { if ($of === null) { return (new DateTime("@0"))->format($this->format); diff --git a/src/Descriptors/Values/ValueEnum.php b/src/Descriptors/Values/ValueEnum.php index 47b251e..e5cfc93 100644 --- a/src/Descriptors/Values/ValueEnum.php +++ b/src/Descriptors/Values/ValueEnum.php @@ -3,6 +3,7 @@ namespace Ark4ne\JsonApi\Descriptors\Values; use BackedEnum; +use Illuminate\Http\Request; use UnitEnum; /** @@ -11,7 +12,7 @@ */ class ValueEnum extends Value { - protected function value(mixed $of): mixed + protected function value(mixed $of, Request $request): mixed { if ($of instanceof BackedEnum) return $of->value; if ($of instanceof UnitEnum) return $of->name; diff --git a/src/Descriptors/Values/ValueFloat.php b/src/Descriptors/Values/ValueFloat.php index b29952c..a8efeeb 100644 --- a/src/Descriptors/Values/ValueFloat.php +++ b/src/Descriptors/Values/ValueFloat.php @@ -4,6 +4,7 @@ use Ark4ne\JsonApi\Support\Config; use Closure; +use Illuminate\Http\Request; /** * @template T @@ -26,7 +27,7 @@ public function precision(int $precision): static return $this; } - public function value(mixed $of): float + public function value(mixed $of, Request $request): float { if (isset($this->precision)) { $precision = 10 ** $this->precision; diff --git a/src/Descriptors/Values/ValueInteger.php b/src/Descriptors/Values/ValueInteger.php index 4a7696d..9a5458f 100644 --- a/src/Descriptors/Values/ValueInteger.php +++ b/src/Descriptors/Values/ValueInteger.php @@ -2,13 +2,15 @@ namespace Ark4ne\JsonApi\Descriptors\Values; +use Illuminate\Http\Request; + /** * @template T * @extends Value */ class ValueInteger extends Value { - public function value(mixed $of): int + public function value(mixed $of, Request $request): int { return (int)$of; } diff --git a/src/Descriptors/Values/ValueMixed.php b/src/Descriptors/Values/ValueMixed.php index 5f94164..8bf8884 100644 --- a/src/Descriptors/Values/ValueMixed.php +++ b/src/Descriptors/Values/ValueMixed.php @@ -2,13 +2,15 @@ namespace Ark4ne\JsonApi\Descriptors\Values; +use Illuminate\Http\Request; + /** * @template T * @extends Value */ class ValueMixed extends Value { - public function value(mixed $of): mixed + public function value(mixed $of, Request $request): mixed { return $of; } diff --git a/src/Descriptors/Values/ValueString.php b/src/Descriptors/Values/ValueString.php index 82330b4..37e6dd1 100644 --- a/src/Descriptors/Values/ValueString.php +++ b/src/Descriptors/Values/ValueString.php @@ -2,13 +2,15 @@ namespace Ark4ne\JsonApi\Descriptors\Values; +use Illuminate\Http\Request; + /** * @template T * @extends Value */ class ValueString extends Value { - public function value(mixed $of): string + public function value(mixed $of, Request $request): string { return (string)$of; } diff --git a/src/Descriptors/Values/ValueStruct.php b/src/Descriptors/Values/ValueStruct.php new file mode 100644 index 0000000..68d3318 --- /dev/null +++ b/src/Descriptors/Values/ValueStruct.php @@ -0,0 +1,45 @@ + + */ +class ValueStruct extends Value +{ + use Resolver; + + protected mixed $resource; + + public function __construct(Closure $values) + { + parent::__construct($values); + } + + public function resolveFor(Request $request, mixed $model, string $field): mixed + { + $this->resource = $model; + + return parent::resolveFor($request, $model, $field); + } + + /** + * @param iterable $of + * @param Request $request + * + * @return array + */ + public function value(mixed $of, Request $request): array + { + $attributes = Values::mergeValues($this->resolveValues($request, Values::mergeValues($of))); + + return (new Collection($attributes))->toArray(); + } +} diff --git a/src/Resources/Concerns/Attributes.php b/src/Resources/Concerns/Attributes.php index 2eebfdd..ae8525b 100644 --- a/src/Resources/Concerns/Attributes.php +++ b/src/Resources/Concerns/Attributes.php @@ -2,6 +2,7 @@ namespace Ark4ne\JsonApi\Resources\Concerns; +use Ark4ne\JsonApi\Support\Arr; use Ark4ne\JsonApi\Support\Fields; use Illuminate\Http\Request; @@ -14,7 +15,7 @@ trait Attributes * * @param \Illuminate\Http\Request $request * - * @return iterable|iterable + * @return iterable * * ``` * return [ @@ -58,7 +59,7 @@ private function requestedAttributes(Request $request): array $attributes = null === $fields ? $attributes - : array_intersect_key($attributes, array_fill_keys($fields, true)); + : Arr::intersectKeyStruct($attributes, Arr::undot(array_fill_keys($fields, true))); return array_map('\value', $attributes); }); diff --git a/src/Resources/Concerns/PrepareData.php b/src/Resources/Concerns/PrepareData.php index 38fed48..d0c19c2 100644 --- a/src/Resources/Concerns/PrepareData.php +++ b/src/Resources/Concerns/PrepareData.php @@ -28,7 +28,6 @@ trait PrepareData */ protected function mergeValues(iterable $data): iterable { - return Values::mergeValues($data); } diff --git a/src/Resources/Concerns/Schema.php b/src/Resources/Concerns/Schema.php index 8499bd8..4972738 100644 --- a/src/Resources/Concerns/Schema.php +++ b/src/Resources/Concerns/Schema.php @@ -5,6 +5,7 @@ use Ark4ne\JsonApi\Descriptors\Describer; use Ark4ne\JsonApi\Descriptors\Relations\Relation; use Ark4ne\JsonApi\Descriptors\Resolver; +use Ark4ne\JsonApi\Descriptors\Values\ValueStruct; use Ark4ne\JsonApi\Resources\Skeleton; use Ark4ne\JsonApi\Support\FakeModel; use Ark4ne\JsonApi\Support\Values; @@ -37,9 +38,16 @@ public static function schema(null|Request $request = null): Skeleton ); $schema->fields = (new Collection(Values::mergeValues($resource->toAttributes($request)))) - ->map(fn($value, $key) => Describer::retrieveName($value, $key)) - ->values() - ->all(); + ->flatMap(function ($value, $key) use ($resource) { + $collection = (new Collection([Describer::retrieveName($value, $key) => true])); + + if ($value instanceof ValueStruct) { + return $collection->merge(self::structFields($resource, $key, $value, $key)); + } + + return $collection; + }) + ->keys()->all(); foreach (Values::mergeValues($resource->toRelationships($request)) as $name => $relation) { if ($relation instanceof Relation) { @@ -83,4 +91,21 @@ private static function new(): static return $instance; } + + private static function structFields(mixed $resource, string $key, ValueStruct $struct, null|string $prefix = null): Collection + { + /** @var array $attributes */ + $attributes = ($struct->retriever())($resource, $key); + + return (new Collection(Values::mergeValues($attributes))) + ->flatMap(function($value, $key) use ($resource, $prefix) { + $prefixed = Describer::retrieveName($value, $key, $prefix); + + if ($value instanceof ValueStruct) { + return self::structFields($resource, $key, $value, $prefixed); + } + + return [$prefixed => $value]; + }); + } } diff --git a/src/Support/Arr.php b/src/Support/Arr.php index f83b64e..d99a0ec 100644 --- a/src/Support/Arr.php +++ b/src/Support/Arr.php @@ -163,6 +163,28 @@ public static function apply(array &$array, string $path, mixed $value, null|str return $array; } + /** + * @template TKey as array-key + * @template TValue + * + * @param array $array + * @param array $struct + * + * @return array + */ + public static function intersectKeyStruct(array $array, array $struct): array + { + $res = array_intersect_key($array, $struct); + + foreach ($res as $key => $value) { + if (is_array($value) && is_array($struct[$key])) { + $res[$key] = self::intersectKeyStruct($value, $struct[$key]); + } + } + + return $res; + } + /** * @template TKey as array-key * @template TValue diff --git a/tests/Feature/SchemaTest.php b/tests/Feature/SchemaTest.php index 4978272..3a9a303 100644 --- a/tests/Feature/SchemaTest.php +++ b/tests/Feature/SchemaTest.php @@ -20,6 +20,17 @@ public function testSchema() 'with-apply-conditional-raw', 'with-apply-conditional-closure', 'with-apply-conditional-value', + 'struct-set', + 'struct-set.name', + 'struct-set.email', + 'struct-set.casted', + 'struct-set.with-apply-conditional-raw', + 'struct-set.closure', + 'struct-set.missing', + 'struct-set.sub-struct.int', + 'struct-set.sub-struct.float', + 'struct-set.third-struct.int', + 'struct-set.third-struct.float', ]); $post = new Skeleton(PostResource::class, 'post', ['state', 'title', 'content']); $comment = new Skeleton(CommentResource::class, 'comment', ['content']); diff --git a/tests/Feature/User/ResourceTest.php b/tests/Feature/User/ResourceTest.php index c1ab1c5..6848c47 100644 --- a/tests/Feature/User/ResourceTest.php +++ b/tests/Feature/User/ResourceTest.php @@ -2,6 +2,7 @@ namespace Test\Feature\User; +use Ark4ne\JsonApi\Support\Arr; use Ark4ne\JsonApi\Support\Config; use DateTimeInterface; use Illuminate\Http\Request; @@ -32,6 +33,22 @@ public function testShowWithAttributes() $response->assertExactJson($this->getJsonResult($user, ['name'])); } + + public function testShowWithStructAttributes() + { + $user = $this->dataSeed(); + + $attrs = [ + 'name', + 'struct-set.name', + 'struct-set.sub-struct.int' + ]; + + $response = $this->get("user/{$user->id}?fields[user]=" . implode(',', $attrs)); + + $response->assertExactJson($this->getJsonResult($user, $attrs)); + } + public function testShowWithRelationshipsPosts() { $user = $this->dataSeed(); @@ -161,18 +178,30 @@ private function getJsonResult(User $user, ?array $attributes = null, ?array $re 'data' => [ 'id' => $user->id, 'type' => 'user', - 'attributes' => array_filter(array_intersect_key([ + 'attributes' => Arr::intersectKeyStruct([ 'name' => $user->name, 'email' => $user->email, "with-apply-conditional-closure" => "huge-data-set", "with-apply-conditional-raw" => "huge-data-set", - "with-apply-conditional-value" => "huge-data-set" - ], array_fill_keys($attributes ?? [ + "with-apply-conditional-value" => "huge-data-set", + "struct-set" => [ + "name" => $user->name, + "closure" => "closure", + "email" => $user->email, + "casted" => "string", + "with-apply-conditional-raw" => "huge-data-set", + "sub-struct" => [ + "int" => 200, + "float" => 1.1, + ] + ] + ], Arr::undot(array_fill_keys($attributes ?? [ 'name', 'email', "with-apply-conditional-closure", "with-apply-conditional-raw", "with-apply-conditional-value", + "struct-set", ], true))), 'relationships' => [ 'main-post' => [], diff --git a/tests/Unit/Descriptors/ValueTest.php b/tests/Unit/Descriptors/ValueTest.php index ca10641..4b6d73a 100644 --- a/tests/Unit/Descriptors/ValueTest.php +++ b/tests/Unit/Descriptors/ValueTest.php @@ -93,7 +93,7 @@ public function testConvertValue($class, $value, $excepted) { /** @var \Ark4ne\JsonApi\Descriptors\Values\Value $v */ $v = new $class(null); - $this->assertEquals($excepted, Reflect::invoke($v, 'value', $value)); + $this->assertEquals($excepted, Reflect::invoke($v, 'value', $value, new Request)); } /** @@ -228,24 +228,26 @@ public function testWhenInFields($model) public function testValueFloatPrecision() { + $request = new Request; $v = new ValueFloat(null); - $this->assertEquals(123.12, Reflect::invoke($v, 'value', '123.12')); + $this->assertEquals(123.12, Reflect::invoke($v, 'value', '123.12', $request)); $v->precision(2); - $this->assertEquals(123.12, Reflect::invoke($v, 'value', '123.12')); + $this->assertEquals(123.12, Reflect::invoke($v, 'value', '123.12', $request)); $v->precision(1); - $this->assertEquals(123.1, Reflect::invoke($v, 'value', '123.12')); + $this->assertEquals(123.1, Reflect::invoke($v, 'value', '123.12', $request)); $v->precision(0); - $this->assertEquals(123, Reflect::invoke($v, 'value', '123.12')); + $this->assertEquals(123, Reflect::invoke($v, 'value', '123.12', $request)); } public function testValueDateFormat() { + $request = new Request; $v = new ValueDate(null); - $this->assertEquals('2022-01-01T00:00:00+00:00', Reflect::invoke($v, 'value', '2022-01-01 00:00:00')); + $this->assertEquals('2022-01-01T00:00:00+00:00', Reflect::invoke($v, 'value', '2022-01-01 00:00:00', $request)); $v->format('Y-m-d H:i:s'); - $this->assertEquals('2022-01-01 00:00:00', Reflect::invoke($v, 'value', '2022-01-01 00:00:00')); + $this->assertEquals('2022-01-01 00:00:00', Reflect::invoke($v, 'value', '2022-01-01 00:00:00', $request)); $v->format('U'); - $this->assertEquals('1640995200', Reflect::invoke($v, 'value', '2022-01-01 00:00:00')); + $this->assertEquals('1640995200', Reflect::invoke($v, 'value', '2022-01-01 00:00:00', $request)); } private function throughRetrieverTest(&$model, \Closure $missing, \Closure $update, \Closure $check, $expected) diff --git a/tests/app/Http/Resources/UserResource.php b/tests/app/Http/Resources/UserResource.php index 47ae055..d0c349f 100644 --- a/tests/app/Http/Resources/UserResource.php +++ b/tests/app/Http/Resources/UserResource.php @@ -30,6 +30,24 @@ protected function toAttributes(Request $request): iterable 'with-apply-conditional-closure' => fn () => 'huge-data-set', 'with-apply-conditional-value' => $this->string(fn () => 'huge-data-set'), ]), + 'struct-set' => $this->struct(fn () => [ + $this->string('name'), + 'email' => $this->resource->email, + 'casted' => $this->string(fn() => 'string'), + $this->applyWhen(fn () => true, [ + 'with-apply-conditional-raw' => 'huge-data-set', + ]), + 'closure' => fn() => 'closure', + 'missing' => $this->mixed(fn() => 'value')->when(false), + 'sub-struct' => $this->struct(fn () => [ + 'int' => $this->float(fn () => 200), + 'float' => $this->float(fn () => 1.1), + ]), + 'third-struct' => $this->struct(fn () => [ + 'int' => $this->float(fn () => 300), + 'float' => $this->float(fn () => 3.1), + ])->when(false), + ]), ]; }