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
120 changes: 96 additions & 24 deletions src/Parser/OpenApiReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ final class OpenApiReader

public const SUPPORTED_MATRIX = 'Supported versions: OpenAPI 3.0.x and 3.1.x (fully), 3.2.x (accepted best-effort with warnings). See '.self::VERSION_MATRIX_URL;

/**
* The human-readable label for the spec source supplied to read(), captured
* for the current read() so the deep hydration helpers (which thread a JSON
* pointer but not the source) can name the file in their error messages
* without widening every signature. Reset at the start of each read().
*/
private string $source = 'spec';

/**
* Numeric schema keywords whose strictly-numeric string values are coerced
* to int/float (issue #32). Kept here as the
Expand Down Expand Up @@ -142,21 +150,24 @@ public function read(mixed $data, string $source = 'spec'): OpenApiDocument
}

$this->nodeCount = 0;
$this->source = $source;

$version = $data['openapi'] ?? null;

if (! is_string($version) || $version === '') {
throw new ParseException("Not an OpenAPI 3.x document ({$source}): missing 'openapi' version string. Swagger 2.0 and other formats are not supported. ".self::SUPPORTED_MATRIX);
$found = $this->describeType($version);
throw new ParseException("Not an OpenAPI 3.x document ({$source}): the root '#/openapi' member must be a version string like '3.1.0', but {$found}. Swagger 2.0 (a 'swagger' key) and other formats are not supported. ".self::SUPPORTED_MATRIX);
}

if (preg_match('/^3\.(\d+)(?:[.\-+]|$)/', $version, $matches) !== 1 || ! in_array((int) $matches[1], [0, 1, 2], true)) {
throw new ParseException("Unsupported OpenAPI version '{$version}' ({$source}). ".self::SUPPORTED_MATRIX);
throw new ParseException("Unsupported OpenAPI version '{$version}' at #/openapi ({$source}). ".self::SUPPORTED_MATRIX);
}

$rawInfo = $data['info'] ?? null;

if (! is_array($rawInfo)) {
throw new ParseException("Not a valid OpenAPI document ({$source}): missing required 'info' object.");
$found = $this->describeType($rawInfo);
throw new ParseException("Not a valid OpenAPI document ({$source}): the required '#/info' object is {$found}. Add an 'info' object with at least a 'title' and a 'version'.");
}

$warnings = [];
Expand Down Expand Up @@ -494,7 +505,7 @@ private function components(array $raw): ComponentsNode
$rawSchemas = $raw['schemas'] ?? null;
if (is_array($rawSchemas)) {
foreach ($rawSchemas as $name => $value) {
$node = $this->subschema($value, 0);
$node = $this->subschema($value, 0, '#/components/schemas/'.$this->escapePointer((string) $name));
if ($node !== null) {
$schemas[(string) $name] = $node;
}
Expand Down Expand Up @@ -580,7 +591,7 @@ private function components(array $raw): ComponentsNode
* ("nothing"). Anything else (a mistyped value) returns null and the
* caller routes the raw value to its extra bag or skips the entry.
*/
private function subschema(mixed $value, int $depth): SchemaNode|ReferenceNode|null
private function subschema(mixed $value, int $depth, string $pointer = ''): SchemaNode|ReferenceNode|null
{
if (is_bool($value)) {
return $value ? new SchemaNode : new SchemaNode(not: new SchemaNode);
Expand All @@ -592,7 +603,7 @@ private function subschema(mixed $value, int $depth): SchemaNode|ReferenceNode|n

return $this->isReference($value)
? $this->reference($value)
: $this->schema($value, $depth);
: $this->schema($value, $depth, $pointer);
}

/**
Expand All @@ -604,10 +615,12 @@ private function subschema(mixed $value, int $depth): SchemaNode|ReferenceNode|n
*
* @param array<array-key, mixed> $raw
*/
private function schema(array $raw, int $depth): SchemaNode
private function schema(array $raw, int $depth, string $pointer = ''): SchemaNode
{
$at = $this->locationSuffix($pointer);

if ($depth > $this->maxDepth) {
throw new ParseException("OpenAPI document exceeds the maximum schema nesting depth ({$this->maxDepth}).");
throw new ParseException("OpenAPI document ({$this->source}) exceeds the maximum schema nesting depth ({$this->maxDepth}){$at}. Flatten the nesting, factor the deep shape into a component schema referenced by \$ref, or raise the bound via --max-depth / the max_depth config key only for a trusted spec.");
}

// Total-node guard (issue #107): YAML alias amplification can fan a
Expand All @@ -616,7 +629,7 @@ private function schema(array $raw, int $depth): SchemaNode
// node and failing closed here bounds that breadth blowup before it
// exhausts memory.
if (++$this->nodeCount > $this->maxNodes) {
throw new ParseException("OpenAPI document exceeds the maximum hydrated schema node count ({$this->maxNodes}). This usually means the spec uses YAML anchors and aliases to amplify a small file into a very large structure. Override the bound via the constructor only for a trusted spec.");
throw new ParseException("OpenAPI document ({$this->source}) exceeds the maximum hydrated schema node count ({$this->maxNodes}){$at}. This usually means the spec uses YAML anchors and aliases to amplify a small file into a very large structure. Override the bound via the constructor only for a trusted spec.");
}

// Closed tuple (issue #82): `items: false` next to a non-empty
Expand Down Expand Up @@ -785,18 +798,18 @@ private function schema(array $raw, int $depth): SchemaNode
}
break;
case 'properties':
$properties = $this->schemaMap($value, $depth, $extra, 'properties');
$properties = $this->schemaMap($value, $depth, $extra, 'properties', $pointer);
break;
case 'patternProperties':
$patternProperties = $this->schemaMap($value, $depth, $extra, 'patternProperties');
$patternProperties = $this->schemaMap($value, $depth, $extra, 'patternProperties', $pointer);
break;
case 'additionalProperties':
if (is_bool($value)) {
$additionalProperties = $value;
$hasAdditionalProperties = true;
break;
}
$node = $this->subschema($value, $depth + 1);
$node = $this->subschema($value, $depth + 1, $this->childPointer($pointer, 'additionalProperties'));
if ($node === null) {
$extra['additionalProperties'] = $value;
} else {
Expand All @@ -807,7 +820,7 @@ private function schema(array $raw, int $depth): SchemaNode
case 'allOf':
case 'oneOf':
case 'anyOf':
$list = $this->schemaList($value, $depth);
$list = $this->schemaList($value, $depth, $this->childPointer($pointer, (string) $key));
if ($list === null) {
$extra[(string) $key] = $value;
} else {
Expand All @@ -821,15 +834,15 @@ private function schema(array $raw, int $depth): SchemaNode
// empty placeholder node (which emits no rules, exactly
// like the skipped null position on the old cebe path)
// instead of being dropped.
$list = $this->prefixItemList($value, $depth);
$list = $this->prefixItemList($value, $depth, $this->childPointer($pointer, 'prefixItems'));
if ($list === null) {
$extra['prefixItems'] = $value;
} else {
$lists['prefixItems'] = $list;
}
break;
case 'not':
$not = $this->subschema($value, $depth + 1);
$not = $this->subschema($value, $depth + 1, $this->childPointer($pointer, 'not'));
if ($not === null) {
$extra['not'] = $value;
}
Expand All @@ -841,7 +854,7 @@ private function schema(array $raw, int $depth): SchemaNode
if ($value === true) {
$items = new SchemaNode;
} elseif ($value !== false) {
$items = $this->subschema($value, $depth + 1);
$items = $this->subschema($value, $depth + 1, $this->childPointer($pointer, 'items'));
if ($items === null) {
$extra['items'] = $value;
}
Expand Down Expand Up @@ -946,17 +959,19 @@ enum: $enum,
* @param array<string, mixed> $extra
* @return array<string, SchemaNode|ReferenceNode>|null
*/
private function schemaMap(mixed $value, int $depth, array &$extra, string $key): ?array
private function schemaMap(mixed $value, int $depth, array &$extra, string $key, string $pointer = ''): ?array
{
if (! is_array($value)) {
$extra[$key] = $value;

return null;
}

$base = $this->childPointer($pointer, $key);

$map = [];
foreach ($value as $name => $entry) {
$node = $this->subschema($entry, $depth + 1);
$node = $this->subschema($entry, $depth + 1, $this->childPointer($base, (string) $name));
if ($node !== null) {
$map[(string) $name] = $node;
}
Expand All @@ -972,15 +987,15 @@ private function schemaMap(mixed $value, int $depth, array &$extra, string $key)
*
* @return list<SchemaNode|ReferenceNode>|null
*/
private function schemaList(mixed $value, int $depth): ?array
private function schemaList(mixed $value, int $depth, string $pointer = ''): ?array
{
if (! is_array($value) || ! array_is_list($value)) {
return null;
}

$list = [];
foreach ($value as $entry) {
$node = $this->subschema($entry, $depth + 1);
foreach ($value as $index => $entry) {
$node = $this->subschema($entry, $depth + 1, $this->childPointer($pointer, (string) $index));
if ($node !== null) {
$list[] = $node;
}
Expand All @@ -1000,15 +1015,15 @@ private function schemaList(mixed $value, int $depth): ?array
*
* @return list<SchemaNode|ReferenceNode>|null
*/
private function prefixItemList(mixed $value, int $depth): ?array
private function prefixItemList(mixed $value, int $depth, string $pointer = ''): ?array
{
if (! is_array($value) || ! array_is_list($value)) {
return null;
}

$list = [];
foreach ($value as $entry) {
$list[] = $this->subschema($entry, $depth + 1) ?? new SchemaNode;
foreach ($value as $index => $entry) {
$list[] = $this->subschema($entry, $depth + 1, $this->childPointer($pointer, (string) $index)) ?? new SchemaNode;
}

return $list;
Expand Down Expand Up @@ -1282,4 +1297,61 @@ private function numericFromString(string $value): int|float

return $float;
}

/**
* Append a single member or array index to a JSON pointer (RFC 6901),
* escaping the segment. An empty parent pointer (a schema reached from a
* position the reader does not pinpoint, e.g. an inline parameter or media
* type schema) yields an empty pointer too, so the message degrades to "no
* location" cleanly rather than producing a misleading partial pointer.
*/
private function childPointer(string $parent, string $segment): string
{
if ($parent === '') {
return '';
}

return $parent.'/'.$this->escapePointer($segment);
}

/**
* Escape a single JSON pointer reference token (RFC 6901): `~` becomes `~0`
* and `/` becomes `~1`, in that order, so a property or component name that
* contains either character produces a valid, unambiguous pointer.
*/
private function escapePointer(string $segment): string
{
return str_replace(['~', '/'], ['~0', '~1'], $segment);
}

/**
* Render the ` at <pointer>` location suffix for an error message, or the
* empty string when the reader could not pinpoint the offending node (the
* pointer is threaded only from positions the reader tracks). Keeping the
* caller's message identical when there is no pointer avoids a dangling
* "at " with nothing after it.
*/
private function locationSuffix(string $pointer): string
{
return $pointer === '' ? '' : " at {$pointer}";
}

/**
* A short human-readable description of a found value's type, for the
* expected-vs-found half of a structural-rejection message: "missing"
* (null/absent), "a string", "a number", "a boolean", "an array", or "an
* object". Never echoes the value itself, which is untrusted input.
*/
private function describeType(mixed $value): string
{
return match (true) {
$value === null => 'missing',
is_string($value) => 'a string',
is_int($value) || is_float($value) => 'a number',
is_bool($value) => 'a boolean',
is_array($value) && array_is_list($value) => 'an array',
is_array($value) => 'an object',
default => 'an unexpected value',
};
}
}
21 changes: 16 additions & 5 deletions src/Parser/SpecParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,18 +91,27 @@ public function parseFileToDocument(string $path): OpenApiDocument
// (issue #17). The guard is disarmed as soon as the parse step returns.
MemoryGuard::arm($absolute, $this->maxBytes);

$isYaml = $this->isYaml($absolute);

try {
$contents = (string) file_get_contents($absolute);

$data = $this->isYaml($absolute)
$data = $isYaml
? Yaml::parse($contents)
: json_decode($contents, true, flags: JSON_THROW_ON_ERROR);

$document = (new OpenApiReader)->read($data, $path);
} catch (ParseException $e) {
throw $e;
} catch (Throwable $e) {
throw new ParseException("Failed to parse OpenAPI spec ({$path}): {$e->getMessage()}", 0, $e);
$format = $isYaml ? 'YAML' : 'JSON';
throw new ParseException(
"Failed to parse OpenAPI spec ({$path}) as {$format}: {$e->getMessage()}. "
."Check that the file is well-formed {$format} (the format is taken from the file extension, "
.'falling back to sniffing the first non-whitespace byte: { or [ means JSON, anything else YAML).',
0,
$e,
);
} finally {
MemoryGuard::disarm();
}
Expand All @@ -122,9 +131,11 @@ private function guardSize(string $absolute): void

if ($size !== false && $size > $this->maxBytes) {
throw new ParseException(sprintf(
'OpenAPI spec is too large (%d bytes, limit %d bytes). Raise --max-bytes only for trusted '
.'specs, and note that a larger spec needs a proportionally larger PHP memory_limit to parse '
.'(it can otherwise exhaust memory mid-parse). Or run the generator under OS-level resource limits.',
'OpenAPI spec is too large (%s: %d bytes, limit %d bytes). Raise --max-bytes / the max_bytes '
.'config key only for trusted specs, and note that a larger spec needs a proportionally larger '
.'PHP memory_limit to parse (it can otherwise exhaust memory mid-parse). Or run the generator '
.'under OS-level resource limits.',
$absolute,
$size,
$this->maxBytes,
));
Expand Down
Loading
Loading