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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@ How the response is looked up:
* **Media type**: the response's `Content-Type`, including `+json` types such as `application/problem+json`.
Without a match it falls back to `application/json`. Only JSON bodies are validated.

Bodies are validated with [opis/json-schema](https://github.com/opis/json-schema), which supports JSON Schema
2020-12, the dialect of OpenAPI 3.1. OpenAPI 3.0 documents are translated first: `nullable` and the boolean form of
`exclusiveMinimum` and `exclusiveMaximum` become their 2020-12 equivalents.

Another engine can be used by passing a `Schema\SchemaValidator` to the verifier. It gets each schema as the document
writes it, together with the document's OpenAPI version, so it can handle 3.0 its own way or use
`Schema\OpenApi30Translator`.

A mismatch carries one `Schema\SchemaError` per problem, with a JSON pointer into the body, the failing keyword and a
message. `getErrorSummary()` groups them:

```
required - The required properties (name) are missing
- /data/* (2 more)
```

### Guzzle and PSR-18 clients
Verifying on the client side needs no framework. It works against a running server, a mock
handler or anything else a client can talk to.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@
"require": {
"php": ">=8.2",
"ext-json": "*",
"justinrainbow/json-schema": "^5.2.13 || ^6",
"nyholm/psr7": "^1.1",
"nyholm/psr7-server": "^1.0",
"opis/json-schema": "^2.4",
"psr/http-client": "^1.0",
"psr/http-message": "^2.0",
"psr/http-server-middleware": "^1.0",
Expand Down
66 changes: 21 additions & 45 deletions src/OpenApiSchemaMismatchException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,23 @@

namespace Radebatz\OpenApi\Verifier;

/**
* @phpstan-type JsonSchemaValidationError array{
* property: string,
* pointer: string,
* message: string,
* constraint: string|array{name: string, params: array<mixed>},
* context: int
* }
*/
use Radebatz\OpenApi\Verifier\Schema\SchemaError;

class OpenApiSchemaMismatchException extends \Exception
{
/** @var list<JsonSchemaValidationError> */
/** @var list<SchemaError> */
protected array $errors = [];

/**
* @return list<JsonSchemaValidationError>
* @return list<SchemaError>
*/
public function getErrors(): array
{
return $this->errors;
}

/**
* @param list<JsonSchemaValidationError> $errors
* @param list<SchemaError> $errors
*/
public function setErrors(array $errors): OpenApiSchemaMismatchException
{
Expand All @@ -34,49 +27,32 @@ public function setErrors(array $errors): OpenApiSchemaMismatchException
return $this;
}

/**
* The errors grouped by keyword and message, one line per failing pointer.
*
* Array indices in a pointer are replaced by `*`, so the same error on every item of a list
* is one line with a count.
*/
public function getErrorSummary(): ?string
{
if (!$this->errors) {
return null;
}

// sanitize
$errorTypeMap = [];
$groups = [];
foreach ($this->errors as $error) {
$wildcarded = preg_replace('/\[\d+\]/', '[*]', $error['property']);

// ensures compatibility with both, JsonSchema 5.x and 6.x
$constraintName = $error['constraint']['name']
?? $error['constraint'];

$errorType = sprintf('%s|%s', $constraintName, $error['message']);
if (!array_key_exists($errorType, $errorTypeMap)) {
$errorTypeMap[$errorType] = [
'properties' => [
$wildcarded => 1,
],
'constraint' => $constraintName,
'message' => $error['message'],
];
} elseif (!array_key_exists($wildcarded, $errorTypeMap[$errorType]['properties'])) {
// track usage of the same wildcard-property
$errorTypeMap[$errorType]['properties'][$wildcarded] = 1;
} else {
$errorTypeMap[$errorType]['properties'][$wildcarded]++;
}
$pointer = (string) preg_replace('#/\d+(?=/|$)#', '/*', $error->pointer) ?: '/';
$key = $error->keyword . '|' . $error->message;
$groups[$key]['keyword'] = $error->keyword;
$groups[$key]['message'] = $error->message;
$groups[$key]['pointers'][$pointer] = ($groups[$key]['pointers'][$pointer] ?? 0) + 1;
}

$summary = [];
foreach ($errorTypeMap as $errorType) {
$summary[] = sprintf('%s - %s', $errorType['constraint'], $errorType['message']);

foreach ($errorType['properties'] as $propertyName => $propertyAmount) {
// if the property errored multiple times, add the amount to the error-message
if (1 === $propertyAmount) {
$summary[] = sprintf(' - %s', $propertyName);
} else {
$summary[] = sprintf(' - %s (%s more)', $propertyName, $propertyAmount - 1);
}
foreach ($groups as $group) {
$summary[] = sprintf('%s - %s', $group['keyword'], $group['message']);
foreach ($group['pointers'] as $pointer => $count) {
$summary[] = 1 === $count ? sprintf(' - %s', $pointer) : sprintf(' - %s (%s more)', $pointer, $count - 1);
}
}

Expand Down
53 changes: 16 additions & 37 deletions src/OpenApiSpecificationLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,29 +45,23 @@ protected function resolveSpecification($specification)
throw new \InvalidArgumentException('Invalid specification');
}

return $this->fixNullable($resolved);
return $resolved;
}

/**
* Get a schema url for the response matching the given parameters.
*
* `$path` may be a concrete path (`/user/1`) or a template from the specification (`/user/{id}`).
* The document's OpenAPI version, `3.0.0` if it does not say.
*/
public function getResponseSchemaUrlFor(string $method, string $path, int $statusCode, ?string $contentType = null): ?string
public function getOpenApiVersion(): string
{
if (!($schema = $this->getResponseSchemaFor($method, $path, $statusCode, $contentType)) instanceof \stdClass) {
return null;
}

// copy, so the components do not end up inside the specification itself
$schema = clone $schema;
$schema->components = $this->property($this->specification, 'components') ?? new \stdClass();

return 'data://application/json;base64,' . base64_encode((string) json_encode($schema));
return (string) ($this->property($this->specification, 'openapi') ?? '3.0.0');
}

/**
* Get the schema of the response matching the given parameters, if there is one.
*
* `$path` may be a concrete path (`/user/1`) or a template from the specification (`/user/{id}`).
* The schema is as the document writes it, in the dialect of `getOpenApiVersion()`, and
* carries the specification's `components`, so `#/components/...` references resolve within it.
*/
public function getResponseSchemaFor(string $method, string $path, int $statusCode, ?string $contentType = null): ?\stdClass
{
Expand All @@ -79,9 +73,15 @@ public function getResponseSchemaFor(string $method, string $path, int $statusCo
$operation = $this->property($pathItem, strtolower($method));
$response = $this->resolveResponse($this->property($operation, 'responses'), $statusCode);
$mediaType = $this->resolveMediaType($this->property($response, 'content'), $contentType);
$schema = $this->property($mediaType, 'schema');
if (null === ($schema = $this->property($mediaType, 'schema'))) {
return null;
}

return null !== $schema ? (object) $schema : null;
// copy, so the components do not end up inside the specification itself
$schema = is_object($schema) ? clone $schema : (object) $schema;
$schema->components = $this->property($this->specification, 'components') ?? new \stdClass();

return $schema;
}

/**
Expand Down Expand Up @@ -218,25 +218,4 @@ protected function property(mixed $node, string $name): mixed

return null;
}

// https://github.com/justinrainbow/json-schema/issues/551
protected function fixNullable(&$node)
{
if (is_object($node) && property_exists($node, 'nullable') && property_exists($node, 'type')) {
$anyOf = [
['type' => $node->type, 'format' => property_exists($node, 'format') ? $node->format : ''],
['type' => null],
];
unset($node->type);
$node->anyOf = $anyOf;
}

if (is_iterable($node) || is_object($node)) {
foreach ($node as &$value) {
$this->fixNullable($value);
}
}

return $node;
}
}
26 changes: 14 additions & 12 deletions src/OpenApiVerifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

namespace Radebatz\OpenApi\Verifier;

use JsonSchema\Uri\UriRetriever;
use JsonSchema\Validator;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Radebatz\OpenApi\Verifier\Schema\OpisSchemaValidator;
use Radebatz\OpenApi\Verifier\Schema\SchemaValidator;

/**
* Verifies responses against an OpenAPI specification.
Expand All @@ -14,16 +14,21 @@
*/
final class OpenApiVerifier
{
public function __construct(private readonly OpenApiSpecificationLoader $specificationLoader)
{
private readonly SchemaValidator $schemaValidator;

public function __construct(
private readonly OpenApiSpecificationLoader $specificationLoader,
?SchemaValidator $schemaValidator = null,
) {
$this->schemaValidator = $schemaValidator ?? new OpisSchemaValidator();
}

/**
* @param object|string $specification the specification object or filename
*/
public static function fromSpecification(object|string $specification): self
public static function fromSpecification(object|string $specification, ?SchemaValidator $schemaValidator = null): self
{
return new self(new OpenApiSpecificationLoader($specification));
return new self(new OpenApiSpecificationLoader($specification), $schemaValidator);
}

public function getSpecificationLoader(): OpenApiSpecificationLoader
Expand Down Expand Up @@ -68,16 +73,13 @@ public function verify(RequestInterface $request, ResponseInterface $response, ?
*/
public function verifyResponseBody(string $method, string $path, int $statusCode, string $body, ?string $contentType = null): bool
{
if (!$schemaUrl = $this->specificationLoader->getResponseSchemaUrlFor($method, $path, $statusCode, $contentType)) {
if (!($schema = $this->specificationLoader->getResponseSchemaFor($method, $path, $statusCode, $contentType)) instanceof \stdClass) {
return false;
}

$validator = new Validator();
$validator->check(json_decode($body), (new UriRetriever())->retrieve($schemaUrl));

if (!$validator->isValid()) {
if ([] !== ($errors = $this->schemaValidator->validate(json_decode($body), $schema, $this->specificationLoader->getOpenApiVersion()))) {
throw (new OpenApiSchemaMismatchException(sprintf('Schema mismatch: %s[%s]:%s', $method, $statusCode, $path)))
->setErrors($validator->getErrors());
->setErrors($errors);
}

return true;
Expand Down
87 changes: 87 additions & 0 deletions src/Schema/OpenApi30Translator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php declare(strict_types=1);

namespace Radebatz\OpenApi\Verifier\Schema;

/**
* Rewrites an OpenAPI 3.0 schema into JSON Schema 2020-12, for validators of that dialect.
*
* - `nullable: true` adds `null` to `type`, and to `enum` if there is one; without a `type`,
* the schema becomes `anyOf: [<schema>, {type: null}]`
* - boolean `exclusiveMinimum`/`exclusiveMaximum` take the value of `minimum`/`maximum`
*
* Only boolean values are rewritten, so a property that happens to be named `nullable` is not.
*/
final class OpenApi30Translator
{
/**
* Whether schemas from a document of this OpenAPI version need translating.
*/
public function needsTranslation(string $openApiVersion): bool
{
return str_starts_with($openApiVersion, '3.0');
}

/**
* @return \stdClass a translated copy; the given schema is left as it is
*/
public function translate(\stdClass $schema): \stdClass
{
// a deep copy, since the schema shares its components with the loaded specification
$copy = json_decode((string) json_encode($schema));

// translated on their own, so they stay at the root even when the schema gets wrapped
$components = $copy->components ?? null;
unset($copy->components);

$translated = $this->translateNode($copy);
if (null !== $components) {
$translated->components = $this->translateNode($components);
}

return $translated;
}

private function translateNode(mixed $node): mixed
{
if (is_array($node)) {
return array_map($this->translateNode(...), $node);
}

if (!is_object($node)) {
return $node;
}

foreach (get_object_vars($node) as $name => $value) {
$node->{$name} = $this->translateNode($value);
}

foreach (['exclusiveMinimum' => 'minimum', 'exclusiveMaximum' => 'maximum'] as $exclusive => $bound) {
if (is_bool($node->{$exclusive} ?? null)) {
if ($node->{$exclusive} && property_exists($node, $bound)) {
$node->{$exclusive} = $node->{$bound};
unset($node->{$bound});
} else {
unset($node->{$exclusive});
}
}
}

if (is_bool($node->nullable ?? null)) {
$nullable = $node->nullable;
unset($node->nullable);

if ($nullable) {
if (property_exists($node, 'type')) {
$node->type = array_values(array_unique([...(array) $node->type, 'null']));
if (is_array($node->enum ?? null) && !in_array(null, $node->enum, true)) {
$node->enum[] = null;
}
} else {
$node = (object) ['anyOf' => [$node, (object) ['type' => 'null']]];
}
}
}

return $node;
}
}
Loading
Loading