Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/guide/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/guide/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down Expand Up @@ -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'],
Expand Down
6 changes: 5 additions & 1 deletion docs/guide/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 6 additions & 3 deletions docs/guide/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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']);
```

Expand Down
69 changes: 63 additions & 6 deletions src/Dto/Dto.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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 = [];
Expand All @@ -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);
}
}

Expand Down
75 changes: 64 additions & 11 deletions src/Generator/Generator.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use InvalidArgumentException;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;

class Generator
{
Expand Down Expand Up @@ -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]);
Expand All @@ -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) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -181,17 +191,15 @@ 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);
$oldContent = file_get_contents($foundMappers[$name]) ?: '';
$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);
Expand Down Expand Up @@ -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 = [];

Expand Down Expand Up @@ -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',
));
}
}
}
18 changes: 16 additions & 2 deletions src/Importer/Importer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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, array<string, array<string, mixed>|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 [];
}

Expand Down
7 changes: 6 additions & 1 deletion src/Importer/Ref/FileRefResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading