diff --git a/src/foundation/src/Http/Casts/AsDataObjectArray.php b/src/foundation/src/Http/Casts/AsDataObjectArray.php new file mode 100644 index 00000000..2dbeeb6d --- /dev/null +++ b/src/foundation/src/Http/Casts/AsDataObjectArray.php @@ -0,0 +1,71 @@ +arguments[0]; + + // Check if the class has make static method (provided by DataObject) + if (! method_exists($dataClass, 'make')) { + throw new RuntimeException( + "Class {$dataClass} must implement static make(array \$data) method" + ); + } + + return new ArrayObject( + array_map(fn ($item) => $dataClass::make($item), $value) + ); + } + + public function set(string $key, mixed $value, array $inputs): array + { + if ($value === null) { + return [$key => null]; + } + + $storable = array_map(function ($item) { + if (method_exists($item, 'toArray')) { + return $item->toArray(); + } + return $item; + }, (array) $value); + + return [$key => $storable]; + } + }; + } +} diff --git a/src/foundation/src/Http/Casts/AsDataObjectCollection.php b/src/foundation/src/Http/Casts/AsDataObjectCollection.php new file mode 100644 index 00000000..ab11d916 --- /dev/null +++ b/src/foundation/src/Http/Casts/AsDataObjectCollection.php @@ -0,0 +1,75 @@ +arguments[0]; + + // Check if the class has make static method (provided by DataObject) + if (! method_exists($dataClass, 'make')) { + throw new RuntimeException( + "Class {$dataClass} must implement static make(array \$data) method" + ); + } + + return new Collection( + array_map(fn ($item) => $dataClass::make($item), $value) + ); + } + + public function set(string $key, mixed $value, array $inputs): array + { + if ($value === null) { + return [$key => null]; + } + + if (! $value instanceof Collection) { + return [$key => $value]; + } + + $storable = $value->map(function ($item) { + if (method_exists($item, 'toArray')) { + return $item->toArray(); + } + return $item; + })->toArray(); + + return [$key => $storable]; + } + }; + } +} diff --git a/src/foundation/src/Http/Casts/AsEnumArrayObject.php b/src/foundation/src/Http/Casts/AsEnumArrayObject.php new file mode 100644 index 00000000..22565cb2 --- /dev/null +++ b/src/foundation/src/Http/Casts/AsEnumArrayObject.php @@ -0,0 +1,83 @@ +arguments[0]; + + return new ArrayObject( + (new Collection($value))->map(function ($item) use ($enumClass) { + if ($item instanceof $enumClass) { + return $item; + } + + return is_subclass_of($enumClass, BackedEnum::class) + ? $enumClass::from($item) + : constant($enumClass . '::' . $item); + })->toArray() + ); + } + + public function set(string $key, mixed $value, array $inputs): array + { + if ($value === null) { + return [$key => null]; + } + + $storable = []; + + foreach ($value as $enum) { + $storable[] = $this->getStorableEnumValue($enum); + } + + return [$key => $storable]; + } + + protected function getStorableEnumValue(mixed $enum): mixed + { + if (is_string($enum) || is_int($enum)) { + return $enum; + } + + return enum_value($enum); + } + }; + } +} diff --git a/src/foundation/src/Http/Casts/AsEnumCollection.php b/src/foundation/src/Http/Casts/AsEnumCollection.php new file mode 100644 index 00000000..5fae4435 --- /dev/null +++ b/src/foundation/src/Http/Casts/AsEnumCollection.php @@ -0,0 +1,78 @@ +arguments[0]; + + return (new Collection($value))->map(function ($item) use ($enumClass) { + if ($item instanceof $enumClass) { + return $item; + } + + return is_subclass_of($enumClass, BackedEnum::class) + ? $enumClass::from($item) + : constant($enumClass . '::' . $item); + }); + } + + public function set(string $key, mixed $value, array $inputs): array + { + if ($value === null) { + return [$key => null]; + } + + $storable = (new Collection($value))->map(function ($enum) { + return $this->getStorableEnumValue($enum); + })->toArray(); + + return [$key => $storable]; + } + + protected function getStorableEnumValue(mixed $enum): mixed + { + if (is_string($enum) || is_int($enum)) { + return $enum; + } + + return enum_value($enum); + } + }; + } +} diff --git a/src/foundation/src/Http/Contracts/CastInputs.php b/src/foundation/src/Http/Contracts/CastInputs.php new file mode 100644 index 00000000..f59122c3 --- /dev/null +++ b/src/foundation/src/Http/Contracts/CastInputs.php @@ -0,0 +1,28 @@ +validated() : $this->all(); + + if (is_null($key)) { + return $this->castInputs($data, $validate); + } + + if (is_array($key)) { + $results = []; + foreach ($key as $k) { + $results[$k] = $this->castInputValue($k, $data[$k] ?? null, $validate); + } + + return $results; + } + + return $this->castInputValue($key, $data[$key] ?? null, $validate); + } + + /** + * Cast all inputs based on the casts definition. + */ + protected function castInputs(array $inputs, bool $validate = true): array + { + $casted = []; + + foreach ($inputs as $key => $value) { + $casted[$key] = $this->castInputValue($key, $value, $validate); + } + + return $casted; + } + + /** + * Cast a single input value. + */ + protected function castInputValue(string $key, mixed $value, bool $validate = true): mixed + { + if (! $this->hasCast($key)) { + return $value; + } + + return $this->castInput($key, $value, $validate); + } + + /** + * Cast an input to a native PHP type. + */ + protected function castInput(string $key, mixed $value, bool $validate = true): mixed + { + $castType = $this->getCastType($key); + + if (is_null($value) && in_array($castType, static::$primitiveCastTypes)) { + return null; + } + + // Handle primitive casts + switch ($castType) { + case 'int': + case 'integer': + return (int) $value; + case 'real': + case 'float': + case 'double': + return $this->fromFloat($value); + case 'decimal': + return $this->asDecimal($value, explode(':', $this->getCasts()[$key], 2)[1]); + case 'string': + return (string) $value; + case 'bool': + case 'boolean': + return (bool) $value; + case 'object': + return $this->fromJson($value, true); + case 'array': + case 'json': + return $this->fromJson($value); + case 'collection': + return new Collection($this->fromJson($value)); + case 'date': + return $this->asDate($value); + case 'datetime': + case 'custom_datetime': + return $this->asDateTime($value); + case 'timestamp': + return $this->asTimestamp($value); + } + + // Handle Enum casts + if ($this->isEnumCastable($key)) { + return $this->getEnumCastableInputValue($key, $value); + } + + // Handle DataObject casts + if ($this->isDataObjectCastable($key)) { + return $this->getDataObjectCastableInputValue($key, $value); + } + + // Handle custom class casts + if ($this->isClassCastable($key)) { + return $this->getClassCastableInputValue($key, $value, $validate); + } + + return $value; + } + + /** + * Cast the given input using a custom cast class. + */ + protected function getClassCastableInputValue(string $key, mixed $value, bool $validate = true): mixed + { + $cacheKey = ($validate ? 'validated:' : 'all:') . $key; + + if (isset($this->classCastCache[$cacheKey])) { + return $this->classCastCache[$cacheKey]; + } + + $caster = $this->resolveCasterClass($key); + $inputs = $validate ? $this->validated() : $this->all(); + + $value = $caster->get($key, $value, $inputs); + + if (is_object($value)) { + $this->classCastCache[$cacheKey] = $value; + } + + return $value; + } + + /** + * Cast the given input to an enum. + */ + protected function getEnumCastableInputValue(string $key, mixed $value): mixed + { + if (is_null($value)) { + return null; + } + + $castType = $this->getCasts()[$key]; + + if ($value instanceof $castType) { + return $value; + } + + return $this->getEnumCaseFromValue($castType, $value); + } + + /** + * Cast the given input to a DataObject. + */ + public function getDataObjectCastableInputValue(string $key, mixed $value): mixed + { + if (is_null($value)) { + return null; + } + + $castType = $this->getCasts()[$key]; + + if (! is_array($value)) { + throw new InvalidCastException(static::class, $key, $castType); + } + + // Check if the class has make static method (provided by DataObject) + if (! method_exists($castType, 'make')) { + throw new RuntimeException( + "Class {$castType} must implement static make(array \$data) method" + ); + } + + return $castType::make($value); + } + + /** + * Get an enum case instance from a given class and value. + */ + protected function getEnumCaseFromValue(string $enumClass, int|string $value): BackedEnum|UnitEnum + { + return EnumCollector::getEnumCaseFromValue($enumClass, $value); + } + + /** + * Determine whether an input should be cast to a native type. + */ + public function hasCast(string $key, mixed $types = null): bool + { + if (array_key_exists($key, $this->getCasts())) { + return ! $types || in_array($this->getCastType($key), (array) $types, true); + } + + return false; + } + + /** + * Get the casts array. + */ + public function getCasts(): array + { + return array_merge($this->casts, $this->casts()); + } + + /** + * @return array + */ + protected function casts(): array + { + return []; + } + + /** + * Get the type of cast for an input. + */ + protected function getCastType(string $key): string + { + return trim(strtolower($this->getCasts()[$key])); + } + + /** + * Determine if the given key is cast using a custom class. + */ + protected function isClassCastable(string $key): bool + { + $casts = $this->getCasts(); + + if (! array_key_exists($key, $casts)) { + return false; + } + + $castType = $this->parseCasterClass($casts[$key]); + + if (in_array($castType, static::$primitiveCastTypes)) { + return false; + } + + if (class_exists($castType)) { + return true; + } + + throw new InvalidCastException(static::class, $key, $castType); + } + + /** + * Determine if the given key is cast using an enum. + */ + protected function isEnumCastable(string $key): bool + { + $casts = $this->getCasts(); + + if (! array_key_exists($key, $casts)) { + return false; + } + + $castType = $casts[$key]; + + if (in_array($castType, static::$primitiveCastTypes)) { + return false; + } + + return enum_exists($castType); + } + + /** + * Determine if the given key is cast using a DataObject. + */ + public function isDataObjectCastable(string $key): bool + { + $casts = $this->getCasts(); + + if (! array_key_exists($key, $casts)) { + return false; + } + + $castType = $casts[$key]; + + if (in_array($castType, static::$primitiveCastTypes)) { + return false; + } + + return is_subclass_of($castType, DataObject::class); + } + + /** + * Resolve the custom caster class for a given key. + */ + protected function resolveCasterClass(string $key): CastInputs + { + $castType = $this->getCasts()[$key]; + $arguments = []; + + if (is_string($castType) && str_contains($castType, ':')) { + $segments = explode(':', $castType, 2); + + $castType = $segments[0]; + $arguments = explode(',', $segments[1]); + } + + if (is_subclass_of($castType, Castable::class)) { + $castType = $castType::castUsing($arguments); + } + + if (is_object($castType)) { + return $castType; + } + + return new $castType(...$arguments); + } + + /** + * Parse the given caster class, removing any arguments. + */ + protected function parseCasterClass(string $class): string + { + return ! str_contains($class, ':') ? $class : explode(':', $class, 2)[0]; + } + + /** + * Decode the given JSON back into an array or object. + */ + public function fromJson(string $value, bool $asObject = false) + { + return json_decode($value, ! $asObject); + } + + /** + * Decode the given float. + */ + public function fromFloat(mixed $value): float + { + return match ((string) $value) { + 'Infinity' => INF, + '-Infinity' => -INF, + 'NaN' => NAN, + default => (float) $value, + }; + } + + /** + * Convert a DateTime to a storable string. + * + * @return null|string + */ + public function fromDateTime(mixed $value): mixed + { + return empty($value) ? $value : $this->asDateTime($value)->format( + $this->getDateFormat() + ); + } + + /** + * Get the format for database stored dates. + */ + public function getDateFormat(): string + { + return $this->dateFormat; + } + + /** + * Encode the given value as JSON. + */ + protected function asJson(mixed $value): false|string + { + return json_encode($value); + } + + /** + * Return a decimal as string. + * + * @param float $value + * @param int $decimals + */ + protected function asDecimal(mixed $value, mixed $decimals): string + { + return number_format((float) $value, (int) $decimals, '.', ''); + } + + /** + * Return a timestamp as DateTime object with time set to 00:00:00. + */ + protected function asDate(mixed $value): CarbonInterface + { + return $this->asDateTime($value)->startOfDay(); + } + + /** + * Return a timestamp as DateTime object. + */ + protected function asDateTime(mixed $value): CarbonInterface + { + // If this value is already a Carbon instance, we shall just return it as is. + // This prevents us having to re-instantiate a Carbon instance when we know + // it already is one, which wouldn't be fulfilled by the DateTime check. + if ($value instanceof CarbonInterface) { + return Carbon::instance($value); + } + + // If the value is already a DateTime instance, we will just skip the rest of + // these checks since they will be a waste of time, and hinder performance + // when checking the field. We will just return the DateTime right away. + if ($value instanceof DateTimeInterface) { + return Carbon::parse( + $value->format('Y-m-d H:i:s.u'), + $value->getTimezone() + ); + } + + // If this value is an integer, we will assume it is a UNIX timestamp's value + // and format a Carbon object from this timestamp. This allows flexibility + // when defining your date fields as they might be UNIX timestamps here. + if (is_numeric($value)) { + return Carbon::createFromTimestamp($value); + } + + // If the value is in simply year, month, day format, we will instantiate the + // Carbon instances from that format. Again, this provides for simple date + // fields on the database, while still supporting Carbonized conversion. + if ($this->isStandardDateFormat($value)) { + return Carbon::instance(Carbon::createFromFormat('Y-m-d', $value)->startOfDay()); + } + + $format = $this->getDateFormat(); + + // Finally, we will just assume this date is in the format used by default on + // the database connection and use that format to create the Carbon object + // that is returned back out to the developers after we convert it here. + if (Carbon::hasFormat($value, $format)) { + return Carbon::createFromFormat($format, $value); + } + + return Carbon::parse($value); + } + + /** + * Determine if the given value is a standard date format. + */ + protected function isStandardDateFormat(mixed $value): bool|int + { + return preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', (string) $value); + } + + /** + * Return a timestamp as unix timestamp. + */ + protected function asTimestamp(mixed $value): false|int + { + return $this->asDateTime($value)->getTimestamp(); + } +} diff --git a/tests/Foundation/Http/CustomCastingTest.php b/tests/Foundation/Http/CustomCastingTest.php new file mode 100644 index 00000000..eff716c1 --- /dev/null +++ b/tests/Foundation/Http/CustomCastingTest.php @@ -0,0 +1,652 @@ +shouldReceive('getParsedBody')->andReturn([ + 'status' => 'active', + ]); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); + Context::set(ServerRequestInterface::class, $psrRequest); + + $request = new EnumCastingRequest($this->app); + + $status = $request->casted('status'); + $this->assertInstanceOf(UserStatus::class, $status); + $this->assertSame(UserStatus::Active, $status); + } + + /** + * Test enum casting for all data. + */ + public function testEnumCastingAll() + { + $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest->shouldReceive('getParsedBody')->andReturn([ + 'status' => 'active', + 'name' => 'Test', + ]); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); + Context::set(ServerRequestInterface::class, $psrRequest); + + $request = new EnumCastingRequest($this->app); + + // Use validate = false to avoid validation issues + $data = $request->casted(null, false); + $this->assertIsArray($data); + $this->assertArrayHasKey('status', $data); + $this->assertArrayHasKey('name', $data); + $this->assertInstanceOf(UserStatus::class, $data['status']); + $this->assertSame(UserStatus::Active, $data['status']); + $this->assertSame('Test', $data['name']); + } + + /** + * Test custom class casting. + */ + public function testCustomClassCasting() + { + $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest->shouldReceive('getParsedBody')->andReturn([ + 'price' => '1000', + ]); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); + Context::set(ServerRequestInterface::class, $psrRequest); + + $request = new CustomClassCastingRequest($this->app); + + $price = $request->casted('price', false); + $this->assertInstanceOf(Money::class, $price); + $this->assertSame(1000, $price->amount); + $this->assertSame('TWD', $price->currency); + } + + /** + * Test null value handling. + */ + public function testNullValueHandling() + { + $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest->shouldReceive('getParsedBody')->andReturn([ + 'status' => null, + ]); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); + Context::set(ServerRequestInterface::class, $psrRequest); + + $request = new NullableEnumCastingRequest($this->app); + + $status = $request->casted('status', false); + $this->assertNull($status); + } + + /** + * Test non-existent field. + */ + public function testNonExistentField() + { + $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest->shouldReceive('getParsedBody')->andReturn([ + 'status' => 'active', + 'name' => 'Test', + ]); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); + Context::set(ServerRequestInterface::class, $psrRequest); + + $request = new EnumCastingRequest($this->app); + + $nonExistent = $request->casted('non_existent'); + $this->assertNull($nonExistent); + } + + /** + * Test AsEnumArrayObject casting. + */ + public function testAsEnumArrayObjectCasting() + { + $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest->shouldReceive('getParsedBody')->andReturn([ + 'statuses' => ['active', 'inactive'], + ]); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); + Context::set(ServerRequestInterface::class, $psrRequest); + + $request = new EnumArrayObjectCastingRequest($this->app); + + $statuses = $request->casted('statuses', false); + $this->assertInstanceOf(ArrayObject::class, $statuses); + $this->assertCount(2, $statuses); + $this->assertSame(UserStatus::Active, $statuses[0]); + $this->assertSame(UserStatus::Inactive, $statuses[1]); + } + + /** + * Test AsEnumCollection casting. + */ + public function testAsEnumCollectionCasting() + { + $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest->shouldReceive('getParsedBody')->andReturn([ + 'statuses' => ['active', 'inactive', 'pending'], + ]); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); + Context::set(ServerRequestInterface::class, $psrRequest); + + $request = new EnumCollectionCastingRequest($this->app); + + $statuses = $request->casted('statuses', false); + $this->assertInstanceOf(Collection::class, $statuses); + $this->assertCount(3, $statuses); + + $values = $statuses->pluck('value')->all(); + $this->assertSame(['active', 'inactive', 'pending'], $values); + } + + /** + * Test casted($key, false) uses raw input. + */ + public function testCastedWithoutValidation() + { + $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest->shouldReceive('getParsedBody')->andReturn([ + 'status' => 'active', + 'extra_field' => 'extra_value', + ]); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); + Context::set(ServerRequestInterface::class, $psrRequest); + + $request = new EnumCastingRequest($this->app); + + // Using validate = false should get data from raw input + $status = $request->casted('status', false); + $this->assertInstanceOf(UserStatus::class, $status); + $this->assertSame(UserStatus::Active, $status); + + // Get all casted data from raw input + $data = $request->casted(null, false); + $this->assertArrayHasKey('status', $data); + $this->assertArrayHasKey('extra_field', $data); + } + + /** + * Test primitive type casting - int. + */ + public function testPrimitiveIntCasting() + { + $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest->shouldReceive('getParsedBody')->andReturn([ + 'age' => '25', + ]); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); + Context::set(ServerRequestInterface::class, $psrRequest); + + $request = new PrimitiveCastingRequest($this->app); + + $age = $request->casted('age', false); + $this->assertIsInt($age); + $this->assertSame(25, $age); + } + + /** + * Test primitive type casting - float. + */ + public function testPrimitiveFloatCasting() + { + $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest->shouldReceive('getParsedBody')->andReturn([ + 'price' => '19.99', + ]); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); + Context::set(ServerRequestInterface::class, $psrRequest); + + $request = new PrimitiveCastingRequest($this->app); + + $price = $request->casted('price', false); + $this->assertIsFloat($price); + $this->assertSame(19.99, $price); + } + + /** + * Test primitive type casting - bool. + */ + public function testPrimitiveBoolCasting() + { + $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest->shouldReceive('getParsedBody')->andReturn([ + 'is_active' => '1', + ]); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); + Context::set(ServerRequestInterface::class, $psrRequest); + + $request = new PrimitiveCastingRequest($this->app); + + $isActive = $request->casted('is_active', false); + $this->assertIsBool($isActive); + $this->assertTrue($isActive); + } + + /** + * Test primitive type casting - array. + */ + public function testPrimitiveArrayCasting() + { + $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest->shouldReceive('getParsedBody')->andReturn([ + 'tags' => '["tag1","tag2"]', + ]); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); + Context::set(ServerRequestInterface::class, $psrRequest); + + $request = new PrimitiveCastingRequest($this->app); + + $tags = $request->casted('tags', false); + $this->assertIsArray($tags); + $this->assertSame(['tag1', 'tag2'], $tags); + } + + /** + * Test primitive type casting - collection. + */ + public function testPrimitiveCollectionCasting() + { + $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest->shouldReceive('getParsedBody')->andReturn([ + 'items' => json_encode(['item1', 'item2']), + ]); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); + Context::set(ServerRequestInterface::class, $psrRequest); + + $request = new PrimitiveCastingRequest($this->app); + + $items = $request->casted('items', false); + $this->assertInstanceOf(Collection::class, $items); + $this->assertSame(['item1', 'item2'], $items->all()); + } + + /** + * Test primitive type casting - datetime. + */ + public function testPrimitiveDatetimeCasting() + { + $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest->shouldReceive('getParsedBody')->andReturn([ + 'created_at' => 1705315800, // 2024-01-15 10:50:00 UTC + 'published_date' => '2024-01-15', + 'updated_timestamp' => '2024-01-15 10:50:00', + ]); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); + Context::set(ServerRequestInterface::class, $psrRequest); + + $request = new DatetimeCastingRequest($this->app); + + // Test datetime casting + $createdAt = $request->casted('created_at', false); + $this->assertInstanceOf(CarbonInterface::class, $createdAt); + $this->assertSame('2024-01-15 10:50:00', $createdAt->format('Y-m-d H:i:s')); + + // Test date casting (time should be 00:00:00) + $publishedDate = $request->casted('published_date', false); + $this->assertInstanceOf(CarbonInterface::class, $publishedDate); + $this->assertSame('2024-01-15 00:00:00', $publishedDate->format('Y-m-d H:i:s')); + + // Test timestamp casting (returns int timestamp) + /** @var Carbon $updatedTimestamp */ + $updatedTimestamp = $request->casted('updated_timestamp', false); + $this->assertIsInt($updatedTimestamp); + $this->assertSame(1705315800, $updatedTimestamp); + } + + /** + * Test DataObject casting with DataObject. + */ + public function testDataObjectCasting() + { + $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest->shouldReceive('getParsedBody')->andReturn([ + 'contact' => ['name' => 'Jane', 'email' => 'jane@example.com'], + ]); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); + Context::set(ServerRequestInterface::class, $psrRequest); + + $request = new DataObjectCastingRequest($this->app); + + $contact = $request->casted('contact'); + $this->assertInstanceOf(Contact::class, $contact); + $this->assertSame('Jane', $contact->name); + $this->assertSame('jane@example.com', $contact->email); + } + + /** + * Test AsDataObjectArray casting with DataObject. + */ + public function testAsArrayObjectCasting() + { + $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest->shouldReceive('getParsedBody')->andReturn([ + 'contacts' => [ + ['name' => 'John', 'email' => 'john@example.com'], + ['name' => 'Jane', 'email' => 'jane@example.com'], + ], + ]); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); + Context::set(ServerRequestInterface::class, $psrRequest); + + $request = new DataObjectArrayCastingRequest($this->app); + + $contacts = $request->casted('contacts', false); + $this->assertInstanceOf(ArrayObject::class, $contacts); + $this->assertCount(2, $contacts); + $this->assertInstanceOf(Contact::class, $contacts[0]); + $this->assertSame('John', $contacts[0]->name); + $this->assertSame('john@example.com', $contacts[0]->email); + } + + /** + * Test AsCollection casting with DataObject. + */ + public function testAsCollectionCasting() + { + $psrRequest = Mockery::mock(ServerRequestPlusInterface::class); + $psrRequest->shouldReceive('getParsedBody')->andReturn([ + 'products' => [ + ['sku' => 'ABC123', 'name' => 'Product A', 'price' => 100], + ['sku' => 'DEF456', 'name' => 'Product B', 'price' => 200], + ['sku' => 'GHI789', 'name' => 'Product C', 'price' => 150], + ], + ]); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $psrRequest->shouldReceive('getUploadedFiles')->andReturn([]); + Context::set(ServerRequestInterface::class, $psrRequest); + + $request = new DataObjectCollectionCastingRequest($this->app); + + $products = $request->casted('products', false); + $this->assertInstanceOf(Collection::class, $products); + $this->assertCount(3, $products); + $this->assertInstanceOf(Product::class, $products->first()); + + // Test Collection methods + $expensiveProducts = $products->filter(fn ($p) => $p->price > 100); + $this->assertCount(2, $expensiveProducts); + + $skus = $products->pluck('sku')->all(); + $this->assertSame(['ABC123', 'DEF456', 'GHI789'], $skus); + } +} + +// Test Request Classes + +class EnumCastingRequest extends FormRequest +{ + protected array $casts = [ + 'status' => UserStatus::class, + ]; + + public function rules(): array + { + return [ + 'status' => 'required|string', + 'name' => 'string', + ]; + } +} + +class NullableEnumCastingRequest extends FormRequest +{ + protected array $casts = [ + 'status' => UserStatus::class, + ]; + + public function rules(): array + { + return [ + 'status' => 'nullable|string', + ]; + } +} + +class CustomClassCastingRequest extends FormRequest +{ + protected array $casts = [ + 'price' => MoneyCast::class, + ]; + + public function rules(): array + { + return [ + 'price' => 'required|numeric', + ]; + } +} + +class EnumArrayObjectCastingRequest extends FormRequest +{ + protected function casts(): array + { + return [ + 'statuses' => AsEnumArrayObject::of(UserStatus::class), + ]; + } + + public function rules(): array + { + return [ + 'statuses' => 'required|array', + 'statuses.*' => [Rule::enum(UserStatus::class)], + ]; + } +} + +class EnumCollectionCastingRequest extends FormRequest +{ + protected function casts(): array + { + return [ + 'statuses' => AsEnumCollection::of(UserStatus::class), + ]; + } + + public function rules(): array + { + return [ + 'statuses' => 'required|array', + 'statuses.*' => [Rule::enum(UserStatus::class)], + ]; + } +} + +class PrimitiveCastingRequest extends FormRequest +{ + protected array $casts = [ + 'age' => 'int', + 'price' => 'float', + 'is_active' => 'bool', + 'tags' => 'array', + 'items' => 'collection', + ]; + + public function rules(): array + { + return [ + 'age' => 'numeric', + 'price' => 'numeric', + 'is_active' => 'boolean', + 'tags' => 'string', + 'items' => 'array', + ]; + } +} + +class DatetimeCastingRequest extends FormRequest +{ + protected array $casts = [ + 'created_at' => 'datetime', + 'published_date' => 'date', + 'updated_timestamp' => 'timestamp', + ]; + + public function rules(): array + { + return [ + 'created_at' => 'integer', + 'published_date' => 'string', + 'updated_timestamp' => 'string', + ]; + } +} + +class DataObjectCastingRequest extends FormRequest +{ + protected array $casts = [ + 'contact' => Contact::class, + ]; + + public function rules(): array + { + return [ + 'contact' => 'array', + ]; + } +} + +class DataObjectArrayCastingRequest extends FormRequest +{ + protected function casts(): array + { + return [ + 'contacts' => AsDataObjectArray::of(Contact::class), + ]; + } + + public function rules(): array + { + return [ + 'contacts' => 'array', + ]; + } +} + +class DataObjectCollectionCastingRequest extends FormRequest +{ + protected function casts(): array + { + return [ + 'products' => AsDataObjectCollection::of(Product::class), + ]; + } + + public function rules(): array + { + return [ + 'products' => 'array', + ]; + } +} + +// Test Enums and Classes + +enum UserStatus: string +{ + case Active = 'active'; + case Inactive = 'inactive'; + case Pending = 'pending'; +} + +class Money +{ + public function __construct( + public readonly int $amount, + public readonly string $currency = 'TWD' + ) { + } + + public static function fromCents(int $cents): self + { + return new self($cents); + } +} + +class MoneyCast implements CastInputs +{ + public function get(string $key, mixed $value, array $inputs): Money + { + return Money::fromCents((int) $value); + } + + public function set(string $key, mixed $value, array $inputs): array + { + if ($value instanceof Money) { + return [$key => $value->amount]; + } + + return [$key => $value]; + } +} + +class Contact extends DataObject +{ + public function __construct( + public readonly string $name, + public readonly string $email + ) { + } +} + +class Product extends DataObject +{ + public function __construct( + public readonly string $sku, + public readonly string $name, + public readonly int $price + ) { + } +}