From fe588115f66f1152f7851781c1b3c7607f562e3a Mon Sep 17 00:00:00 2001 From: Thomas Vestergaard Date: Sun, 4 Jan 2026 23:51:38 +0100 Subject: [PATCH 1/4] represent JSON objects as stdClass instances instead of arrays; improved object/array validation and error messages (#46) * represent json objects as stdClass in ResourceMarshaller * better laravel integration testing --------- Co-authored-by: Thomas-Rosenkrans-Vestergaard --- src/Marshaller/ResourceMarshaller.php | 34 ++- src/Parsing/EnumParser.php | 1 - src/Resource.php | 14 +- src/ResourceValidation/ResourceValidation.php | 1 - src/Support/Laravel/RestingMiddleware.php | 25 +- src/Support/Laravel/RestingResponse.php | 8 +- src/Support/Laravel/UsesResting.php | 19 +- src/Support/OpenAPI.php | 49 ++-- .../Errors/NotObjectValidationError.php | 27 ++ tests/Fields/ArrayFieldTest.php | 2 +- tests/Fields/BoolFieldTest.php | 2 +- tests/Fields/EnumFieldTest.php | 1 - tests/Marshaller/ResourceMarshallerTest.php | 184 +++++++----- tests/Meta/ArrayResourceFieldsResource.php | 1 - tests/Meta/CarbonPeriodQuery.php | 16 ++ tests/Meta/NotRequiredPersonResource.php | 21 ++ tests/Meta/PersonParams.php | 19 ++ tests/Meta/PersonQuery.php | 19 ++ tests/Parsing/BoolParserTest.php | 2 +- tests/Parsing/CarbonParserTest.php | 2 +- tests/Parsing/CarbonPeriodParserTest.php | 2 +- tests/Parsing/IntParserTest.php | 2 +- tests/Parsing/NumberParserTest.php | 2 +- tests/Parsing/TimeParserTest.php | 2 +- tests/Support/LaravelIntegrationTest.php | 261 ++++++++++++++++++ .../Support/LaravelIntegrationTestHarness.php | 114 ++++++++ ...LaravelIntegrationTestHarnessRunResult.php | 48 ++++ tests/Validation/ArrayValidatorTest.php | 2 +- tests/Validation/BoolValidatorTest.php | 2 +- .../Validation/CarbonPeriodValidatorTest.php | 2 +- tests/Validation/CarbonValidatorTest.php | 2 +- tests/Validation/IntValidatorTest.php | 2 +- tests/Validation/NumberValidatorTest.php | 2 +- tests/Validation/Predicates/FactoriesTest.php | 2 +- .../Anonymous/AnonymousValidationTest.php | 2 +- .../Arrays/ArrayMaxSizeValidatorTest.php | 2 +- .../Arrays/ArrayMinSizeValidatorTest.php | 2 +- .../Arrays/ArraySizeValidatorTest.php | 2 +- .../Secondary/Arrays/ArrayValidationTest.php | 2 +- .../CarbonPeriodMaxDurationValidatorTest.php | 2 +- .../CarbonPeriodValidationTest.php | 2 +- .../Secondary/In/InValidationTest.php | 2 +- .../Numeric/NumericValidationTest.php | 2 +- .../String/StringLengthValidatorTest.php | 2 +- .../String/StringMaxLengthValidatorTest.php | 2 +- .../String/StringMinLengthValidatorTest.php | 2 +- .../String/StringRegexValidatorTest.php | 2 +- .../Secondary/String/StringValidationTest.php | 2 +- tests/Validation/StringValidatorTest.php | 2 +- tests/Validation/TimeValidatorTest.php | 2 +- 50 files changed, 753 insertions(+), 171 deletions(-) create mode 100644 src/Validation/Errors/NotObjectValidationError.php create mode 100644 tests/Meta/CarbonPeriodQuery.php create mode 100644 tests/Meta/NotRequiredPersonResource.php create mode 100644 tests/Meta/PersonParams.php create mode 100644 tests/Meta/PersonQuery.php create mode 100644 tests/Support/LaravelIntegrationTest.php create mode 100644 tests/Support/LaravelIntegrationTestHarness.php create mode 100644 tests/Support/LaravelIntegrationTestHarnessRunResult.php diff --git a/src/Marshaller/ResourceMarshaller.php b/src/Marshaller/ResourceMarshaller.php index 68026aeb..cf686c66 100644 --- a/src/Marshaller/ResourceMarshaller.php +++ b/src/Marshaller/ResourceMarshaller.php @@ -4,6 +4,7 @@ namespace Seier\Resting\Marshaller; +use stdClass; use Seier\Resting\UnionResource; use Seier\Resting\ResourceFactory; use Seier\Resting\Fields\ResourceField; @@ -11,13 +12,13 @@ use Seier\Resting\Resource as RestingResource; use Seier\Resting\Parsing\DefaultParseContext; use Seier\Resting\Validation\Errors\ValidationError; -use Seier\Resting\ResourceValidation\ResourceValidator; use Seier\Resting\Exceptions\ValidationExceptionHandler; use Seier\Resting\Validation\Errors\NotArrayValidationError; use Seier\Resting\Validation\Errors\RequiredValidationError; use Seier\Resting\Validation\Errors\NullableValidationError; use Seier\Resting\Validation\Predicates\ArrayResourceContext; use Seier\Resting\Validation\Errors\ForbiddenValidationError; +use Seier\Resting\Validation\Errors\NotObjectValidationError; use Seier\Resting\Validation\Errors\UnknownUnionDiscriminatorValidationError; class ResourceMarshaller @@ -44,7 +45,7 @@ public function pushPathError(string $path, ValidationError $validationError) $this->validationErrors[] = $validationError->prependPath($path); } - private function pushRootError(NotArrayValidationError $validationError) + private function pushRootError(NotArrayValidationError|NotObjectValidationError $validationError) { $this->pushPathError('', $validationError); } @@ -79,9 +80,8 @@ public function marshalResource(ResourceFactory $factory, mixed $content): Resou $this->reset(); $resource = $factory->create(); - - if (!is_array($content)) { - $this->pushRootError(new NotArrayValidationError($content)); + if (!$content instanceof stdClass) { + $this->pushRootError(new NotObjectValidationError($content)); return new ResourceMarshallerResult($resource, $this->validationErrors); } @@ -97,18 +97,18 @@ public function marshalResource(ResourceFactory $factory, mixed $content): Resou return new ResourceMarshallerResult($resource, $this->validationErrors); } - private function getUnionSubResource(UnionResource $baseResource, $content): ResourceMarshallerResult|UnionResource + private function getUnionSubResource(UnionResource $baseResource, stdClass $content): ResourceMarshallerResult|UnionResource { $dependantResources = $baseResource->getResourceMap(); $discriminatorKey = $baseResource->getDiscriminatorKey(); - if (!array_key_exists($discriminatorKey, $content)) { + if (!property_exists($content, $discriminatorKey)) { $path = $this->getCurrentPath($discriminatorKey); $this->pushPathError($path, new RequiredValidationError()); return new ResourceMarshallerResult($baseResource, $this->validationErrors); } - $discriminatorValue = $content[$discriminatorKey]; + $discriminatorValue = $content->{$discriminatorKey}; if (!is_scalar($discriminatorValue) || !array_key_exists($discriminatorValue, $dependantResources)) { $path = $this->getCurrentPath($discriminatorKey); $this->pushPathError($path, new UnknownUnionDiscriminatorValidationError( @@ -121,10 +121,14 @@ private function getUnionSubResource(UnionResource $baseResource, $content): Res return $dependantResources[$discriminatorValue]; } - public function marshalResourceFields(RestingResource $resource, array $content) + public function marshalResourceFields(RestingResource $resource, array|stdClass $content) { + if (is_array($content)) { + $content = (object)$content; + } + $fields = $this->getFields($resource); - $resourceContext = new ArrayResourceContext($fields, $content, $this->isStringBased); + $resourceContext = new ArrayResourceContext($fields, (array)$content, $this->isStringBased); $resource->prepare($resourceContext); foreach ($fields as $key => $field) { @@ -132,7 +136,7 @@ public function marshalResourceFields(RestingResource $resource, array $content) $requiredValidator = $field->getRequiredValidator(); $nullableValidator = $field->getNullableValidator(); $forbiddenValidator = $field->getForbiddenValidator(); - $isProvided = array_key_exists($key, $content); + $isProvided = property_exists($content, $key); $field->setFilled($isProvided); if (!$isProvided) { @@ -166,7 +170,7 @@ public function marshalResourceFields(RestingResource $resource, array $content) } - $fieldValue = $isProvided ? $content[$key] : $defaultValue; + $fieldValue = $isProvided ? $content->{$key} : $defaultValue; if ($fieldValue === null) { foreach ($nullableValidator->getDefaultValues() as $possibleDefault) { @@ -267,7 +271,7 @@ public function marshalResourceFields(RestingResource $resource, array $content) continue; } - $exceptionHandler->suppress($this->getCurrentPath($key), fn() => $field->set($fieldValue)); + $exceptionHandler->suppress($this->getCurrentPath($key), fn () => $field->set($fieldValue)); foreach ($exceptionHandler->getErrors() as $validationError) { $this->pushError($validationError); } @@ -328,9 +332,9 @@ public function marshalResources(ResourceFactory $factory, $content): ResourceMa $this->pushPath($key); $resource = $factory->create(); - if (!is_array($value)) { + if (!is_object($value)) { $path = $this->getCurrentPath(); - $this->pushPathError($path, new NotArrayValidationError($value)); + $this->pushPathError($path, new NotObjectValidationError($value)); continue; } diff --git a/src/Parsing/EnumParser.php b/src/Parsing/EnumParser.php index be5cf0ff..4c9974f9 100644 --- a/src/Parsing/EnumParser.php +++ b/src/Parsing/EnumParser.php @@ -2,7 +2,6 @@ namespace Seier\Resting\Parsing; -use Exception; use Throwable; use BackedEnum; use ReflectionEnum; diff --git a/src/Resource.php b/src/Resource.php index 037f7a9d..fc753f12 100644 --- a/src/Resource.php +++ b/src/Resource.php @@ -2,6 +2,7 @@ namespace Seier\Resting; +use stdClass; use Seier\Resting\Fields\Field; use Illuminate\Support\Collection; use Seier\Resting\Fields\ResourceField; @@ -33,7 +34,7 @@ public static function create(): static public static function fromArray(array $values): static { - return static::fromCollection(collect($values)); + return static::fromCollection(collect((object)$values)); } public static function fromCollection(Collection $values): static @@ -89,7 +90,7 @@ public function set(array|Collection $values): static public function setFieldsFromCollection(Collection $collection): static { $marshaller = new ResourceMarshaller(); - $marshaller->marshalResourceFields($this, $collection->toArray()); + $marshaller->marshalResourceFields($this, (object)$collection->toArray()); if ($errors = $marshaller->getValidationErrors()) { throw new ValidationException($errors); } @@ -280,6 +281,15 @@ public function toResponseArray(array $filter = null, array $rename = null, bool }); } + public function toResponseObject(array $filter = null, array $rename = null, bool $requireFilled = false): stdClass + { + return (object)$this->toResponseArray( + filter: $filter, + rename: $rename, + requireFilled: $requireFilled + ); + } + public function removeNulls(bool $should): static { $this->removeNulls = $should; diff --git a/src/ResourceValidation/ResourceValidation.php b/src/ResourceValidation/ResourceValidation.php index b32dcc4f..910117ec 100644 --- a/src/ResourceValidation/ResourceValidation.php +++ b/src/ResourceValidation/ResourceValidation.php @@ -3,7 +3,6 @@ namespace Seier\Resting\ResourceValidation; use Seier\Resting\Fields\Field; -use Seier\Resting\Validation\Predicates\Predicate; trait ResourceValidation { diff --git a/src/Support/Laravel/RestingMiddleware.php b/src/Support/Laravel/RestingMiddleware.php index 0664240f..c9c9e754 100644 --- a/src/Support/Laravel/RestingMiddleware.php +++ b/src/Support/Laravel/RestingMiddleware.php @@ -3,6 +3,7 @@ namespace Seier\Resting\Support\Laravel; use Closure; +use stdClass; use ReflectionClass; use Seier\Resting\Query; use ReflectionNamedType; @@ -29,7 +30,7 @@ public function handle(Request $request, Closure $next) { $this->request = $request; - // validate that we received json + // validate that we received JSON $this->validateIsJsonBody(); // clear all parameters for the route, so we can create our own @@ -52,7 +53,7 @@ protected function clearRouteParameters(): array $route = $this->request->route(); foreach ($route->parameterNames() as $parameterName) { $parameters[$parameterName] = $route->parameter($parameterName); - $this->request->route()->forgetParameter($parameterName); + $route->forgetParameter($parameterName); } return $parameters; @@ -114,24 +115,24 @@ protected function resolveParameter(ReflectionClass $resourceClass, bool $nullab } if ($resourceClass->isSubclassOf(Query::class)) { - return $this->resolveQuery($resourceName); + return $this->resolveQueryResource($resourceName); } if ($resourceClass->isSubclassOf(Params::class)) { - return $this->resolveParam($resourceName); + return $this->resolveParamResource($resourceName); } - return $this->resolveResource($resourceName, $nullable, $isVariadic); + return $this->resolveBodyResource($resourceName, $nullable, $isVariadic); } - protected function resolveParam(string $resourceName) + protected function resolveParamResource(string $resourceName) { $marshaller = new ResourceMarshaller(); $marshaller->isStringBased(); $factory = ClosureResourceFactory::from($resourceName); $result = $marshaller->marshalResource( $factory, - $this->request->route()->originalParameters(), + (object)$this->request->route()->originalParameters(), ); $this->appendErrors($result, $this->paramErrors); @@ -139,14 +140,14 @@ protected function resolveParam(string $resourceName) return $result->getValue(); } - protected function resolveQuery(string $resourceName): Resource + protected function resolveQueryResource(string $resourceName): Resource { $marshaller = new ResourceMarshaller(); $marshaller->isStringBased(); $factory = ClosureResourceFactory::from($resourceName); $result = $marshaller->marshalResource( $factory, - $this->request->query->all(), + (object)$this->request->query->all(), ); $this->appendErrors($result, $this->queryErrors); @@ -154,15 +155,15 @@ protected function resolveQuery(string $resourceName): Resource return $result->getValue(); } - protected function resolveResource(string $resourceName, bool $nullable, bool $isVariadic = false) + protected function resolveBodyResource(string $resourceName, bool $nullable, bool $isVariadic = false) { - $content = json_decode($this->request->getContent(), true); + $content = json_decode($this->request->getContent()); $marshaller = new ResourceMarshaller(); $factory = ClosureResourceFactory::from($resourceName); $result = $isVariadic ? $marshaller->marshalResources($factory, $content) - : ($nullable ? $marshaller->marshalNullableResource($factory, $content) : $marshaller->marshalResource($factory, $content)); + : ($nullable ? $marshaller->marshalNullableResource($factory, $content) : $marshaller->marshalResource($factory, $content ?? new stdClass)); $this->appendErrors($result, $this->bodyErrors); diff --git a/src/Support/Laravel/RestingResponse.php b/src/Support/Laravel/RestingResponse.php index 6693a396..10b0e4cf 100644 --- a/src/Support/Laravel/RestingResponse.php +++ b/src/Support/Laravel/RestingResponse.php @@ -10,10 +10,10 @@ class RestingResponse implements Responsable { - private array $data; + private mixed $data; private int $status; - public function __construct(array $data, int $status = 200) + public function __construct(mixed $data, int $status = 200) { $this->data = $data; $this->status = $status; @@ -24,14 +24,14 @@ public static function fromResources(array|Collection $resources, int $status = $resources = $resources instanceof Collection ? $resources->toArray() : $resources; return new static(array_map(function ($resource) { - return $resource instanceof RestingResource ? $resource->toResponseArray() : $resource; + return $resource instanceof RestingResource ? $resource->toResponseObject() : $resource; }, $resources), $status); } public static function fromResource(RestingResource $resource, int $status = 200): static { return new static( - $resource->toResponseArray(), + $resource->toResponseObject(), $status ); } diff --git a/src/Support/Laravel/UsesResting.php b/src/Support/Laravel/UsesResting.php index 2b67f434..40b28856 100644 --- a/src/Support/Laravel/UsesResting.php +++ b/src/Support/Laravel/UsesResting.php @@ -5,22 +5,25 @@ use Exception; use ReflectionClass; use ReflectionMethod; +use ReflectionNamedType; use Seier\Resting\Resource; use Illuminate\Http\Request; +use ReflectionFunctionAbstract; use Illuminate\Support\Collection; use Seier\Resting\Support\Transformer; use Seier\Resting\Support\Resourcable; use Seier\Resting\Support\BaseTransformer; +use Symfony\Component\HttpFoundation\Response; trait UsesResting { - - protected Request $request; + protected function resolveReflectionFunction(string $methodName): ReflectionFunctionAbstract + { + return new ReflectionMethod($this, $methodName); + } public function callAction($method, $parameters) { - $this->request = request(); - $result = $this->{$method}(...$this->handleVariadicParameters($method, $parameters)); if ($result instanceof Collection) { @@ -46,7 +49,7 @@ public function callAction($method, $parameters) $result = RestingResponse::fromResources($result); } - if ($result instanceof \Symfony\Component\HttpFoundation\Response) { + if ($result instanceof Response) { return $result; } @@ -55,7 +58,7 @@ public function callAction($method, $parameters) } if ($result instanceof Resource) { - return new RestingResponse($result->toResponseArray()); + return new RestingResponse($result->toResponseObject()); } return $result; @@ -63,7 +66,7 @@ public function callAction($method, $parameters) private function handleVariadicParameters(string $method, array $parameters): array { - $methodReflection = new ReflectionMethod($this, $method); + $methodReflection = $this->resolveReflectionFunction($method); $reflectionParameters = []; foreach ($methodReflection->getParameters() as $reflectionParameter) { $reflectionParameters[$reflectionParameter->getName()] = $reflectionParameter; @@ -78,7 +81,7 @@ private function handleVariadicParameters(string $method, array $parameters): ar $reflectionParameter = $reflectionParameters[$parameterName]; if ($reflectionParameter->isVariadic()) { $type = $reflectionParameters[$parameterName]->getType(); - if ($type instanceof \ReflectionNamedType) { + if ($type instanceof ReflectionNamedType) { $resourceName = $type->getName(); if ((new ReflectionClass($resourceName))->isSubclassOf(Resource::class)) { array_pop($values); diff --git a/src/Support/OpenAPI.php b/src/Support/OpenAPI.php index c44e0fd7..bbe57093 100644 --- a/src/Support/OpenAPI.php +++ b/src/Support/OpenAPI.php @@ -2,9 +2,11 @@ namespace Seier\Resting\Support; +use Closure; use ArrayObject; use ReflectionType; use ReflectionClass; +use ReflectionFunction; use ReflectionParameter; use Seier\Resting\Query; use ReflectionNamedType; @@ -26,10 +28,10 @@ class OpenAPI implements Arrayable, Responsable { - public $document; - protected $routes; - protected $resources = []; - protected $parameters = []; + public array $document = []; + protected RouteCollection $routes; + protected array $resources = []; + protected array $parameters = []; public function __construct(RouteCollection $collection) { @@ -38,7 +40,7 @@ public function __construct(RouteCollection $collection) $this->process(); } - protected function process() + protected function process(): void { $this->processInfo(); @@ -47,7 +49,7 @@ protected function process() $this->processParameters(); } - protected function processInfo() + protected function processInfo(): void { $this->document['openapi'] = '3.0.0'; @@ -61,7 +63,7 @@ protected function processInfo() } } - protected function processParameters() + protected function processParameters(): void { foreach ($this->parameters as $query => $where) { /** @var Resource $query */ @@ -82,7 +84,7 @@ protected function processParameters() } } - protected function processResources() + protected function processResources(): void { foreach ($this->resources as $resource => $_) { $resource = new $resource; @@ -90,7 +92,7 @@ protected function processResources() } } - protected function describeResource(Resource $resource) + protected function describeResource(Resource $resource): void { $fields = $resource->fields()->filter(function ($attr) { $field = $attr instanceof Field; @@ -172,12 +174,11 @@ protected function describeResource(Resource $resource) ]; } - protected function processPaths() + protected function processPaths(): void { $paths = []; foreach ($this->routes->getRoutes() as $route) { - /** @var $route Route */ $method = Arr::first(array_filter($route->methods(), function ($method) { return !in_array($method, ['OPTIONS', 'HEAD']); })); @@ -356,7 +357,7 @@ protected function describeEndpoint(Route $route, $method): array return $endpoint; } - protected function describeResponse(Route $route) + protected function describeResponse(Route $route): array { $resourceClassesSeen = new ArrayObject(); $responseType = []; @@ -366,8 +367,8 @@ protected function describeResponse(Route $route) list($_class, $_method) = explode('@', $type); $reflectionClass = new ReflectionClass($_class); $returnType = $reflectionClass->getMethod($_method)->getReturnType(); - } elseif ($route->action['uses'] instanceof \Closure) { - $reflectionFunction = new \ReflectionFunction($route->action['uses']); + } elseif ($route->action['uses'] instanceof Closure) { + $reflectionFunction = new ReflectionFunction($route->action['uses']); $returnType = $reflectionFunction->getReturnType(); } @@ -403,7 +404,7 @@ protected function createTypeFromReflectionType(ReflectionType $type, ArrayObjec array_filter( $type->getTypes(), function (ReflectionType $type) { - + if ($type instanceof ReflectionNamedType && $type->getName() === 'null') { return false; } @@ -466,7 +467,7 @@ protected function getDependantResources($className) return (new $className)->getDependantResources(); } - protected function addResource($resourceName) + protected function addResource($resourceName): void { if ($resourceName !== UnionResource::class) { $resource = new $resourceName; @@ -478,7 +479,7 @@ protected function addResource($resourceName) $this->resources[$resourceName] = []; } - foreach ($resource->fields() as $k => $field) { + foreach ($resource->fields() as $field) { if ($field instanceof ResourceField && ($field->getResourcePrototype() instanceof UnionResource)) { foreach ($field->getResourcePrototype()->getDependantResources() as $dependantResource) { $this->addResource($dependantResource); @@ -493,32 +494,32 @@ protected function addResource($resourceName) } } - public function addParameter($queryClass, $where = 'query') + public function addParameter($queryClass, $where = 'query'): void { $this->parameters[$queryClass] = $where; } - public static function componentPath($component, $type = 'schemas') + public static function componentPath($component, $type = 'schemas'): string { - return "#/components/{$type}/{$component}"; + return "#/components/$type/$component"; } - public static function resourceRefName($resourceClass) + public static function resourceRefName($resourceClass): array|string { return str_replace(['App\\Api\\Resources\\', '\\'], ['', '_'], $resourceClass); } - protected static function parametersRefName($queryClass, $propertyName) + protected static function parametersRefName($queryClass, $propertyName): string { return str_replace(['App\\Api\\Resources\\', '\\'], ['', '_'], $queryClass) . '_' . $propertyName; } - public function toArray() + public function toArray(): array { return $this->document; } - public function toResponse($request) + public function toResponse($request): JsonResponse { return new JsonResponse( $this->toArray() diff --git a/src/Validation/Errors/NotObjectValidationError.php b/src/Validation/Errors/NotObjectValidationError.php new file mode 100644 index 00000000..15e968ca --- /dev/null +++ b/src/Validation/Errors/NotObjectValidationError.php @@ -0,0 +1,27 @@ +value = $value; + } + + public function getMessage(): string + { + $formatted = $this->format($this->value); + + return "The value was expected to be an object, $formatted received instead."; + } +} \ No newline at end of file diff --git a/tests/Fields/ArrayFieldTest.php b/tests/Fields/ArrayFieldTest.php index b136e390..45b80939 100644 --- a/tests/Fields/ArrayFieldTest.php +++ b/tests/Fields/ArrayFieldTest.php @@ -37,7 +37,7 @@ public function testGetCanReturnNull() $this->assertNull($this->instance->get()); } - public function getGetCanReturnArray() + public function getGetCanReturnArray(): void { $this->instance->set([]); $this->assertEquals([], $this->instance->get()); diff --git a/tests/Fields/BoolFieldTest.php b/tests/Fields/BoolFieldTest.php index db96d419..75121db2 100644 --- a/tests/Fields/BoolFieldTest.php +++ b/tests/Fields/BoolFieldTest.php @@ -6,8 +6,8 @@ use Seier\Resting\Tests\TestCase; use Seier\Resting\Fields\BoolField; -use Seier\Resting\Tests\Meta\MockSecondaryValidator; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Tests\Meta\MockSecondaryValidator; use Seier\Resting\Tests\Meta\MockSecondaryValidationError; use Seier\Resting\Validation\Errors\NotBoolValidationError; use Seier\Resting\Validation\Errors\NullableValidationError; diff --git a/tests/Fields/EnumFieldTest.php b/tests/Fields/EnumFieldTest.php index 42475a30..b9b4b657 100644 --- a/tests/Fields/EnumFieldTest.php +++ b/tests/Fields/EnumFieldTest.php @@ -6,7 +6,6 @@ use Seier\Resting\Tests\TestCase; use Seier\Resting\Fields\EnumField; use Seier\Resting\Tests\Meta\SuiteEnum; -use Seier\Resting\Parsing\EnumParseError; use Seier\Resting\Tests\Meta\AssertsErrors; use Seier\Resting\Validation\Errors\InValidation; use Seier\Resting\Exceptions\ValidationException; diff --git a/tests/Marshaller/ResourceMarshallerTest.php b/tests/Marshaller/ResourceMarshallerTest.php index e02494c9..c76698cc 100644 --- a/tests/Marshaller/ResourceMarshallerTest.php +++ b/tests/Marshaller/ResourceMarshallerTest.php @@ -4,9 +4,11 @@ namespace Seier\Resting\Tests\Marshaller; +use stdClass; use Seier\Resting\Tests\TestCase; use Seier\Resting\DynamicResource; use Seier\Resting\Fields\RawField; +use Seier\Resting\ResourceFactory; use Seier\Resting\Fields\TimeField; use Seier\Resting\Fields\CarbonField; use Seier\Resting\Fields\StringField; @@ -22,12 +24,14 @@ use Seier\Resting\Tests\Meta\UnionResourceBase; use Seier\Resting\Marshaller\ResourceMarshaller; use Seier\Resting\Tests\Meta\UnionParentResource; +use Seier\Resting\Marshaller\ResourceMarshallerResult; use Seier\Resting\Validation\Errors\NotIntValidationError; use Seier\Resting\Validation\Errors\NotArrayValidationError; use Seier\Resting\Validation\Errors\RequiredValidationError; use Seier\Resting\Validation\Errors\NullableValidationError; use Seier\Resting\Validation\Errors\NotStringValidationError; use Seier\Resting\Validation\Errors\ForbiddenValidationError; +use Seier\Resting\Validation\Errors\NotObjectValidationError; use Seier\Resting\Validation\Secondary\Comparable\MinValidationError; use Seier\Resting\Validation\Errors\UnknownUnionDiscriminatorValidationError; use Seier\Resting\ResourceValidation\ResourceAttributeComparisonValidationError; @@ -53,7 +57,7 @@ public function setUp(): void public function testMarshalResource() { $factory = $this->resourceFactory(PersonResource::class); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'name' => $name = $this->faker->name, 'age' => $age = $this->faker->randomNumber(2), ]); @@ -73,7 +77,7 @@ public function testMarshalResourceDoesNotSetDisabledFields() return $resource; }); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'name' => $name = $this->faker->name, 'age' => $this->faker->randomNumber(2), ]); @@ -89,7 +93,7 @@ public function testMarshalResourceDoesNotSetDisabledFields() public function testMarshalNullableResourceWhenNull() { $factory = $this->resourceFactory(PersonResource::class); - $result = $this->instance->marshalNullableResource($factory, null); + $result = $this->runMarshalNullableResource($factory, null); $this->assertFalse($result->hasErrors()); $this->assertNull($result->getValue()); @@ -98,7 +102,7 @@ public function testMarshalNullableResourceWhenNull() public function testMarshalNullableResourceWhenNotNull() { $factory = $this->resourceFactory(PersonResource::class); - $result = $this->instance->marshalNullableResource($factory, [ + $result = $this->runMarshalNullableResource($factory, [ 'name' => $name = $this->faker->name, 'age' => $age = $this->faker->randomNumber(2), ]); @@ -119,7 +123,7 @@ public function testMarshalResourceWithPredicatedRequiredThatIsTrue() return $person; }); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'age' => $this->faker->randomNumber(2), ]); @@ -136,7 +140,7 @@ public function testMarshalResourceWithPredicatedRequiredThatIsFalse() return $person; }); - $result = $this->instance->marshalResource($factory, []); + $result = $this->runMarshalResource($factory, new stdClass); $this->assertFalse($result->hasErrors()); $this->assertType($result->getValue(), function (PersonResource $person) { @@ -154,7 +158,7 @@ public function testMarshalResourceWithPredicatedForbiddenThatIsTrue() return $person; }); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'name' => $this->faker->name, 'age' => $this->faker->randomNumber(2), ]); @@ -172,7 +176,7 @@ public function testMarshalResourceWithPredicatedForbiddenThatIsFalse() return $person; }); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'name' => $name = $this->faker->name, ]); @@ -192,7 +196,7 @@ public function testMarshalResourceWithPredicatedNullableThatIsTrue() return $person; }); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'name' => null, 'age' => null, ]); @@ -213,7 +217,7 @@ public function testMarshalResourceWithPredicatedNullableThatIsFalse() return $person; }); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'name' => null, 'age' => $this->faker->randomNumber(2), ]); @@ -230,7 +234,7 @@ public function testMarshalResourceWithLateValidationCanHandleNullValue() return $activity; }); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'start' => $start = now(), 'end' => null, ]); @@ -250,7 +254,7 @@ public function testMarshalResourceWithLateValidationThatPasses() return $activity; }); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'start' => $start = now(), 'end' => $end = $start->copy()->addSecond(), ]); @@ -270,7 +274,7 @@ public function testMarshalResourceWithLateValidationThatFails() return $activity; }); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'start' => $start = now(), 'end' => $end = $start->copy(), ]); @@ -282,7 +286,7 @@ public function testMarshalResourceWithLateValidationThatFails() public function testMarshalResourceField() { $factory = $this->resourceFactory(PetResource::class); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'name' => $petName = $this->faker->name, 'owner' => [ 'name' => $ownerName = $this->faker->name, @@ -301,7 +305,7 @@ public function testMarshalResourceField() public function testMarshalResourceArrayField() { $factory = $this->resourceFactory(ClassResource::class); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'grade' => $grade = $this->faker->randomNumber(1), 'students' => [ ['name' => $nameA = $this->faker->name, 'age' => $ageA = $this->faker->randomNumber(2)], @@ -322,7 +326,7 @@ public function testMarshalResourceArrayField() public function testMarshalResourceArrayFieldOfEnums() { $factory = $this->resourceFactory(SuiteResource::class); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'suites' => [ SuiteEnum::Diamonds->value, SuiteEnum::Clubs->value, @@ -345,7 +349,7 @@ public function testMarshalResourceArrayFieldOfEnums() public function testMarshalUnionResource() { $factory = $this->resourceFactory(UnionResourceBase::class); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'discriminator' => $discriminator = 'b', 'value' => $value = $this->faker->word, 'b' => $b = $this->faker->word, @@ -362,7 +366,7 @@ public function testMarshalUnionResource() public function testMarshalResourceWithUnionResource() { $factory = $this->resourceFactory(UnionParentResource::class); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'other' => $other = $this->faker->word, 'union' => [ 'discriminator' => 'b', @@ -387,9 +391,9 @@ public function testMarshalResourceWithResourcePassingValidation() $resource = new ResourceAttributeComparisonTestResource(); $resource->only($resource->int_field_a, $resource->int_field_b); $resource->greaterThan($resource->int_field_a, $resource->int_field_b); - $factory = $this->resourceFactory(fn() => $resource); + $factory = $this->resourceFactory(fn () => $resource); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'int_field_a' => 1, 'int_field_b' => 0, ]); @@ -406,9 +410,9 @@ public function testMarshalResourceWithResourceFailingValidation() $resource = new ResourceAttributeComparisonTestResource(); $resource->only($resource->int_field_a, $resource->int_field_b); $resource->greaterThan($resource->int_field_a, $resource->int_field_b); - $factory = $this->resourceFactory(fn() => $resource); + $factory = $this->resourceFactory(fn () => $resource); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'int_field_a' => 1, 'int_field_b' => 1, ]); @@ -425,9 +429,9 @@ public function testMarshalResourceWithResourceValidationWhenFieldsAreNull() $resource->int_field_a->nullable(); $resource->int_field_b->nullable(); $resource->greaterThan($resource->int_field_a, $resource->int_field_b); - $factory = $this->resourceFactory(fn() => $resource); + $factory = $this->resourceFactory(fn () => $resource); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'int_field_a' => null, 'int_field_b' => null, ]); @@ -438,7 +442,7 @@ public function testMarshalResourceWithResourceValidationWhenFieldsAreNull() public function testMarshalResourceArray() { $factory = $this->resourceFactory(PersonResource::class); - $result = $this->instance->marshalResources($factory, [ + $result = $this->runMarshalResources($factory, [ ['name' => $nameA = $this->faker->name, 'age' => $ageA = $this->faker->randomNumber(2)], ['name' => $nameB = $this->faker->name, 'age' => $ageB = $this->faker->randomNumber(2)], ]); @@ -452,20 +456,31 @@ public function testMarshalResourceArray() $this->assertEquals($ageB, $persons[1]->age->get()); } - public function testMarshalResourceArrayEmpty() + public function testMarshalResourceWhenProvidedEmptyObject() { $factory = $this->resourceFactory(PersonResource::class); - $result = $this->instance->marshalResources($factory, []); + $result = $this->runMarshalResources($factory, new stdClass); - $this->assertFalse($result->hasErrors()); - $this->assertIsArray($result->getValue()); + $this->assertTrue($result->hasErrors()); + $this->assertSame([], $result->getValue()); $this->assertCount(0, $result->getValue()); + + $this->assertHasError($result->getErrors(), NotArrayValidationError::class, path: ''); + } + + public function testMarshalResourceWhenProvidedEmptyArray() + { + $factory = $this->resourceFactory(PersonResource::class); + $result = $this->runMarshalResources($factory, []); + + $this->assertFalse($result->hasErrors()); + $this->assertSame([], $result->getValue()); } public function testMarshalUnionResourceArray() { $factory = $this->resourceFactory(UnionResourceBase::class); - $result = $this->instance->marshalResources($factory, [ + $result = $this->runMarshalResources($factory, [ ['discriminator' => 'a', 'a' => $a = $this->faker->word, 'value' => $valueA = $this->faker->word], ['discriminator' => 'b', 'b' => $b = $this->faker->word, 'value' => $valueB = $this->faker->word], ]); @@ -484,7 +499,7 @@ public function testMarshalUnionResourceArray() public function testMarshalResourceArrayWithUnionResource() { $factory = $this->resourceFactory(UnionParentResource::class); - $result = $this->instance->marshalResources($factory, [ + $result = $this->runMarshalResources($factory, [ [ 'other' => $otherA = $this->faker->word, 'union' => ['discriminator' => 'a', 'a' => $a = $this->faker->word, 'value' => $valueA = $this->faker->word], @@ -514,7 +529,7 @@ public function testMarshalResourceArrayWithUnionResource() public function testMarshalResourceArrayWithResourceField() { $factory = $this->resourceFactory(ClassResource::class); - $result = $this->instance->marshalResources($factory, [[ + $result = $this->runMarshalResources($factory, [[ 'grade' => $grade = $this->faker->randomNumber(1), 'students' => [ ['name' => $nameA = $this->faker->name, 'age' => $ageA = $this->faker->randomNumber(2)], @@ -541,7 +556,7 @@ public function testMarshalResourceArrayWithResourceField() public function testMarshalResourceArrayWhenProvidedJsonObject() { $factory = $this->resourceFactory(PersonResource::class); - $result = $this->instance->marshalResources($factory, [ + $result = $this->runMarshalResources($factory, [ 'name' => $this->faker->name, 'age' => $this->faker->randomNumber(2), ]); @@ -553,7 +568,7 @@ public function testMarshalResourceArrayWhenProvidedJsonObject() public function testMarshalResourceArrayFieldWhenProvidedJsonObject() { $factory = $this->resourceFactory(ClassResource::class); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'grade' => $this->faker->randomNumber(1), 'students' => [ 'name' => $this->faker->name, @@ -568,7 +583,7 @@ public function testMarshalResourceArrayFieldWhenProvidedJsonObject() public function testRequiredValidationOnFields() { $factory = $this->resourceFactory(PersonResource::class); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'age' => 0, ]); @@ -579,7 +594,7 @@ public function testRequiredValidationOnFields() public function testRequiredValidationOnNestedFields() { $factory = $this->resourceFactory(PetResource::class); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'name' => $this->faker->name, 'owner' => [ 'name' => $this->faker->name, @@ -593,7 +608,7 @@ public function testRequiredValidationOnNestedFields() public function testRequiredValidationOnResourceArrayField() { $factory = $this->resourceFactory(ClassResource::class); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'grade' => $this->faker->numberBetween(0, 9), 'students' => [[ 'name' => $this->faker->name, @@ -607,7 +622,7 @@ public function testRequiredValidationOnResourceArrayField() public function testRequiredValidationOnManyResourcesInResourceArrayField() { $factory = $this->resourceFactory(ClassResource::class); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'grade' => $this->faker->numberBetween(0, 9), 'students' => [ ['name' => $this->faker->name], @@ -623,10 +638,10 @@ public function testRequiredValidationOnManyResourcesInResourceArrayField() public function testRequiredValidationManyOnSameResourceInResourceArrayField() { $factory = $this->resourceFactory(ClassResource::class); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'grade' => $this->faker->numberBetween(0, 9), 'students' => [ - [], + new stdClass, ] ]); @@ -644,7 +659,7 @@ public function testForbiddenValidationOnFields() return $person; }); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'name' => $this->faker->name, ]); @@ -665,7 +680,7 @@ public function testForbiddenValidationOnNestedFields() }); }); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'name' => $this->faker->name, 'owner' => [ 'name' => $this->faker->name, @@ -689,7 +704,7 @@ public function testForbiddenValidationOnResourceArrayField() }); }); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'grade' => $this->faker->numberBetween(0, 9), 'students' => [[ 'name' => $this->faker->name, @@ -713,7 +728,7 @@ public function testForbiddenValidationOnManyResourcesInResourceArrayField() }); }); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'grade' => $this->faker->numberBetween(0, 9), 'students' => [ ['name' => $this->faker->name], @@ -739,7 +754,7 @@ public function testForbiddenValidationManyOnSameResourceInResourceArrayField() }); }); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'grade' => $this->faker->numberBetween(0, 9), 'students' => [ ['name' => $this->faker->name, 'age' => $this->faker->randomNumber(2)], @@ -754,7 +769,7 @@ public function testForbiddenValidationManyOnSameResourceInResourceArrayField() public function testNullableWhenProvidedNull() { $factory = $this->personNullable(); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'name' => null, 'age' => null, ]); @@ -769,7 +784,7 @@ public function testNullableWhenProvidedNull() public function testNullableWhenProvidedValue() { $factory = $this->personNullable(); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'name' => $name = $this->faker->name, 'age' => $age = $this->faker->randomNumber(2), ]); @@ -784,7 +799,7 @@ public function testNullableWhenProvidedValue() public function testNullableValidationWhenNotProvided() { $factory = $this->personNullable(); - $result = $this->instance->marshalResource($factory, []); + $result = $this->runMarshalResource($factory, new stdClass); $this->assertCount(1, $result->getErrorsForPath('name')); $this->assertCount(1, $result->getErrorsForPath('age')); @@ -800,7 +815,7 @@ public function testNullableValidationWhenNotProvided() public function testNullableResourceField() { $factory = $this->petWithNullableOwner(); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'name' => $this->faker->name, 'owner' => null, ]); @@ -814,7 +829,7 @@ public function testNullableResourceField() public function testValidationWhenUnionResourceDiscriminatorIsMissing() { - $result = $this->instance->marshalResource($this->resourceFactory(UnionResourceBase::class), [ + $result = $this->runMarshalResource($this->resourceFactory(UnionResourceBase::class), [ 'value' => 'value' ]); @@ -824,7 +839,7 @@ public function testValidationWhenUnionResourceDiscriminatorIsMissing() public function testValidationWhenUnionResourceDiscriminatorIsUnknown() { - $result = $this->instance->marshalResource($this->resourceFactory(UnionResourceBase::class), [ + $result = $this->runMarshalResource($this->resourceFactory(UnionResourceBase::class), [ 'discriminator' => 'unknown' ]); @@ -834,7 +849,7 @@ public function testValidationWhenUnionResourceDiscriminatorIsUnknown() public function testValidationWhenFieldValidationFails() { - $result = $this->instance->marshalResource($this->resourceFactory(PersonResource::class), [ + $result = $this->runMarshalResource($this->resourceFactory(PersonResource::class), [ 'name' => 0, 'age' => 1, ]); @@ -845,7 +860,7 @@ public function testValidationWhenFieldValidationFails() public function testValidationWhenThereAreManyErrorsOnSameResource() { - $result = $this->instance->marshalResource($this->resourceFactory(PersonResource::class), [ + $result = $this->runMarshalResource($this->resourceFactory(PersonResource::class), [ 'name' => 0, 'age' => '', ]); @@ -857,7 +872,7 @@ public function testValidationWhenThereAreManyErrorsOnSameResource() public function testValidationOnNestedResourceFields() { - $result = $this->instance->marshalResource($this->resourceFactory(PetResource::class), [ + $result = $this->runMarshalResource($this->resourceFactory(PetResource::class), [ 'name' => $this->faker->name, 'owner' => [ 'name' => 0, @@ -871,18 +886,18 @@ public function testValidationOnNestedResourceFields() public function testValidationWhenRootResourceProvidedString() { - $result = $this->instance->marshalResource($this->resourceFactory(PersonResource::class), ''); + $result = $this->runMarshalResource($this->resourceFactory(PersonResource::class), ''); $this->assertCount(1, $errors = $result->getErrors()); - $this->assertHasError($errors, NotArrayValidationError::class); + $this->assertHasError($errors, NotObjectValidationError::class); } public function testValidationWhenRootResourceProvidedInteger() { - $result = $this->instance->marshalResource($this->resourceFactory(PersonResource::class), 0); + $result = $this->runMarshalResource($this->resourceFactory(PersonResource::class), 0); $this->assertCount(1, $errors = $result->getErrors()); - $this->assertHasError($errors, NotArrayValidationError::class); + $this->assertHasError($errors, NotObjectValidationError::class); } public function testMarshalResourceFieldSetsFilledTrueWhenProvided() @@ -894,7 +909,7 @@ public function testMarshalResourceFieldSetsFilledTrueWhenProvided() return $person; }); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'name' => $this->faker->name, 'age' => $this->faker->randomNumber(2) ]); @@ -914,7 +929,7 @@ public function testMarshalResourceFieldSetsFilledTrueWhenProvidedNull() return $person; }); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'name' => null, 'age' => null ]); @@ -934,7 +949,7 @@ public function testMarshalResourceFieldSetsFilledFalseWhenNotProvided() return $person; }); - $result = $this->instance->marshalResource($factory, []); + $result = $this->runMarshalResource($factory, new stdClass); $resource = $result->getValue(); assert($resource instanceof PersonResource); @@ -951,7 +966,7 @@ public function testMarshalFieldUsesOmittedDefaultValue() return $person; }); - $result = $this->instance->marshalResource($factory, []); + $result = $this->runMarshalResource($factory, new stdClass); $this->assertFalse($result->hasErrors()); $resource = $result->getValue(); @@ -969,7 +984,7 @@ public function testMarshalFieldDoesNotUseOmittedDefaultValueWhenProvidedNull() return $person; }); - $result = $this->instance->marshalResource($factory, ['age' => null]); + $result = $this->runMarshalResource($factory, ['age' => null]); $this->assertFalse($result->hasErrors()); $resource = $result->getValue(); @@ -987,7 +1002,7 @@ public function testMarshalFieldUsesNullDefaultValueWhenOmitted() return $person; }); - $result = $this->instance->marshalResource($factory, []); + $result = $this->runMarshalResource($factory, new stdClass); $this->assertFalse($result->hasErrors()); $resource = $result->getValue(); @@ -1005,7 +1020,7 @@ public function testMarshalFieldUsesNullDefaultValueWhenProvidedNull() return $person; }); - $result = $this->instance->marshalResource($factory, ['age' => null]); + $result = $this->runMarshalResource($factory, ['age' => null]); $this->assertFalse($result->hasErrors()); $resource = $result->getValue(); @@ -1024,7 +1039,7 @@ public function testMarshalFieldEvaluatesPredicatedOmittedDefault() return $person; }); - $result = $this->instance->marshalResource($factory, ['name' => '..']); + $result = $this->runMarshalResource($factory, ['name' => '..']); $this->assertFalse($result->hasErrors()); $resource = $result->getValue(); @@ -1042,7 +1057,7 @@ public function testMarshalFieldEvaluatesPredicatedNullDefault() return $person; }); - $result = $this->instance->marshalResource($factory, ['name' => '..']); + $result = $this->runMarshalResource($factory, ['name' => '..']); $this->assertFalse($result->hasErrors()); $resource = $result->getValue(); @@ -1059,7 +1074,7 @@ public function testMarshalResourceFieldWithResourceAsDefault() return $pet; }); - $result = $this->instance->marshalResource($factory, []); + $result = $this->runMarshalResource($factory, new stdClass); $this->assertFalse($result->hasErrors()); $resource = $result->getValue(); @@ -1073,7 +1088,7 @@ public function testCanParseIntegersWhenAllowsParsing() $this->instance->isStringBased(); $factory = $this->resourceFactory(PersonResource::class); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'name' => $name = $this->faker->name, 'age' => $age = '1', ]); @@ -1097,7 +1112,7 @@ public function testFieldEmptyStringAsNull() return $dynamic; }); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'time' => '', 'carbon' => '', 'string' => '', @@ -1131,7 +1146,7 @@ public function testRawFieldWhenProvidedArray() return $dynamic; }); - $result = $this->instance->marshalResource($factory, [ + $result = $this->runMarshalResource($factory, [ 'raw_array' => [], 'raw_int' => 1, 'raw_string' => "raw", @@ -1163,4 +1178,31 @@ public function testRawFieldWhenProvidedArray() }); } + + private function runMarshalResource(ResourceFactory $factory, mixed $data): ResourceMarshallerResult + { + if (is_array($data)) { + $data = json_decode(json_encode($data)); + } + + return $this->instance->marshalResource($factory, $data); + } + + private function runMarshalNullableResource(ResourceFactory $factory, mixed $data): ResourceMarshallerResult + { + if (is_array($data)) { + $data = json_decode(json_encode($data)); + } + + return $this->instance->marshalNullableResource($factory, $data); + } + + private function runMarshalResources(ResourceFactory $factory, mixed $data): ResourceMarshallerResult + { + if (is_array($data)) { + $data = json_decode(json_encode($data)); + } + + return $this->instance->marshalResources($factory, $data); + } } \ No newline at end of file diff --git a/tests/Meta/ArrayResourceFieldsResource.php b/tests/Meta/ArrayResourceFieldsResource.php index 51792fe2..b3a33919 100644 --- a/tests/Meta/ArrayResourceFieldsResource.php +++ b/tests/Meta/ArrayResourceFieldsResource.php @@ -3,7 +3,6 @@ namespace Seier\Resting\Tests\Meta; use Seier\Resting\Resource; -use Seier\Resting\Fields\ArrayField; use Seier\Resting\Fields\ResourceArrayField; class ArrayResourceFieldsResource extends Resource diff --git a/tests/Meta/CarbonPeriodQuery.php b/tests/Meta/CarbonPeriodQuery.php new file mode 100644 index 00000000..6a8d4cb7 --- /dev/null +++ b/tests/Meta/CarbonPeriodQuery.php @@ -0,0 +1,16 @@ +period = (new CarbonPeriodField)->notRequired(); + } +} \ No newline at end of file diff --git a/tests/Meta/NotRequiredPersonResource.php b/tests/Meta/NotRequiredPersonResource.php new file mode 100644 index 00000000..9913c7cc --- /dev/null +++ b/tests/Meta/NotRequiredPersonResource.php @@ -0,0 +1,21 @@ +name = (new StringField)->notRequired(); + $this->age = (new IntField)->notRequired(); + } +} \ No newline at end of file diff --git a/tests/Meta/PersonParams.php b/tests/Meta/PersonParams.php new file mode 100644 index 00000000..8808d2d9 --- /dev/null +++ b/tests/Meta/PersonParams.php @@ -0,0 +1,19 @@ +name = (new StringField)->notRequired(); + $this->age = (new IntField)->notRequired(); + } +} \ No newline at end of file diff --git a/tests/Meta/PersonQuery.php b/tests/Meta/PersonQuery.php new file mode 100644 index 00000000..79e43c28 --- /dev/null +++ b/tests/Meta/PersonQuery.php @@ -0,0 +1,19 @@ +name = (new StringField)->notRequired(); + $this->age = (new IntField)->notRequired(); + } +} \ No newline at end of file diff --git a/tests/Parsing/BoolParserTest.php b/tests/Parsing/BoolParserTest.php index 2ca13c36..c3b7be2f 100644 --- a/tests/Parsing/BoolParserTest.php +++ b/tests/Parsing/BoolParserTest.php @@ -7,8 +7,8 @@ use Seier\Resting\Tests\TestCase; use Seier\Resting\Parsing\BoolParser; use Seier\Resting\Parsing\BoolParseError; -use Seier\Resting\Parsing\DefaultParseContext; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Parsing\DefaultParseContext; class BoolParserTest extends TestCase { diff --git a/tests/Parsing/CarbonParserTest.php b/tests/Parsing/CarbonParserTest.php index d8193b03..5becbd38 100644 --- a/tests/Parsing/CarbonParserTest.php +++ b/tests/Parsing/CarbonParserTest.php @@ -8,8 +8,8 @@ use Seier\Resting\Tests\TestCase; use Seier\Resting\Parsing\CarbonParser; use Seier\Resting\Parsing\CarbonParseError; -use Seier\Resting\Parsing\DefaultParseContext; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Parsing\DefaultParseContext; class CarbonParserTest extends TestCase { diff --git a/tests/Parsing/CarbonPeriodParserTest.php b/tests/Parsing/CarbonPeriodParserTest.php index 237085b9..21281b88 100644 --- a/tests/Parsing/CarbonPeriodParserTest.php +++ b/tests/Parsing/CarbonPeriodParserTest.php @@ -8,10 +8,10 @@ use Seier\Resting\Tests\TestCase; use Seier\Resting\Parsing\CarbonParser; use Seier\Resting\Parsing\CarbonParseError; +use Seier\Resting\Tests\Meta\AssertsErrors; use Seier\Resting\Parsing\CarbonPeriodParser; use Seier\Resting\Parsing\DefaultParseContext; use Seier\Resting\Parsing\CarbonPeriodParseError; -use Seier\Resting\Tests\Meta\AssertsErrors; class CarbonPeriodParserTest extends TestCase { diff --git a/tests/Parsing/IntParserTest.php b/tests/Parsing/IntParserTest.php index 25fcdaac..07898d8d 100644 --- a/tests/Parsing/IntParserTest.php +++ b/tests/Parsing/IntParserTest.php @@ -7,8 +7,8 @@ use Seier\Resting\Tests\TestCase; use Seier\Resting\Parsing\IntParser; use Seier\Resting\Parsing\IntParseError; -use Seier\Resting\Parsing\DefaultParseContext; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Parsing\DefaultParseContext; class IntParserTest extends TestCase { diff --git a/tests/Parsing/NumberParserTest.php b/tests/Parsing/NumberParserTest.php index 6f3c0a40..92d76b98 100644 --- a/tests/Parsing/NumberParserTest.php +++ b/tests/Parsing/NumberParserTest.php @@ -7,8 +7,8 @@ use Seier\Resting\Tests\TestCase; use Seier\Resting\Parsing\NumberParser; use Seier\Resting\Parsing\NumberParseError; -use Seier\Resting\Parsing\DefaultParseContext; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Parsing\DefaultParseContext; class NumberParserTest extends TestCase { diff --git a/tests/Parsing/TimeParserTest.php b/tests/Parsing/TimeParserTest.php index 4baf7dfd..17ef8905 100644 --- a/tests/Parsing/TimeParserTest.php +++ b/tests/Parsing/TimeParserTest.php @@ -7,8 +7,8 @@ use Seier\Resting\Tests\TestCase; use Seier\Resting\Parsing\TimeParser; use Seier\Resting\Parsing\TimeParseError; -use Seier\Resting\Parsing\DefaultParseContext; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Parsing\DefaultParseContext; class TimeParserTest extends TestCase { diff --git a/tests/Support/LaravelIntegrationTest.php b/tests/Support/LaravelIntegrationTest.php new file mode 100644 index 00000000..a452ebe2 --- /dev/null +++ b/tests/Support/LaravelIntegrationTest.php @@ -0,0 +1,261 @@ +instance = new RestingMiddleware(); + } + + public function testEmptyResourceIsReturnedCorrectly() + { + $harness = new LaravelIntegrationTestHarness( + methods: ['GET'], + action: fn (): PersonResource => (new PersonResource)->only(), + ); + + $harnessRun = $harness->request(content: ''); + + $this->assertInstanceOf(RestingResponse::class, $response = $harnessRun->getResponse()); + $this->assertArrayHasKey('data', $data = $response->toArray()); + $this->assertTrue($data['data'] instanceof stdClass); + $this->assertSame( + (array)new stdClass, + (array)$data['data'] + ); + } + + public function testWhenNullIsProvidedWhenResourceIsExpected() + { + $response = new RestingResponse(data: []); + $harness = new LaravelIntegrationTestHarness( + methods: ['POST'], + action: fn (PersonResource $r) => $response, + ); + + $harnessRun = $harness->request(content: 'null'); + + $this->assertFalse($harnessRun->wasActionCalled()); + $this->assertInstanceOf(JsonResponse::class, $response = $harnessRun->getResponse()); + + $this->assertSame(422, $response->getStatusCode()); + $this->assertArraySubset( + [ + 'message' => 'One or more errors prevented the request from being fulfilled.', + 'errors' => [ + 'body' => [ + [ + 'path' => 'name', + 'message' => 'Value is required, but was not received.' + ], + [ + 'path' => 'age', + 'message' => 'Value is required, but was not received.' + ] + ] + ] + ], + json_decode($response->getContent(), JSON_OBJECT_AS_ARRAY) + ); + } + + public function testWhenNullIsProvidedWhenArrayOfResourcesIsExpected() + { + $response = new RestingResponse(data: []); + $harness = new LaravelIntegrationTestHarness( + methods: ['POST'], + action: fn (PersonResource ...$rs) => $response, + ); + + $harnessRun = $harness->request(content: 'null'); + + $this->assertFalse($harnessRun->wasActionCalled()); + $this->assertInstanceOf(JsonResponse::class, $response = $harnessRun->getResponse()); + + $this->assertSame(422, $response->getStatusCode()); + $this->assertArraySubset( + [ + 'message' => 'One or more errors prevented the request from being fulfilled.', + 'errors' => [ + 'body' => [[ + 'path' => '', + 'message' => 'The value was expected to be an array, null received instead.' + ]] + ] + ], + json_decode($response->getContent(), JSON_OBJECT_AS_ARRAY) + ); + } + + public function testCanProvideNullToNullableResourceThroughBody() + { + $response = new RestingResponse(data: []); + $harness = new LaravelIntegrationTestHarness( + methods: ['POST'], + action: function (?PersonResource $r) use ($response) { + $this->assertNull($r); + return $response; + }, + ); + + $harnessRun = $harness->request(content: 'null'); + + $this->assertTrue($harnessRun->wasActionCalled()); + $this->assertInstanceOf(RestingResponse::class, $response = $harnessRun->getResponse()); + $this->assertSame(['data' => []], $response->toArray()); + } + + public function testCanParseContentIntoResource() + { + $name = $this->faker->uuid(); + $age = 18; + + $harness = new LaravelIntegrationTestHarness( + methods: ['POST'], + action: fn (PersonResource $r) => $r, + ); + + $harnessRun = $harness->request(content: json_encode([ + 'name' => $name, + 'age' => $age, + ])); + + $this->assertTrue($harnessRun->wasActionCalled()); + $this->assertCount(1, $harnessRun->getActionCallArguments()); + + $this->assertInstanceOf(PersonResource::class, $person = $harnessRun->getActionCallArguments()[0]); + $this->assertSame($name, $person->name->get()); + $this->assertSame($age, $person->age->get()); + } + + public function testCanParseQueryContentIntoResource() + { + $name = $this->faker->uuid(); + $age = 18; + + $harness = new LaravelIntegrationTestHarness( + methods: ['POST'], + action: fn (PersonQuery $q) => $q, + ); + + $harnessRun = $harness->request(query: [ + 'name' => $name, + 'age' => $age, + ]); + + $this->assertTrue($harnessRun->wasActionCalled()); + $this->assertCount(1, $harnessRun->getActionCallArguments()); + + $this->assertInstanceOf(PersonQuery::class, $person = $harnessRun->getActionCallArguments()[0]); + $this->assertSame($name, $person->name->get()); + $this->assertSame($age, $person->age->get()); + } + + public function testCanParseParamContentIntoResource() + { + $name = $this->faker->uuid(); + + $harness = new LaravelIntegrationTestHarness( + methods: ['POST'], + action: fn (PersonParams $p) => $p, + path: '/create-person/{name}' + ); + + $harnessRun = $harness->request(url: "/create-person/$name"); + + $this->assertTrue($harnessRun->wasActionCalled()); + $this->assertCount(1, $harnessRun->getActionCallArguments()); + + $this->assertInstanceOf(PersonParams::class, $person = $harnessRun->getActionCallArguments()[0]); + $this->assertSame($name, $person->name->get()); + $this->assertNull($person->age->get()); + } + + public function testWhenContentNotProvidedAndNothingExpected() + { + $name = $this->faker->uuid(); + + $harness = new LaravelIntegrationTestHarness( + methods: ['GET'], + action: fn (PersonParams $p) => $p, + path: '/search/{name}' + ); + + $harnessRun = $harness->request(url: "/search/$name", content: null); + + $this->assertTrue($harnessRun->wasActionCalled()); + + $this->assertInstanceOf(PersonParams::class, $person = $harnessRun->getActionCallArguments()[0]); + $this->assertSame($name, $person->name->get()); + } + + public function testContentCanBeNullWhenThereAreOnlyNonRequiredFields() + { + $harness = new LaravelIntegrationTestHarness( + methods: ['GET'], + action: fn (NotRequiredPersonResource $p) => $p, + path: '/search' + ); + + $harnessRun = $harness->request(url: "/search", content: null); + + $this->assertTrue($harnessRun->wasActionCalled()); + + $this->assertInstanceOf(NotRequiredPersonResource::class, $person = $harnessRun->getActionCallArguments()[0]); + $this->assertNull($person->name->get()); + $this->assertNull($person->age->get()); + } + + public function testWhenExpectingOneResourceButProvidedArrayOfObjects() + { + $harness = new LaravelIntegrationTestHarness( + methods: ['POST'], + action: fn (PersonResource $p) => $p, + ); + + $harnessRun = $harness->request(content: json_encode([ + ['name' => $this->faker->uuid(), 'age' => 1], + ])); + + $this->assertFalse($harnessRun->wasActionCalled()); + + $this->assertInstanceOf(JsonResponse::class, $response = $harnessRun->getResponse()); + $this->assertStringContainsString('The value was expected to be an object, array [object] received instead.', $response->getContent()); + } + + public function testWhenExpectingCarbonPeriodFieldInQuery() + { + $harness = new LaravelIntegrationTestHarness( + methods: ['POST'], + action: fn (CarbonPeriodQuery $p) => $p, + path: '/search' + ); + + $harnessRun = $harness->request(url: '/search', query: ['period' => '2025-01-01,2025-01-01']); + + $this->assertTrue($harnessRun->wasActionCalled()); + $this->assertInstanceOf(RestingResponse::class, $harnessRun->getResponse()); + $this->assertCount(1, $harnessRun->getActionCallArguments()); + $this->assertInstanceOf(CarbonPeriodQuery::class, $query = $harnessRun->getActionCallArguments()[0]); + $this->assertSame('2025-01-01', $query->period->start()->toDateString()); + $this->assertSame('2025-01-01', $query->period->end()->toDateString()); + } +} \ No newline at end of file diff --git a/tests/Support/LaravelIntegrationTestHarness.php b/tests/Support/LaravelIntegrationTestHarness.php new file mode 100644 index 00000000..baeb8b85 --- /dev/null +++ b/tests/Support/LaravelIntegrationTestHarness.php @@ -0,0 +1,114 @@ +restingMiddleware = new RestingMiddleware(); + $this->path = trim($path ?? Str::random(), '/'); + $this->actionClosure = $action; + $this->route = new Route( + methods: $methods, + uri: $this->path, + action: $this->actionClosure, + ); + } + + public function request(?string $url = null, string $content = null, array $query = []): LaravelIntegrationTestHarnessRunResult + { + $url ??= $this->path; + $url = trim($url, '/'); + + $this->response = null; + $this->actionCallArguments = null; + $this->wasActionCalled = false; + + $server = $this->createFakeServerEnvironments( + requestPath: $url, + queryParameters: $query, + ); + + $this->request = new Request(query: $query, server: $server, content: $content); + $this->request->setRouteResolver(fn () => $this->route); + $this->route->bind($this->request); + $this->response = $this->restingMiddleware->handle($this->request, function () { + return $this->callAction([$this, 'controllerMethod'][1], $this->route->parameters()); + }); + + return new LaravelIntegrationTestHarnessRunResult( + request: $this->request, + response: $this->response, + wasActionCalled: $this->wasActionCalled, + actionCallArguments: $this->actionCallArguments, + ); + } + + public function resolveReflectionFunction(string $methodName): ReflectionFunctionAbstract + { + return new ReflectionClosure($this->actionClosure); + } + + public function controllerMethod(): mixed + { + $this->actionCallArguments = func_get_args(); + $this->wasActionCalled = true; + + return ($this->actionClosure)(...$this->actionCallArguments); + } + + private function createFakeServerEnvironments(string $requestPath, array $queryParameters): array + { + $queryPath = ""; + foreach ($queryParameters as $queryKey => $queryValue) { + $queryPath .= $queryPath === '' ? '?' : '&'; + $queryPath .= "$queryKey=$queryValue"; + } + + return [ + "DOCUMENT_ROOT" => "/api/public", + "REMOTE_ADDR" => "127.0.0.1", + "SERVER_SOFTWARE" => "PHP 8.2.29 Development Server", + "SERVER_PROTOCOL" => "HTTP/1.1", + "SERVER_NAME" => "127.0.0.1", + "SERVER_PORT" => "9000", + "REQUEST_URI" => "/$requestPath", + "REQUEST_METHOD" => Arr::random($this->route->methods()), + "SCRIPT_NAME" => "/index.php", + "SCRIPT_FILENAME" => "/api/public/index.php", + "PATH_INFO" => "/$requestPath", + "PHP_SELF" => "/index.php/$requestPath", + "HTTP_HOST" => "localhost:9000", + ]; + } +} \ No newline at end of file diff --git a/tests/Support/LaravelIntegrationTestHarnessRunResult.php b/tests/Support/LaravelIntegrationTestHarnessRunResult.php new file mode 100644 index 00000000..8b2e2041 --- /dev/null +++ b/tests/Support/LaravelIntegrationTestHarnessRunResult.php @@ -0,0 +1,48 @@ +request = $request; + $this->response = $response; + $this->wasActionCalled = $wasActionCalled; + $this->actionCallArguments = $actionCallArguments; + } + + public function getRequest(): Request + { + return $this->request; + } + + public function getResponse(): RestingResponse|Response + { + return $this->response; + } + + public function wasActionCalled(): bool + { + return $this->wasActionCalled; + } + + public function getActionCallArguments(): ?array + { + return $this->actionCallArguments; + } +} \ No newline at end of file diff --git a/tests/Validation/ArrayValidatorTest.php b/tests/Validation/ArrayValidatorTest.php index bdce38a5..7b7c4f0a 100644 --- a/tests/Validation/ArrayValidatorTest.php +++ b/tests/Validation/ArrayValidatorTest.php @@ -5,10 +5,10 @@ use Seier\Resting\Tests\TestCase; +use Seier\Resting\Tests\Meta\AssertsErrors; use Seier\Resting\Validation\ArrayValidator; use Seier\Resting\Tests\Meta\MockPrimaryValidator; use Seier\Resting\Tests\Meta\MockSecondaryValidator; -use Seier\Resting\Tests\Meta\AssertsErrors; use Seier\Resting\Tests\Meta\MockPrimaryValidationError; use Seier\Resting\Tests\Meta\MockSecondaryValidationError; use Seier\Resting\Validation\Errors\NotArrayValidationError; diff --git a/tests/Validation/BoolValidatorTest.php b/tests/Validation/BoolValidatorTest.php index 8bafcbae..31e1b2b3 100644 --- a/tests/Validation/BoolValidatorTest.php +++ b/tests/Validation/BoolValidatorTest.php @@ -6,8 +6,8 @@ use Seier\Resting\Tests\TestCase; use Seier\Resting\Validation\BoolValidator; -use Seier\Resting\Tests\Meta\MockSecondaryValidator; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Tests\Meta\MockSecondaryValidator; use Seier\Resting\Tests\Meta\MockSecondaryValidationError; use Seier\Resting\Validation\Errors\NotBoolValidationError; diff --git a/tests/Validation/CarbonPeriodValidatorTest.php b/tests/Validation/CarbonPeriodValidatorTest.php index fe63addc..078ad275 100644 --- a/tests/Validation/CarbonPeriodValidatorTest.php +++ b/tests/Validation/CarbonPeriodValidatorTest.php @@ -6,10 +6,10 @@ use Carbon\CarbonPeriod; use Seier\Resting\Tests\TestCase; +use Seier\Resting\Tests\Meta\AssertsErrors; use Seier\Resting\Validation\CarbonValidator; use Seier\Resting\Validation\CarbonPeriodValidator; use Seier\Resting\Tests\Meta\MockSecondaryValidator; -use Seier\Resting\Tests\Meta\AssertsErrors; use Seier\Resting\Tests\Meta\MockSecondaryValidationError; use Seier\Resting\Validation\Errors\NotCarbonPeriodValidationError; use Seier\Resting\Validation\Secondary\Comparable\MinValidationError; diff --git a/tests/Validation/CarbonValidatorTest.php b/tests/Validation/CarbonValidatorTest.php index 080310d3..5c887890 100644 --- a/tests/Validation/CarbonValidatorTest.php +++ b/tests/Validation/CarbonValidatorTest.php @@ -5,9 +5,9 @@ use Seier\Resting\Tests\TestCase; +use Seier\Resting\Tests\Meta\AssertsErrors; use Seier\Resting\Validation\CarbonValidator; use Seier\Resting\Tests\Meta\MockSecondaryValidator; -use Seier\Resting\Tests\Meta\AssertsErrors; use Seier\Resting\Tests\Meta\MockSecondaryValidationError; use Seier\Resting\Validation\Errors\NotCarbonValidationError; diff --git a/tests/Validation/IntValidatorTest.php b/tests/Validation/IntValidatorTest.php index c7e98859..3c80f88e 100644 --- a/tests/Validation/IntValidatorTest.php +++ b/tests/Validation/IntValidatorTest.php @@ -6,8 +6,8 @@ use Seier\Resting\Tests\TestCase; use Seier\Resting\Validation\IntValidator; -use Seier\Resting\Tests\Meta\MockSecondaryValidator; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Tests\Meta\MockSecondaryValidator; use Seier\Resting\Tests\Meta\MockSecondaryValidationError; use Seier\Resting\Validation\Errors\NotIntValidationError; diff --git a/tests/Validation/NumberValidatorTest.php b/tests/Validation/NumberValidatorTest.php index 66364280..a47db66d 100644 --- a/tests/Validation/NumberValidatorTest.php +++ b/tests/Validation/NumberValidatorTest.php @@ -5,9 +5,9 @@ use Seier\Resting\Tests\TestCase; +use Seier\Resting\Tests\Meta\AssertsErrors; use Seier\Resting\Validation\NumberValidator; use Seier\Resting\Tests\Meta\MockSecondaryValidator; -use Seier\Resting\Tests\Meta\AssertsErrors; use Seier\Resting\Tests\Meta\MockSecondaryValidationError; use Seier\Resting\Validation\Errors\NotNumberValidationError; diff --git a/tests/Validation/Predicates/FactoriesTest.php b/tests/Validation/Predicates/FactoriesTest.php index 4bc12695..d4fa24ef 100644 --- a/tests/Validation/Predicates/FactoriesTest.php +++ b/tests/Validation/Predicates/FactoriesTest.php @@ -16,11 +16,11 @@ use function Seier\Resting\Validation\Predicates\any; use function Seier\Resting\Validation\Predicates\all; use function Seier\Resting\Validation\Predicates\none; +use function Seier\Resting\Validation\Predicates\when; use function Seier\Resting\Validation\Predicates\whenIn; use function Seier\Resting\Validation\Predicates\whenNull; use function Seier\Resting\Validation\Predicates\whenNotIn; use function Seier\Resting\Validation\Predicates\whenEquals; -use function Seier\Resting\Validation\Predicates\when; use function Seier\Resting\Validation\Predicates\whenNotNull; use function Seier\Resting\Validation\Predicates\whenProvided; use function Seier\Resting\Validation\Predicates\whenNotEquals; diff --git a/tests/Validation/Secondary/Anonymous/AnonymousValidationTest.php b/tests/Validation/Secondary/Anonymous/AnonymousValidationTest.php index 5b6aee08..69b704d4 100644 --- a/tests/Validation/Secondary/Anonymous/AnonymousValidationTest.php +++ b/tests/Validation/Secondary/Anonymous/AnonymousValidationTest.php @@ -5,8 +5,8 @@ use Seier\Resting\Tests\TestCase; -use Seier\Resting\Tests\Meta\MockPrimaryValidator; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Tests\Meta\MockPrimaryValidator; use Seier\Resting\Validation\Secondary\Anonymous\AnonymousValidationError; class AnonymousValidationTest extends TestCase diff --git a/tests/Validation/Secondary/Arrays/ArrayMaxSizeValidatorTest.php b/tests/Validation/Secondary/Arrays/ArrayMaxSizeValidatorTest.php index c01901b5..95c70425 100644 --- a/tests/Validation/Secondary/Arrays/ArrayMaxSizeValidatorTest.php +++ b/tests/Validation/Secondary/Arrays/ArrayMaxSizeValidatorTest.php @@ -6,8 +6,8 @@ use Seier\Resting\Tests\TestCase; use Jchook\AssertThrows\AssertThrows; -use Seier\Resting\Exceptions\RestingInternalException; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Exceptions\RestingInternalException; use Seier\Resting\Validation\Secondary\Arrays\ArrayMaxSizeValidator; use Seier\Resting\Validation\Secondary\Arrays\ArrayMaxSizeValidationError; diff --git a/tests/Validation/Secondary/Arrays/ArrayMinSizeValidatorTest.php b/tests/Validation/Secondary/Arrays/ArrayMinSizeValidatorTest.php index 27c21943..cb9cb90f 100644 --- a/tests/Validation/Secondary/Arrays/ArrayMinSizeValidatorTest.php +++ b/tests/Validation/Secondary/Arrays/ArrayMinSizeValidatorTest.php @@ -6,8 +6,8 @@ use Seier\Resting\Tests\TestCase; use Jchook\AssertThrows\AssertThrows; -use Seier\Resting\Exceptions\RestingInternalException; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Exceptions\RestingInternalException; use Seier\Resting\Validation\Secondary\Arrays\ArrayMinSizeValidator; use Seier\Resting\Validation\Secondary\Arrays\ArrayMinSizeValidationError; diff --git a/tests/Validation/Secondary/Arrays/ArraySizeValidatorTest.php b/tests/Validation/Secondary/Arrays/ArraySizeValidatorTest.php index 88dc8606..e4be6d46 100644 --- a/tests/Validation/Secondary/Arrays/ArraySizeValidatorTest.php +++ b/tests/Validation/Secondary/Arrays/ArraySizeValidatorTest.php @@ -6,8 +6,8 @@ use Seier\Resting\Tests\TestCase; use Jchook\AssertThrows\AssertThrows; -use Seier\Resting\Exceptions\RestingInternalException; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Exceptions\RestingInternalException; use Seier\Resting\Validation\Secondary\Arrays\ArraySizeValidator; use Seier\Resting\Validation\Secondary\Arrays\ArraySizeValidationError; diff --git a/tests/Validation/Secondary/Arrays/ArrayValidationTest.php b/tests/Validation/Secondary/Arrays/ArrayValidationTest.php index ed9e36c8..ca35a877 100644 --- a/tests/Validation/Secondary/Arrays/ArrayValidationTest.php +++ b/tests/Validation/Secondary/Arrays/ArrayValidationTest.php @@ -5,8 +5,8 @@ use Seier\Resting\Tests\TestCase; -use Seier\Resting\Tests\Meta\MockPrimaryValidator; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Tests\Meta\MockPrimaryValidator; use Seier\Resting\Validation\Secondary\Arrays\ArraySizeValidationError; use Seier\Resting\Validation\Secondary\Arrays\ArrayMinSizeValidationError; use Seier\Resting\Validation\Secondary\Arrays\ArrayMaxSizeValidationError; diff --git a/tests/Validation/Secondary/CarbonPeriod/CarbonPeriodMaxDurationValidatorTest.php b/tests/Validation/Secondary/CarbonPeriod/CarbonPeriodMaxDurationValidatorTest.php index d34c3b0f..6ff74dd2 100644 --- a/tests/Validation/Secondary/CarbonPeriod/CarbonPeriodMaxDurationValidatorTest.php +++ b/tests/Validation/Secondary/CarbonPeriod/CarbonPeriodMaxDurationValidatorTest.php @@ -8,8 +8,8 @@ use Carbon\CarbonInterval; use Seier\Resting\Tests\TestCase; use Jchook\AssertThrows\AssertThrows; -use Seier\Resting\Exceptions\RestingInternalException; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Exceptions\RestingInternalException; use Seier\Resting\Validation\Secondary\CarbonPeriod\CarbonPeriodMaxDurationValidator; use Seier\Resting\Validation\Secondary\CarbonPeriod\CarbonPeriodMaxDurationValidationError; diff --git a/tests/Validation/Secondary/CarbonPeriod/CarbonPeriodValidationTest.php b/tests/Validation/Secondary/CarbonPeriod/CarbonPeriodValidationTest.php index 7a5b4446..ca30c796 100644 --- a/tests/Validation/Secondary/CarbonPeriod/CarbonPeriodValidationTest.php +++ b/tests/Validation/Secondary/CarbonPeriod/CarbonPeriodValidationTest.php @@ -8,8 +8,8 @@ use Carbon\CarbonPeriod; use Carbon\CarbonInterval; use Seier\Resting\Tests\TestCase; -use Seier\Resting\Tests\Meta\MockPrimaryValidator; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Tests\Meta\MockPrimaryValidator; use Seier\Resting\Validation\Secondary\CarbonPeriod\CarbonPeriodMinDurationValidationError; use Seier\Resting\Validation\Secondary\CarbonPeriod\CarbonPeriodMaxDurationValidationError; diff --git a/tests/Validation/Secondary/In/InValidationTest.php b/tests/Validation/Secondary/In/InValidationTest.php index 578a42e4..6980ea0c 100644 --- a/tests/Validation/Secondary/In/InValidationTest.php +++ b/tests/Validation/Secondary/In/InValidationTest.php @@ -5,8 +5,8 @@ use Seier\Resting\Tests\TestCase; -use Seier\Resting\Tests\Meta\MockPrimaryValidator; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Tests\Meta\MockPrimaryValidator; use Seier\Resting\Validation\Secondary\In\InValidationError; class InValidationTest extends TestCase diff --git a/tests/Validation/Secondary/Numeric/NumericValidationTest.php b/tests/Validation/Secondary/Numeric/NumericValidationTest.php index 517d21a4..dcd6616b 100644 --- a/tests/Validation/Secondary/Numeric/NumericValidationTest.php +++ b/tests/Validation/Secondary/Numeric/NumericValidationTest.php @@ -5,8 +5,8 @@ use Seier\Resting\Tests\TestCase; -use Seier\Resting\Tests\Meta\MockPrimaryValidator; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Tests\Meta\MockPrimaryValidator; use Seier\Resting\Validation\Secondary\SupportsSecondaryValidation; use Seier\Resting\Validation\Secondary\Comparable\MinValidationError; use Seier\Resting\Validation\Secondary\Comparable\MaxValidationError; diff --git a/tests/Validation/Secondary/String/StringLengthValidatorTest.php b/tests/Validation/Secondary/String/StringLengthValidatorTest.php index 96f7ea98..69f1f46b 100644 --- a/tests/Validation/Secondary/String/StringLengthValidatorTest.php +++ b/tests/Validation/Secondary/String/StringLengthValidatorTest.php @@ -6,8 +6,8 @@ use Seier\Resting\Tests\TestCase; use Jchook\AssertThrows\AssertThrows; -use Seier\Resting\Exceptions\RestingInternalException; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Exceptions\RestingInternalException; use Seier\Resting\Validation\Secondary\String\StringLengthValidator; use Seier\Resting\Validation\Secondary\String\StringLengthValidationError; diff --git a/tests/Validation/Secondary/String/StringMaxLengthValidatorTest.php b/tests/Validation/Secondary/String/StringMaxLengthValidatorTest.php index 87b20d55..9ad94846 100644 --- a/tests/Validation/Secondary/String/StringMaxLengthValidatorTest.php +++ b/tests/Validation/Secondary/String/StringMaxLengthValidatorTest.php @@ -6,8 +6,8 @@ use Seier\Resting\Tests\TestCase; use Jchook\AssertThrows\AssertThrows; -use Seier\Resting\Exceptions\RestingInternalException; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Exceptions\RestingInternalException; use Seier\Resting\Validation\Secondary\String\StringMaxLengthValidator; use Seier\Resting\Validation\Secondary\String\StringMaxLengthValidationError; diff --git a/tests/Validation/Secondary/String/StringMinLengthValidatorTest.php b/tests/Validation/Secondary/String/StringMinLengthValidatorTest.php index b96a7e01..b309e978 100644 --- a/tests/Validation/Secondary/String/StringMinLengthValidatorTest.php +++ b/tests/Validation/Secondary/String/StringMinLengthValidatorTest.php @@ -6,8 +6,8 @@ use Seier\Resting\Tests\TestCase; use Jchook\AssertThrows\AssertThrows; -use Seier\Resting\Exceptions\RestingInternalException; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Exceptions\RestingInternalException; use Seier\Resting\Validation\Secondary\String\StringMinLengthValidator; use Seier\Resting\Validation\Secondary\String\StringMinLengthValidationError; diff --git a/tests/Validation/Secondary/String/StringRegexValidatorTest.php b/tests/Validation/Secondary/String/StringRegexValidatorTest.php index 65b3d010..de487cde 100644 --- a/tests/Validation/Secondary/String/StringRegexValidatorTest.php +++ b/tests/Validation/Secondary/String/StringRegexValidatorTest.php @@ -6,8 +6,8 @@ use Seier\Resting\Tests\TestCase; use Jchook\AssertThrows\AssertThrows; -use Seier\Resting\Exceptions\RestingInternalException; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Exceptions\RestingInternalException; use Seier\Resting\Validation\Secondary\String\StringRegexValidator; use Seier\Resting\Validation\Secondary\String\StringRegexValidationError; diff --git a/tests/Validation/Secondary/String/StringValidationTest.php b/tests/Validation/Secondary/String/StringValidationTest.php index 184d9d25..5b6b6069 100644 --- a/tests/Validation/Secondary/String/StringValidationTest.php +++ b/tests/Validation/Secondary/String/StringValidationTest.php @@ -5,8 +5,8 @@ use Seier\Resting\Tests\TestCase; -use Seier\Resting\Tests\Meta\MockPrimaryValidator; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Tests\Meta\MockPrimaryValidator; use Seier\Resting\Validation\Secondary\String\StringRegexValidationError; use Seier\Resting\Validation\Secondary\String\StringLengthValidationError; use Seier\Resting\Validation\Secondary\String\StringMinLengthValidationError; diff --git a/tests/Validation/StringValidatorTest.php b/tests/Validation/StringValidatorTest.php index 4bbea599..e7908100 100644 --- a/tests/Validation/StringValidatorTest.php +++ b/tests/Validation/StringValidatorTest.php @@ -5,9 +5,9 @@ use Seier\Resting\Tests\TestCase; +use Seier\Resting\Tests\Meta\AssertsErrors; use Seier\Resting\Validation\StringValidator; use Seier\Resting\Tests\Meta\MockSecondaryValidator; -use Seier\Resting\Tests\Meta\AssertsErrors; use Seier\Resting\Tests\Meta\MockSecondaryValidationError; use Seier\Resting\Validation\Errors\NotStringValidationError; diff --git a/tests/Validation/TimeValidatorTest.php b/tests/Validation/TimeValidatorTest.php index cf5587e6..e09ade00 100644 --- a/tests/Validation/TimeValidatorTest.php +++ b/tests/Validation/TimeValidatorTest.php @@ -7,8 +7,8 @@ use Seier\Resting\Fields\Time; use Seier\Resting\Tests\TestCase; use Seier\Resting\Validation\TimeValidator; -use Seier\Resting\Tests\Meta\MockSecondaryValidator; use Seier\Resting\Tests\Meta\AssertsErrors; +use Seier\Resting\Tests\Meta\MockSecondaryValidator; use Seier\Resting\Tests\Meta\MockSecondaryValidationError; use Seier\Resting\Validation\Errors\NotTimeValidationError; From 5714cf7e52167034415009555da317da41af113e Mon Sep 17 00:00:00 2001 From: Thomas-Rosenkrans-Vestergaard Date: Wed, 25 Feb 2026 22:43:31 +0100 Subject: [PATCH 2/4] support immutable carbon instances --- .gitignore | 3 +- src/Fields/CarbonField.php | 3 +- src/Fields/CarbonPeriodField.php | 16 +++--- src/Fields/Time.php | 3 +- src/Fields/TimeField.php | 4 +- src/Formatting/CarbonFormatter.php | 4 +- src/Parsing/CarbonParser.php | 25 +++++++-- src/RestingSettings.php | 28 ++++++++++ src/Validation/CarbonValidator.php | 4 +- ...onPeriodOrderedRequiredValidationError.php | 8 +-- ...CarbonPeriodMaxDurationValidationError.php | 8 +-- ...CarbonPeriodMinDurationValidationError.php | 8 +-- src/Validation/Secondary/CarbonValidation.php | 32 ++++++------ tests/Fields/CarbonFieldTest.php | 52 +++++++++++++++++++ tests/Fields/CarbonPeriodFieldTest.php | 27 ++++++++++ tests/Fields/TimeFieldTest.php | 11 ++++ tests/Formatting/CarbonFormatterTest.php | 8 +++ tests/Parsing/CarbonParserTest.php | 38 ++++++++++++++ tests/RestingSettingsTest.php | 47 +++++++++++++++++ tests/Validation/CarbonValidatorTest.php | 6 +++ 20 files changed, 284 insertions(+), 51 deletions(-) create mode 100644 src/RestingSettings.php create mode 100644 tests/RestingSettingsTest.php diff --git a/.gitignore b/.gitignore index 1f4fa74d..5f4b8d8d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ composer.phar composer.lock .DS_Store docs -.phpunit.result.cache \ No newline at end of file +.phpunit.result.cache +.claude \ No newline at end of file diff --git a/src/Fields/CarbonField.php b/src/Fields/CarbonField.php index cf7d1cfc..31f80ec4 100644 --- a/src/Fields/CarbonField.php +++ b/src/Fields/CarbonField.php @@ -3,6 +3,7 @@ namespace Seier\Resting\Fields; use Carbon\Carbon; +use Carbon\CarbonImmutable; use Seier\Resting\Parsing\CarbonParser; use Seier\Resting\Validation\CarbonValidator; use Seier\Resting\Formatting\CarbonFormatter; @@ -63,7 +64,7 @@ public function set($value): static return parent::set($value); } - public function get(): ?Carbon + public function get(): Carbon|CarbonImmutable|null { return $this->value?->copy(); } diff --git a/src/Fields/CarbonPeriodField.php b/src/Fields/CarbonPeriodField.php index 90e8a716..166ef6b7 100644 --- a/src/Fields/CarbonPeriodField.php +++ b/src/Fields/CarbonPeriodField.php @@ -3,6 +3,8 @@ namespace Seier\Resting\Fields; use Carbon\Carbon; +use Carbon\CarbonImmutable; +use Carbon\CarbonInterface; use Carbon\CarbonPeriod; use Seier\Resting\Parsing\CarbonParser; use Seier\Resting\Parsing\CarbonPeriodParser; @@ -58,18 +60,14 @@ public function asArray(): array ]; } - public function start(): ?Carbon + public function start(): Carbon|CarbonImmutable|null { - $carbon = $this->get()?->start?->copy(); - - return $carbon ? new Carbon($carbon) : null; + return $this->get()?->start?->copy(); } - public function end(): ?Carbon + public function end(): Carbon|CarbonImmutable|null { - $carbon = $this->get()?->end?->copy(); - - return $carbon ? new Carbon($carbon) : null; + return $this->get()?->end?->copy(); } public function withFormat($format): static @@ -145,7 +143,7 @@ private function fromArray(array $values): CarbonPeriod $parsed = []; foreach ($values as $index => $value) { - if (!$value instanceof Carbon) { + if (!$value instanceof CarbonInterface) { $errors[] = (new NotCarbonValidationError($value))->prependPath($index); continue; } diff --git a/src/Fields/Time.php b/src/Fields/Time.php index 2338c59c..de214aea 100644 --- a/src/Fields/Time.php +++ b/src/Fields/Time.php @@ -5,6 +5,7 @@ use Carbon\Carbon; +use Carbon\CarbonInterface; class Time { @@ -58,7 +59,7 @@ private function toCarbon(): Carbon ); } - public static function fromCarbon(Carbon $carbon): static + public static function fromCarbon(CarbonInterface $carbon): static { return new static( hours: $carbon->hour, diff --git a/src/Fields/TimeField.php b/src/Fields/TimeField.php index b0164bbc..03e164cb 100644 --- a/src/Fields/TimeField.php +++ b/src/Fields/TimeField.php @@ -2,7 +2,7 @@ namespace Seier\Resting\Fields; -use Carbon\Carbon; +use Carbon\CarbonInterface; use Seier\Resting\Parsing\TimeParser; use Seier\Resting\Validation\TimeValidator; use Seier\Resting\Formatting\TimeFormatter; @@ -74,7 +74,7 @@ private function parsed($value): ?Time return $value; } - if ($value instanceof Carbon) { + if ($value instanceof CarbonInterface) { return Time::fromCarbon($value); } diff --git a/src/Formatting/CarbonFormatter.php b/src/Formatting/CarbonFormatter.php index fca84b44..39c7ed58 100644 --- a/src/Formatting/CarbonFormatter.php +++ b/src/Formatting/CarbonFormatter.php @@ -4,7 +4,7 @@ namespace Seier\Resting\Formatting; -use Carbon\Carbon; +use Carbon\CarbonInterface; use Seier\Resting\Validation\Secondary\Panics; class CarbonFormatter implements Formatter @@ -20,7 +20,7 @@ public function format(mixed $value): ?string return null; } - if (!$value instanceof Carbon) { + if (!$value instanceof CarbonInterface) { $this->panic(); } diff --git a/src/Parsing/CarbonParser.php b/src/Parsing/CarbonParser.php index 355fe6ef..16ac3a58 100644 --- a/src/Parsing/CarbonParser.php +++ b/src/Parsing/CarbonParser.php @@ -5,6 +5,8 @@ use Carbon\Carbon; +use Carbon\CarbonImmutable; +use Seier\Resting\RestingSettings; use Seier\Resting\Fields\EmptyStringAsNull; use Carbon\Exceptions\InvalidFormatException; @@ -35,11 +37,12 @@ public function canParse(ParseContext $context): array } try { + $carbonClass = $this->carbonClass(); if ($this->format) { - Carbon::createFromFormat($this->format, $raw); + $carbonClass::createFromFormat($this->format, $raw); } else { - Carbon::parse($raw); + $carbonClass::parse($raw); } return []; @@ -48,7 +51,7 @@ public function canParse(ParseContext $context): array } } - public function parse(ParseContext $context): ?Carbon + public function parse(ParseContext $context): Carbon|CarbonImmutable|null { $raw = $context->getValue(); $raw = $this->maybeEmptyStringAsNull($raw); @@ -56,9 +59,21 @@ public function parse(ParseContext $context): ?Carbon return null; } + $carbonClass = $this->carbonClass(); + return $this->format - ? Carbon::createFromFormat($this->format, $raw, now()->timezone) - : Carbon::parse($raw); + ? $carbonClass::createFromFormat($this->format, $raw, now()->timezone) + : $carbonClass::parse($raw); + } + + /** + * @return class-string + */ + private function carbonClass(): string + { + return RestingSettings::instance()->useImmutableCarbon + ? CarbonImmutable::class + : Carbon::class; } public function shouldParse(ParseContext $context): bool diff --git a/src/RestingSettings.php b/src/RestingSettings.php new file mode 100644 index 00000000..c16e3dd8 --- /dev/null +++ b/src/RestingSettings.php @@ -0,0 +1,28 @@ +runValidators($value) : [new NotCarbonValidationError($value)]; } diff --git a/src/Validation/Errors/CarbonPeriodOrderedRequiredValidationError.php b/src/Validation/Errors/CarbonPeriodOrderedRequiredValidationError.php index 3214e791..88361e23 100644 --- a/src/Validation/Errors/CarbonPeriodOrderedRequiredValidationError.php +++ b/src/Validation/Errors/CarbonPeriodOrderedRequiredValidationError.php @@ -4,7 +4,7 @@ namespace Seier\Resting\Validation\Errors; -use Carbon\Carbon; +use Carbon\CarbonInterface; use Seier\Resting\Support\HasPath; class CarbonPeriodOrderedRequiredValidationError implements ValidationError @@ -12,10 +12,10 @@ class CarbonPeriodOrderedRequiredValidationError implements ValidationError use HasPath; - private Carbon $start; - private Carbon $end; + private CarbonInterface $start; + private CarbonInterface $end; - public function __construct(Carbon $start, Carbon $end) + public function __construct(CarbonInterface $start, CarbonInterface $end) { $this->start = $start; $this->end = $end; diff --git a/src/Validation/Secondary/CarbonPeriod/CarbonPeriodMaxDurationValidationError.php b/src/Validation/Secondary/CarbonPeriod/CarbonPeriodMaxDurationValidationError.php index 7f463975..124c9d7f 100644 --- a/src/Validation/Secondary/CarbonPeriod/CarbonPeriodMaxDurationValidationError.php +++ b/src/Validation/Secondary/CarbonPeriod/CarbonPeriodMaxDurationValidationError.php @@ -4,7 +4,7 @@ namespace Seier\Resting\Validation\Secondary\CarbonPeriod; -use Carbon\Carbon; +use Carbon\CarbonInterface; use Carbon\CarbonInterval; use Seier\Resting\Support\HasPath; use Seier\Resting\Validation\Errors\ValidationError; @@ -15,10 +15,10 @@ class CarbonPeriodMaxDurationValidationError implements ValidationError use HasPath; private CarbonInterval $max; - private Carbon $start; - private ?Carbon $end; + private CarbonInterface $start; + private ?CarbonInterface $end; - public function __construct(CarbonInterval $max, Carbon $start, ?Carbon $end) + public function __construct(CarbonInterval $max, CarbonInterface $start, ?CarbonInterface $end) { $this->max = $max; $this->start = $start; diff --git a/src/Validation/Secondary/CarbonPeriod/CarbonPeriodMinDurationValidationError.php b/src/Validation/Secondary/CarbonPeriod/CarbonPeriodMinDurationValidationError.php index 3ca55462..0f121b05 100644 --- a/src/Validation/Secondary/CarbonPeriod/CarbonPeriodMinDurationValidationError.php +++ b/src/Validation/Secondary/CarbonPeriod/CarbonPeriodMinDurationValidationError.php @@ -4,7 +4,7 @@ namespace Seier\Resting\Validation\Secondary\CarbonPeriod; -use Carbon\Carbon; +use Carbon\CarbonInterface; use Carbon\CarbonInterval; use Seier\Resting\Support\HasPath; use Seier\Resting\Validation\Errors\ValidationError; @@ -15,10 +15,10 @@ class CarbonPeriodMinDurationValidationError implements ValidationError use HasPath; private CarbonInterval $min; - private Carbon $actualStart; - private Carbon $actualEnd; + private CarbonInterface $actualStart; + private CarbonInterface $actualEnd; - public function __construct(CarbonInterval $min, Carbon $actualStart, Carbon $actualEnd) + public function __construct(CarbonInterval $min, CarbonInterface $actualStart, CarbonInterface $actualEnd) { $this->min = $min; $this->actualStart = $actualStart; diff --git a/src/Validation/Secondary/CarbonValidation.php b/src/Validation/Secondary/CarbonValidation.php index 95a87962..e02c2c3b 100644 --- a/src/Validation/Secondary/CarbonValidation.php +++ b/src/Validation/Secondary/CarbonValidation.php @@ -4,7 +4,7 @@ namespace Seier\Resting\Validation\Secondary; -use Carbon\Carbon; +use Carbon\CarbonInterface; use Seier\Resting\Fields\Field; use Seier\Resting\Fields\CarbonField; use Seier\Resting\Validation\Resolver\ClosureValidatorResolver; @@ -16,11 +16,11 @@ trait CarbonValidation protected abstract function getSupportsSecondaryValidation(): SupportsSecondaryValidation; - public function min(Carbon|Field $min): static + public function min(CarbonInterface|Field $min): static { if ($min instanceof Field) { $this->getSupportsSecondaryValidation()->withLateBoundValidator( - ClosureValidatorResolver::whenNotNullThen($min, function (Carbon $resolved) { + ClosureValidatorResolver::whenNotNullThen($min, function (CarbonInterface $resolved) { return $this->createMinValidator($resolved, inclusive: true); }) ); @@ -35,11 +35,11 @@ public function min(Carbon|Field $min): static return $this; } - public function max(Carbon|Field $max): static + public function max(CarbonInterface|Field $max): static { if ($max instanceof Field) { $this->getSupportsSecondaryValidation()->withLateBoundValidator( - ClosureValidatorResolver::whenNotNullThen($max, function (Carbon $resolved) { + ClosureValidatorResolver::whenNotNullThen($max, function (CarbonInterface $resolved) { return $this->createMaxValidator($resolved, inclusive: true); }) ); @@ -54,11 +54,11 @@ public function max(Carbon|Field $max): static return $this; } - public function after(Carbon|Field $low): static + public function after(CarbonInterface|Field $low): static { if ($low instanceof Field) { $this->getSupportsSecondaryValidation()->withLateBoundValidator( - ClosureValidatorResolver::whenNotNullThen($low, function (Carbon $resolved) { + ClosureValidatorResolver::whenNotNullThen($low, function (CarbonInterface $resolved) { return $this->createMinValidator($resolved, inclusive: false); }) ); @@ -73,11 +73,11 @@ public function after(Carbon|Field $low): static return $this; } - public function before(Carbon|Field $high): static + public function before(CarbonInterface|Field $high): static { if ($high instanceof CarbonField) { $this->getSupportsSecondaryValidation()->withLateBoundValidator( - ClosureValidatorResolver::whenNotNullThen($high, function (Carbon $resolved) { + ClosureValidatorResolver::whenNotNullThen($high, function (CarbonInterface $resolved) { return $this->createMaxValidator($resolved, inclusive: false); }) ); @@ -92,7 +92,7 @@ public function before(Carbon|Field $high): static return $this; } - public function between(Carbon $from, Carbon $to): static + public function between(CarbonInterface $from, CarbonInterface $to): static { $this->min($from); $this->max($to); @@ -100,23 +100,23 @@ public function between(Carbon $from, Carbon $to): static return $this; } - private function createMinValidator(Carbon $min, bool $inclusive): MinValidator + private function createMinValidator(CarbonInterface $min, bool $inclusive): MinValidator { return new MinValidator( $min, inclusive: $inclusive, - normalizer: fn(Carbon $carbon) => $carbon->unix(), - formatter: fn(Carbon $carbon) => $carbon->toDateString(), + normalizer: fn(CarbonInterface $carbon) => $carbon->unix(), + formatter: fn(CarbonInterface $carbon) => $carbon->toDateString(), ); } - private function createMaxValidator(Carbon $max, bool $inclusive): MaxValidator + private function createMaxValidator(CarbonInterface $max, bool $inclusive): MaxValidator { return new MaxValidator( $max, inclusive: $inclusive, - normalizer: fn(Carbon $carbon) => $carbon->unix(), - formatter: fn(Carbon $carbon) => $carbon->toDateString(), + normalizer: fn(CarbonInterface $carbon) => $carbon->unix(), + formatter: fn(CarbonInterface $carbon) => $carbon->toDateString(), ); } } \ No newline at end of file diff --git a/tests/Fields/CarbonFieldTest.php b/tests/Fields/CarbonFieldTest.php index 3c023abb..1e3e8092 100644 --- a/tests/Fields/CarbonFieldTest.php +++ b/tests/Fields/CarbonFieldTest.php @@ -2,6 +2,7 @@ namespace Seier\Resting\Tests\Fields; +use Carbon\CarbonImmutable; use Seier\Resting\Tests\TestCase; use Seier\Resting\Fields\CarbonField; use Seier\Resting\Parsing\CarbonParseError; @@ -169,4 +170,55 @@ public function testCanCastEmptyValuesToNull() $this->instance->set(''); $this->assertNull($this->instance->get()); } + + public function testSetCarbonImmutableReturnsCarbonImmutable() + { + $now = CarbonImmutable::now(); + $this->instance->set($now); + $this->assertInstanceOf(CarbonImmutable::class, $this->instance->get()); + } + + public function testSetCarbonImmutable() + { + $now = CarbonImmutable::now(); + $this->instance->set($now); + $this->assertEquals($now->micro, $this->instance->get()->micro); + } + + public function testSetCarbonImmutableReturnsNewInstance() + { + $now = CarbonImmutable::now(); + $this->instance->set($now); + $this->assertNotSame($now, $this->instance->get()); + } + + public function testSetCarbonImmutableWithMinValidation() + { + $this->instance->min($limit = CarbonImmutable::now()); + $this->assertDoesNotThrowValidationException(function () use ($limit) { + $this->instance->set($limit->addSecond()); + }); + } + + public function testSetCarbonImmutableFailsMinValidation() + { + $this->instance->min($limit = CarbonImmutable::now()); + $validationException = $this->assertThrowsValidationException(function () use ($limit) { + $this->instance->set($limit->subSecond()); + }); + + $this->assertCount(1, $validationException->getErrors()); + $this->assertHasError($validationException, MinValidationError::class); + } + + public function testFormattedWithCarbonImmutable() + { + $now = CarbonImmutable::now(); + $this->instance->set($now); + + $this->assertEquals( + $now->toDateTimeString(), + $this->instance->formatted(), + ); + } } diff --git a/tests/Fields/CarbonPeriodFieldTest.php b/tests/Fields/CarbonPeriodFieldTest.php index 39b48b72..a5a4c6a8 100644 --- a/tests/Fields/CarbonPeriodFieldTest.php +++ b/tests/Fields/CarbonPeriodFieldTest.php @@ -4,6 +4,8 @@ namespace Seier\Resting\Tests\Fields; +use Carbon\CarbonInterface; +use Carbon\CarbonImmutable; use Carbon\CarbonPeriod; use Seier\Resting\Tests\TestCase; use Seier\Resting\Fields\CarbonPeriodField; @@ -155,4 +157,29 @@ public function testValidateWithRegisteredSecondaryValidationThatFails() $this->assertHasError($exception, MockSecondaryValidationError::class); } + + public function testSetArrayOfCarbonImmutable() + { + $from = CarbonImmutable::now(); + $to = $from->addHour(); + + $this->instance->set([$from, $to]); + $this->assertNotNull($period = $this->instance->get()); + $this->assertEquals($from->unix(), $period->start->unix()); + $this->assertEquals($to->unix(), $period->end->unix()); + } + + public function testStartReturnsCarbonInterface() + { + $period = CarbonPeriod::create(now(), now()->addDay()); + $this->instance->set($period); + $this->assertInstanceOf(CarbonInterface::class, $this->instance->start()); + } + + public function testEndReturnsCarbonInterface() + { + $period = CarbonPeriod::create(now(), now()->addDay()); + $this->instance->set($period); + $this->assertInstanceOf(CarbonInterface::class, $this->instance->end()); + } } \ No newline at end of file diff --git a/tests/Fields/TimeFieldTest.php b/tests/Fields/TimeFieldTest.php index 18b07ba4..272f9ff1 100644 --- a/tests/Fields/TimeFieldTest.php +++ b/tests/Fields/TimeFieldTest.php @@ -2,6 +2,7 @@ namespace Seier\Resting\Tests\Fields; +use Carbon\CarbonImmutable; use Seier\Resting\Fields\Time; use Seier\Resting\Tests\TestCase; use Seier\Resting\Fields\TimeField; @@ -165,6 +166,16 @@ public function testValidateWithRegisteredSecondaryValidationThatFails() $this->assertHasError($exception, MockSecondaryValidationError::class); } + public function testSetCarbonImmutable() + { + $this->instance->set(CarbonImmutable::create(2024, 1, 1, 10, 20, 30)); + + $result = $this->instance->get(); + $this->assertEquals(10, $result->hours); + $this->assertEquals(20, $result->minutes); + $this->assertEquals(30, $result->seconds); + } + public function testCanCastEmptyValuesToNull() { $this->instance->emptyStringAsNull(); diff --git a/tests/Formatting/CarbonFormatterTest.php b/tests/Formatting/CarbonFormatterTest.php index 8052edf9..46c310f9 100644 --- a/tests/Formatting/CarbonFormatterTest.php +++ b/tests/Formatting/CarbonFormatterTest.php @@ -4,6 +4,7 @@ namespace Seier\Resting\Tests\Formatting; +use Carbon\CarbonImmutable; use Seier\Resting\Tests\TestCase; use Seier\Resting\Formatting\CarbonFormatter; @@ -42,4 +43,11 @@ public function testFormatWithCustomFormat() $this->instance->format($now), ); } + + public function testFormatCarbonImmutable() + { + $now = CarbonImmutable::now(); + + $this->assertEquals($now->toDateTimeString(), $this->instance->format($now)); + } } \ No newline at end of file diff --git a/tests/Parsing/CarbonParserTest.php b/tests/Parsing/CarbonParserTest.php index 5becbd38..f04b5aea 100644 --- a/tests/Parsing/CarbonParserTest.php +++ b/tests/Parsing/CarbonParserTest.php @@ -5,6 +5,8 @@ use Carbon\Carbon; +use Carbon\CarbonImmutable; +use Seier\Resting\RestingSettings; use Seier\Resting\Tests\TestCase; use Seier\Resting\Parsing\CarbonParser; use Seier\Resting\Parsing\CarbonParseError; @@ -25,6 +27,13 @@ public function setUp(): void $this->instance = new CarbonParser(); } + protected function tearDown(): void + { + RestingSettings::reset(); + + parent::tearDown(); + } + public function testWhenProvidedEmptyString() { $context = new DefaultParseContext(''); @@ -61,4 +70,33 @@ public function testCanEnforceFormat() $this->assertNotEmpty($errors = $this->instance->canParse($context)); $this->assertHasError($errors, CarbonParseError::class); } + + public function testParseReturnsMutableCarbonByDefault() + { + $context = new DefaultParseContext('2020-10-11'); + $result = $this->instance->parse($context); + + $this->assertInstanceOf(Carbon::class, $result); + } + + public function testParseReturnsImmutableCarbonWhenEnabled() + { + RestingSettings::instance()->useImmutableCarbon = true; + + $context = new DefaultParseContext('2020-10-11'); + $result = $this->instance->parse($context); + + $this->assertInstanceOf(CarbonImmutable::class, $result); + } + + public function testParseWithFormatReturnsImmutableCarbonWhenEnabled() + { + RestingSettings::instance()->useImmutableCarbon = true; + $this->instance->withFormat('Y-m-d'); + + $context = new DefaultParseContext('2020-10-11'); + $result = $this->instance->parse($context); + + $this->assertInstanceOf(CarbonImmutable::class, $result); + } } \ No newline at end of file diff --git a/tests/RestingSettingsTest.php b/tests/RestingSettingsTest.php new file mode 100644 index 00000000..00717e0c --- /dev/null +++ b/tests/RestingSettingsTest.php @@ -0,0 +1,47 @@ +assertSame($a, $b); + } + + public function testDefaultsToMutableCarbon() + { + $this->assertFalse(RestingSettings::instance()->useImmutableCarbon); + } + + public function testCanEnableImmutableCarbon() + { + RestingSettings::instance()->useImmutableCarbon = true; + + $this->assertTrue(RestingSettings::instance()->useImmutableCarbon); + } + + public function testResetCreatesNewInstance() + { + $before = RestingSettings::instance(); + $before->useImmutableCarbon = true; + + RestingSettings::reset(); + + $after = RestingSettings::instance(); + $this->assertNotSame($before, $after); + $this->assertFalse($after->useImmutableCarbon); + } +} diff --git a/tests/Validation/CarbonValidatorTest.php b/tests/Validation/CarbonValidatorTest.php index 5c887890..440c1ab4 100644 --- a/tests/Validation/CarbonValidatorTest.php +++ b/tests/Validation/CarbonValidatorTest.php @@ -4,6 +4,7 @@ namespace Seier\Resting\Tests\Validation; +use Carbon\CarbonImmutable; use Seier\Resting\Tests\TestCase; use Seier\Resting\Tests\Meta\AssertsErrors; use Seier\Resting\Validation\CarbonValidator; @@ -56,4 +57,9 @@ public function testValidateWithRegisteredSecondaryValidationThatFails() $this->assertNotEmpty($errors = $this->instance->validate(now())); $this->assertHasError($errors, MockSecondaryValidationError::class); } + + public function testValidateCarbonImmutableInstance() + { + $this->assertEmpty($this->instance->validate(CarbonImmutable::now())); + } } \ No newline at end of file From 8a6b3dd0c38721cf062d7b15897fd5dd7e9f452d Mon Sep 17 00:00:00 2001 From: Thomas-Rosenkrans-Vestergaard Date: Thu, 26 Feb 2026 00:44:00 +0100 Subject: [PATCH 3/4] added ResourceField::apply and ResourceField::applyNullable to enable better performance when serializing many resources --- src/Fields/ResourceField.php | 23 +++++++ tests/Fields/ResourceFieldTest.php | 97 +++++++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/src/Fields/ResourceField.php b/src/Fields/ResourceField.php index 1fda9563..9584a0b1 100644 --- a/src/Fields/ResourceField.php +++ b/src/Fields/ResourceField.php @@ -83,6 +83,29 @@ public function set($value): static return $this; } + public function apply(Closure $apply): static + { + $resource = $this->resource; + $apply($resource); + $this->set($resource); + + return $this; + } + + public function applyNullable(mixed $value, Closure $apply): static + { + if ($value === null) { + $this->set(null); + return $this; + } + + $resource = $this->resource; + $apply($resource, $value); + $this->set($resource); + + return $this; + } + public function resourceAsDefault(): static { $this->nullDefault($this->resourceFactory); diff --git a/tests/Fields/ResourceFieldTest.php b/tests/Fields/ResourceFieldTest.php index c279b095..aa5e53ed 100644 --- a/tests/Fields/ResourceFieldTest.php +++ b/tests/Fields/ResourceFieldTest.php @@ -3,6 +3,7 @@ namespace Seier\Resting\Tests\Fields; use Seier\Resting\Tests\TestCase; +use Seier\Resting\Tests\Meta\Person; use Jchook\AssertThrows\AssertThrows; use Seier\Resting\Fields\ResourceField; use Seier\Resting\Tests\Meta\PetResource; @@ -20,7 +21,7 @@ public function setUp(): void { parent::setUp(); - $this->instance = new ResourceField(fn() => new PersonResource); + $this->instance = new ResourceField(fn () => new PersonResource); } public function testGetEmptyReturnsNull() @@ -48,7 +49,7 @@ public function testSetWhenGivenArray() public function testSetWhenGivenArrayWithNullValuesCanOverride() { - $this->instance = new ResourceField(fn() => PersonResource::nullableName()); + $this->instance = new ResourceField(fn () => PersonResource::nullableName()); $this->instance->set(['name' => 'A', 'age' => 1]); $this->assertType($this->instance->get(), function (PersonResource $resource) { @@ -117,4 +118,96 @@ public function testNonNullableSetWhenGivenNull() $this->instance->set(null); }); } + + public function testApplyCanSetValue() + { + $name = $this->faker->name; + $age = $this->faker->randomNumber(2); + + $this->assertNull($this->instance->get()); + $givenPersonResource = null; + + $this->instance->apply( + function (PersonResource $resource) use ($age, $name, &$givenPersonResource) { + $resource->name->set($name); + $resource->age->set($age); + $givenPersonResource = $resource; + } + ); + + $value = $this->instance->get(); + $this->assertSame($givenPersonResource, $value); + $this->assertInstanceOf(PersonResource::class, $value); + $this->assertSame($name, $value->name->get()); + $this->assertSame($age, $value->age->get()); + } + + public function testApplyCanSetValueUsingFactoryMethod() + { + $name = $this->faker->name; + $age = $this->faker->randomNumber(2); + $person = Person::from($name, $age); + + $this->assertNull($this->instance->get()); + + $this->instance->apply( + fn (PersonResource $resource) => $resource->from($person) + ); + + $value = $this->instance->get(); + $this->assertInstanceOf(PersonResource::class, $value); + $this->assertSame($name, $value->name->get()); + $this->assertSame($age, $value->age->get()); + } + + public function testApplyNullable() + { + $name = $this->faker->name; + $age = $this->faker->randomNumber(2); + $person = Person::from($name, $age); + + $this->assertNull($this->instance->get()); + $this->instance->applyNullable( + $person, + fn (PersonResource $resource) => $resource->from($person) + ); + + $value = $this->instance->get(); + $this->assertInstanceOf(PersonResource::class, $value); + $this->assertSame($name, $value->name->get()); + $this->assertSame($age, $value->age->get()); + + $this->instance->nullable(); + $this->instance->applyNullable( + null, + fn (PersonResource $resource) => $resource->from(null) // Expected not to be called + ); + + $this->assertSame(null, $this->instance->get()); + $this->assertTrue($this->instance->isNull()); + } + + public function testApplyNullableClosureIsGivenValue() + { + $name = $this->faker->name; + $age = $this->faker->randomNumber(2); + $person = Person::from($name, $age); + + $givenValue = null; + $this->assertNull($this->instance->get()); + $this->instance->applyNullable( + $person, + function (PersonResource $resource, Person $value) use ($person, &$givenValue) { + $givenValue = $value; + return $resource->from($person); + } + ); + + $value = $this->instance->get(); + $this->assertInstanceOf(PersonResource::class, $value); + $this->assertSame($name, $value->name->get()); + $this->assertSame($age, $value->age->get()); + + $this->assertSame($person, $givenValue); + } } From 22650c79ea935b2c792adbfc71988240f5a647f8 Mon Sep 17 00:00:00 2001 From: Thomas-Rosenkrans-Vestergaard Date: Mon, 2 Mar 2026 15:52:23 +0100 Subject: [PATCH 4/4] more work --- tests/Fields/CarbonPeriodFieldTest.php | 36 +++++++++++++++++++++----- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/tests/Fields/CarbonPeriodFieldTest.php b/tests/Fields/CarbonPeriodFieldTest.php index a5a4c6a8..7f718923 100644 --- a/tests/Fields/CarbonPeriodFieldTest.php +++ b/tests/Fields/CarbonPeriodFieldTest.php @@ -4,7 +4,7 @@ namespace Seier\Resting\Tests\Fields; -use Carbon\CarbonInterface; +use Carbon\Carbon; use Carbon\CarbonImmutable; use Carbon\CarbonPeriod; use Seier\Resting\Tests\TestCase; @@ -169,17 +169,39 @@ public function testSetArrayOfCarbonImmutable() $this->assertEquals($to->unix(), $period->end->unix()); } - public function testStartReturnsCarbonInterface() + public function testStartReturnsCarbonInstance() { - $period = CarbonPeriod::create(now(), now()->addDay()); + $period = CarbonPeriod::create(Carbon::now(), Carbon::now()->addDay()); $this->instance->set($period); - $this->assertInstanceOf(CarbonInterface::class, $this->instance->start()); + $this->assertInstanceOf(Carbon::class, $this->instance->start()); } - public function testEndReturnsCarbonInterface() + public function testStartReturnsCarbonImmutableInstance() { - $period = CarbonPeriod::create(now(), now()->addDay()); + $period = new CarbonPeriod(); + $period->setDateClass(CarbonImmutable::class); + $period->setStartDate(CarbonImmutable::now()); + $period->setEndDate(CarbonImmutable::now()->addDay()); + + $this->instance->set($period); + $this->assertInstanceOf(CarbonImmutable::class, $this->instance->start()); + } + + public function testEndReturnsCarbonInstance() + { + $period = CarbonPeriod::create(Carbon::now(), Carbon::now()->addDay()); + $this->instance->set($period); + $this->assertInstanceOf(Carbon::class, $this->instance->end()); + } + + public function testEndReturnsCarbonImmutableInstance() + { + $period = new CarbonPeriod(); + $period->setDateClass(CarbonImmutable::class); + $period->setStartDate(CarbonImmutable::now()); + $period->setEndDate(CarbonImmutable::now()->addDay()); + $this->instance->set($period); - $this->assertInstanceOf(CarbonInterface::class, $this->instance->end()); + $this->assertInstanceOf(CarbonImmutable::class, $this->instance->end()); } } \ No newline at end of file