diff --git a/docs/guide/examples.md b/docs/guide/examples.md index 707452f..1e25a63 100644 --- a/docs/guide/examples.md +++ b/docs/guide/examples.md @@ -624,14 +624,14 @@ $user = new UserDto([ 'username' => 'jo', // Too short (minLength: 3) 'email' => 'john@example.com', ]); -// Exception: Field 'username' must be at least 3 characters +// Exception: Validation failed in App\Dto\UserDto: username must be at least 3 characters // Invalid email pattern $user = new UserDto([ 'username' => 'johndoe', 'email' => 'not-an-email', ]); -// Exception: Field 'email' does not match required pattern +// Exception: Validation failed in App\Dto\UserDto: email must match pattern /^[^@]+@[^@]+\.[^@]+$/ ``` ### Nullable Fields Skip Validation diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 0fe41ac..cafc0e7 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -47,7 +47,7 @@ class UserDtoTest extends TestCase public function testRequiredFieldsThrowException(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Required fields missing'); + $this->expectExceptionMessage('Required field missing in App\\Dto\\UserDto: email'); new UserDto(['name' => 'John']); // Missing required 'email' } @@ -506,7 +506,7 @@ public static function invalidUserDataProvider(): array return [ 'missing email' => [ ['name' => 'John'], - 'Required fields missing', + 'Required field missing in App\\Dto\\UserDto: email', ], 'unknown field' => [ ['name' => 'John', 'email' => 'j@e.com', 'unknown' => 'x'], diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index d683304..73d53b5 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -10,7 +10,11 @@ Common issues, error messages, and debugging tips. ### Runtime Exceptions (Using DTOs) -#### `InvalidArgumentException: Required fields missing: {fields}` +#### `InvalidArgumentException: Required field missing in {DtoClass}: {field}` + +or + +#### `InvalidArgumentException: Required fields missing in {DtoClass}:` **Cause:** Creating a DTO without providing required field values. diff --git a/docs/guide/validation.md b/docs/guide/validation.md index e80c7c6..a1cd2cb 100644 --- a/docs/guide/validation.md +++ b/docs/guide/validation.md @@ -21,7 +21,8 @@ When a field is marked as `required`, the DTO will throw an exception if that fi ``` ```php -// This throws InvalidArgumentException: Required fields missing: email +// This throws InvalidArgumentException: +// Required field missing in App\Dto\UserDto: email $user = new UserDto(['id' => 1]); // This works - nickname is optional @@ -75,10 +76,12 @@ Or in XML: - On failure, an `InvalidArgumentException` is thrown with a descriptive message: ```php -// InvalidArgumentException: Validation failed: name must be at least 2 characters +// InvalidArgumentException: +// Validation failed in App\Dto\UserDto: name must be at least 2 characters $user = new UserDto(['name' => 'A', 'email' => 'a@b.com']); -// InvalidArgumentException: Validation failed: email must match pattern /^[^@]+@[^@]+\.[^@]+$/ +// InvalidArgumentException: +// Validation failed in App\Dto\UserDto: email must match pattern /^[^@]+@[^@]+\.[^@]+$/ $user = new UserDto(['name' => 'Test', 'email' => 'invalid']); ``` diff --git a/src/Dto/Dto.php b/src/Dto/Dto.php index 2ca92a3..d2d31e6 100644 --- a/src/Dto/Dto.php +++ b/src/Dto/Dto.php @@ -395,11 +395,23 @@ protected function _toArrayInternal(?string $type = null, ?array $fields = null, * @param string $childConvertMethodName * @param string $type * + * @throws \RuntimeException If collection count fails. + * * @return array */ protected function transformCollectionToArray($value, array $values, string $arrayKey, string $childConvertMethodName, string $type): array { - if ($value->count() === 0) { + try { + $count = $value->count(); + } catch (Throwable $e) { + throw new RuntimeException(sprintf( + "Failed to count collection for key '%s': %s", + $arrayKey, + $e->getMessage(), + ), 0, $e); + } + + if ($count === 0) { $values[$arrayKey] = []; return $values; @@ -676,7 +688,7 @@ protected function createWithFactory(string $field, $value) } try { - return $class::$factory($value); + $result = $class::$factory($value); } catch (Throwable $e) { throw new RuntimeException(sprintf( "Factory method '%s::%s' failed for field '%s' in %s: %s", @@ -687,6 +699,22 @@ protected function createWithFactory(string $field, $value) $e->getMessage(), ), 0, $e); } + + // Validate that factory returned the expected type + $expectedType = $this->_metadata[$field]['type']; + if ($result !== null && !$result instanceof $expectedType) { + throw new RuntimeException(sprintf( + "Factory method '%s::%s' must return instance of %s, got %s for field '%s' in %s", + $class, + $factory, + $expectedType, + get_debug_type($result), + $field, + static::class, + )); + } + + return $result; } /** @@ -1150,7 +1178,11 @@ protected function validate(): void } } if ($errors) { - throw new InvalidArgumentException('Required fields missing: ' . implode(', ', $errors)); + $message = count($errors) === 1 + ? sprintf('Required field missing in %s: %s', static::class, $errors[0]) + : sprintf("Required fields missing in %s:\n - %s", static::class, implode("\n - ", $errors)); + + throw new InvalidArgumentException($message); } $validationErrors = []; @@ -1170,12 +1202,37 @@ protected function validate(): void if (isset($field['max']) && is_numeric($this->$name) && $this->$name > $field['max']) { $validationErrors[] = $name . ' must be at most ' . $field['max']; } - if (!empty($field['pattern']) && is_string($this->$name) && !preg_match($field['pattern'], $this->$name)) { - $validationErrors[] = $name . ' must match pattern ' . $field['pattern']; + if (!empty($field['pattern']) && is_string($this->$name)) { + try { + if (@preg_match($field['pattern'], $this->$name) === false) { + throw new InvalidArgumentException(sprintf( + "Invalid regex pattern '%s' for field '%s': %s", + $field['pattern'], + $name, + preg_last_error_msg(), + )); + } + if (!preg_match($field['pattern'], $this->$name)) { + $validationErrors[] = $name . ' must match pattern ' . $field['pattern']; + } + } catch (InvalidArgumentException $e) { + throw $e; + } catch (Throwable $e) { + throw new InvalidArgumentException(sprintf( + "Invalid regex pattern '%s' for field '%s': %s", + $field['pattern'], + $name, + $e->getMessage(), + ), 0, $e); + } } } if ($validationErrors) { - throw new InvalidArgumentException('Validation failed: ' . implode(', ', $validationErrors)); + $message = count($validationErrors) === 1 + ? sprintf('Validation failed in %s: %s', static::class, $validationErrors[0]) + : sprintf("Validation failed in %s:\n - %s", static::class, implode("\n - ", $validationErrors)); + + throw new InvalidArgumentException($message); } } diff --git a/src/Generator/Generator.php b/src/Generator/Generator.php index 44b442f..118bcf3 100644 --- a/src/Generator/Generator.php +++ b/src/Generator/Generator.php @@ -8,6 +8,7 @@ use InvalidArgumentException; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; +use RuntimeException; class Generator { @@ -89,9 +90,10 @@ public function generate(string $configPath, string $srcPath, array $options = [ $returnCode = static::CODE_SUCCESS; $changes = 0; + $baseDtoPath = realpath($srcPath) . DIRECTORY_SEPARATOR . 'Dto'; foreach ($dtos as $name => $content) { // Validate DTO name doesn't contain path traversal sequences - if (str_contains($name, '..')) { + if (str_contains($name, '..') || str_contains($name, "\0")) { throw new InvalidArgumentException("Invalid DTO name '{$name}': path traversal not allowed"); } $isNew = !isset($foundDtos[$name]); @@ -107,8 +109,16 @@ public function generate(string $configPath, string $srcPath, array $options = [ $suffix = $this->config->get('suffix', 'Dto'); $target = $srcPath . 'Dto' . DIRECTORY_SEPARATOR . $name . $suffix . '.php'; $targetPath = dirname($target); - if (!is_dir($targetPath)) { - mkdir($targetPath, 0777, true); + + // Validate target path is within the expected base directory + $this->ensureDirectoryExists($targetPath); + $realTargetPath = realpath($targetPath); + if ($realTargetPath === false || !str_starts_with($realTargetPath, $baseDtoPath)) { + throw new InvalidArgumentException(sprintf( + "Invalid target path '%s': must be within '%s'", + $targetPath, + $baseDtoPath, + )); } if ($isModified) { @@ -117,7 +127,7 @@ public function generate(string $configPath, string $srcPath, array $options = [ $this->displayDiff($oldContent, $content); } if (!$options['dryRun']) { - file_put_contents($target, $content); + $this->writeFile($target, $content); if ($options['confirm'] && !$this->checkPhpFileSyntax($target)) { $returnCode = static::CODE_ERROR; } @@ -181,9 +191,7 @@ protected function generateAndWriteMappers(array $definitions, string $srcPath, $suffix = $this->config->get('suffix', 'Dto'); $target = $srcPath . 'Dto' . DIRECTORY_SEPARATOR . 'Mapper' . DIRECTORY_SEPARATOR . $name . $suffix . 'Mapper.php'; $targetPath = dirname($target); - if (!is_dir($targetPath)) { - mkdir($targetPath, 0777, true); - } + $this->ensureDirectoryExists($targetPath); if ($isModified) { $this->io->out('Changes in ' . $name . ' Mapper:', 1, IoInterface::VERBOSE); @@ -191,7 +199,7 @@ protected function generateAndWriteMappers(array $definitions, string $srcPath, $this->displayDiff($oldContent, $content); } if (!$options['dryRun']) { - file_put_contents($target, $content); + $this->writeFile($target, $content); if ($options['confirm'] && !$this->checkPhpFileSyntax($target)) { // Don't fail the whole process for mapper syntax errors $this->io->error('Mapper syntax error in: ' . $name); @@ -220,9 +228,7 @@ protected function generateAndWriteMappers(array $definitions, string $srcPath, */ protected function findExistingDtos(string $path): array { - if (!is_dir($path)) { - mkdir($path, 0777, true); - } + $this->ensureDirectoryExists($path); $files = []; @@ -345,4 +351,51 @@ protected function checkPhpFileSyntax(string $file): bool return true; } + + /** + * Ensure a directory exists, creating it if necessary. + * + * @param string $path + * + * @throws \RuntimeException If directory cannot be created. + * + * @return void + */ + protected function ensureDirectoryExists(string $path): void + { + if (is_dir($path)) { + return; + } + + // Use @ to suppress warning, then check result + if (!@mkdir($path, 0777, true) && !is_dir($path)) { + throw new RuntimeException(sprintf( + "Failed to create directory '%s': %s", + $path, + error_get_last()['message'] ?? 'unknown error', + )); + } + } + + /** + * Write content to a file with proper error handling. + * + * @param string $path + * @param string $content + * + * @throws \RuntimeException If file cannot be written. + * + * @return void + */ + protected function writeFile(string $path, string $content): void + { + $result = @file_put_contents($path, $content); + if ($result === false) { + throw new RuntimeException(sprintf( + "Failed to write file '%s': %s", + $path, + error_get_last()['message'] ?? 'unknown error', + )); + } + } } diff --git a/src/Importer/Importer.php b/src/Importer/Importer.php index 55adf50..7d65c7b 100644 --- a/src/Importer/Importer.php +++ b/src/Importer/Importer.php @@ -4,6 +4,7 @@ namespace PhpCollective\Dto\Importer; +use JsonException; use PhpCollective\Dto\Importer\Builder\SchemaBuilder; use PhpCollective\Dto\Importer\Parser\DataParser; use PhpCollective\Dto\Importer\Parser\SchemaParser; @@ -41,12 +42,25 @@ class Importer * - basePath: Base path for external $ref file resolution * - refResolver: Custom ref resolver instance * + * @throws \JsonException If JSON parsing fails + * * @return array|string>> Parsed DTO definitions */ public function parse(string $json, array $options = []): array { - $array = json_decode($json, true, 512, JSON_THROW_ON_ERROR); - if (!$array) { + try { + $array = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + $source = $options['sourcePath'] ?? 'input'; + + throw new JsonException( + sprintf("Failed to parse JSON from '%s': %s", $source, $e->getMessage()), + $e->getCode(), + $e, + ); + } + + if (!is_array($array) || $array === []) { return []; } diff --git a/src/Importer/Ref/FileRefResolver.php b/src/Importer/Ref/FileRefResolver.php index 60e7f48..18db73e 100644 --- a/src/Importer/Ref/FileRefResolver.php +++ b/src/Importer/Ref/FileRefResolver.php @@ -108,7 +108,12 @@ protected function loadDocument(string $path): ?array return $this->cache[$path]; } - $contents = @file_get_contents($path); + // Check file is readable before attempting to read + if (!is_file($path) || !is_readable($path)) { + return null; + } + + $contents = file_get_contents($path); if ($contents === false) { return null; } diff --git a/tests/Dto/DtoTest.php b/tests/Dto/DtoTest.php index d79b5b7..2eeb12e 100644 --- a/tests/Dto/DtoTest.php +++ b/tests/Dto/DtoTest.php @@ -29,6 +29,8 @@ use PhpCollective\Dto\Test\TestDto\TestCollection; use PhpCollective\Dto\Test\TestDto\TransformDto; use PhpCollective\Dto\Test\TestDto\TraversableDto; +use PhpCollective\Dto\Test\TestDto\ValidatedDto; +use PhpCollective\Dto\Test\TestDto\WrongFactoryReturnDto; use PHPUnit\Framework\TestCase; use RuntimeException; use TypeError; @@ -1713,4 +1715,40 @@ public function testAssociativeArrayCollectionWithDtoObjectsExtractsKeys(): void $this->assertSame('Second', $items['Second']->getName()); $this->assertSame(20, $items['Second']->getCount()); } + + public function testFactoryReturnsWrongTypeThrowsException(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + "Factory method 'PhpCollective\\Dto\\Test\\TestDto\\WrongFactoryReturnValue::create' must return instance of PhpCollective\\Dto\\Test\\Generator\\Fixtures\\FactoryClass, got string for field 'factoryData'", + ); + + new WrongFactoryReturnDto(['factoryData' => 'test']); + } + + public function testRequiredFieldErrorMessageIncludesClassName(): void + { + try { + new ValidatedDto([]); + $this->fail('Expected InvalidArgumentException'); + } catch (InvalidArgumentException $e) { + $this->assertSame( + 'Required field missing in PhpCollective\\Dto\\Test\\TestDto\\ValidatedDto: name', + $e->getMessage(), + ); + } + } + + public function testMultipleRequiredFieldsErrorMessageFormat(): void + { + try { + new RequiredDto([]); + $this->fail('Expected InvalidArgumentException'); + } catch (InvalidArgumentException $e) { + $this->assertSame( + "Required fields missing in PhpCollective\\Dto\\Test\\TestDto\\RequiredDto:\n - name\n - email", + $e->getMessage(), + ); + } + } } diff --git a/tests/Dto/ValidationTest.php b/tests/Dto/ValidationTest.php index 9efdaf8..a1041cc 100644 --- a/tests/Dto/ValidationTest.php +++ b/tests/Dto/ValidationTest.php @@ -5,6 +5,7 @@ namespace PhpCollective\Dto\Test\Dto; use InvalidArgumentException; +use PhpCollective\Dto\Test\TestDto\InvalidPatternDto; use PhpCollective\Dto\Test\TestDto\SimpleDto; use PhpCollective\Dto\Test\TestDto\ValidatedDto; use PHPUnit\Framework\TestCase; @@ -124,11 +125,19 @@ public function testBoundaryValues(): void public function testRequiredFieldStillWorks(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Required fields missing: name'); + $this->expectExceptionMessage('Required field missing in PhpCollective\Dto\Test\TestDto\ValidatedDto: name'); new ValidatedDto([]); } + public function testInvalidPatternThrowsClearException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid regex pattern '/[/' for field 'value'"); + + new InvalidPatternDto(['value' => 'test']); + } + public function testToArray(): void { $dto = new ValidatedDto(['name' => 'Test', 'age' => 25]); diff --git a/tests/TestDto/InvalidPatternDto.php b/tests/TestDto/InvalidPatternDto.php new file mode 100644 index 0000000..2c143ca --- /dev/null +++ b/tests/TestDto/InvalidPatternDto.php @@ -0,0 +1,77 @@ +> + */ + protected array $_metadata = [ + 'value' => [ + 'type' => 'string', + 'required' => false, + 'defaultValue' => null, + 'dto' => false, + 'collectionType' => null, + 'singularType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'isClass' => false, + 'enum' => null, + 'minLength' => null, + 'maxLength' => null, + 'min' => null, + 'max' => null, + 'pattern' => '/[/', + ], + ]; + + /** + * @var array> + */ + protected array $_keyMap = [ + 'underscored' => [ + 'value' => 'value', + ], + 'dashed' => [ + 'value' => 'value', + ], + ]; + + public function getValue(): ?string + { + return $this->value; + } + + public function setValue(?string $value): self + { + $this->value = $value; + $this->_touchedFields['value'] = true; + + return $this; + } + + public function hasValue(): bool + { + return $this->value !== null; + } + + public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array + { + return $this->_toArrayInternal($type, $fields, $touched); + } + + public static function createFromArray(array $data, bool $ignoreMissing = false, ?string $type = null): static + { + return static::_createFromArrayInternal($data, $ignoreMissing, $type); + } +} diff --git a/tests/TestDto/WrongFactoryReturnDto.php b/tests/TestDto/WrongFactoryReturnDto.php new file mode 100644 index 0000000..802bbb9 --- /dev/null +++ b/tests/TestDto/WrongFactoryReturnDto.php @@ -0,0 +1,73 @@ +> + */ + protected array $_metadata = [ + 'factoryData' => [ + 'type' => FactoryClass::class, + 'required' => false, + 'defaultValue' => null, + 'dto' => false, + 'collectionType' => null, + 'singularType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => WrongFactoryReturnValue::class . '::create', + 'isClass' => true, + 'enum' => null, + ], + ]; + + /** + * @var array> + */ + protected array $_keyMap = [ + 'underscored' => [ + 'factory_data' => 'factoryData', + ], + 'dashed' => [ + 'factory-data' => 'factoryData', + ], + ]; + + public function getFactoryData(): ?FactoryClass + { + return $this->factoryData; + } + + public function setFactoryData(?FactoryClass $factoryData): self + { + $this->factoryData = $factoryData; + $this->_touchedFields['factoryData'] = true; + + return $this; + } + + public function hasFactoryData(): bool + { + return $this->factoryData !== null; + } + + public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array + { + return $this->_toArrayInternal($type, $fields, $touched); + } + + public static function createFromArray(array $data, bool $ignoreMissing = false, ?string $type = null): static + { + return static::_createFromArrayInternal($data, $ignoreMissing, $type); + } +} diff --git a/tests/TestDto/WrongFactoryReturnValue.php b/tests/TestDto/WrongFactoryReturnValue.php new file mode 100644 index 0000000..1402123 --- /dev/null +++ b/tests/TestDto/WrongFactoryReturnValue.php @@ -0,0 +1,13 @@ +