Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add assertions for testing response against JSON Schema from API resource #3003

Merged
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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"friendsofsymfony/user-bundle": "^2.2@dev",
"guzzlehttp/guzzle": "^6.0",
"jangregor/phpstan-prophecy": "^0.4.2",
"justinrainbow/json-schema": "^5.0",
"justinrainbow/json-schema": "^5.2.1",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"nelmio/api-doc-bundle": "^2.13.4",
"phpdocumentor/reflection-docblock": "^3.0 || ^4.0",
"phpdocumentor/type-resolver": "^0.3 || ^0.4",
Expand Down
8 changes: 7 additions & 1 deletion phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ parameters:
-
message: '#Parameter \#1 \$docblock of method phpDocumentor\\Reflection\\DocBlockFactoryInterface::create\(\) expects string, ReflectionClass given\.#'
path: %currentWorkingDirectory%/src/Metadata/Resource/Factory/PhpDocResourceMetadataFactory.php
-
message: '#Parameter \#1 \$objectValue of method GraphQL\\Type\\Definition\\InterfaceType::resolveType\(\) expects object, array(<string, string>)? given.#'
path: %currentWorkingDirectory%/tests/GraphQl/Type/TypeBuilderTest.php
-
message: '#Property ApiPlatform\\Core\\Test\\DoctrineMongoDbOdmFilterTestCase::\$repository \(Doctrine\\ODM\\MongoDB\\Repository\\DocumentRepository\) does not accept Doctrine\\ORM\\EntityRepository<ApiPlatform\\Core\\Tests\\Fixtures\\TestBundle\\Document\\Dummy>\.#'
path: %currentWorkingDirectory%/src/Test/DoctrineMongoDbOdmFilterTestCase.php
Expand All @@ -78,7 +81,10 @@ parameters:
-
message: '#Binary operation "\+" between (float\|int\|)?string and 0 results in an error\.#'
path: %currentWorkingDirectory%/src/Bridge/Doctrine/Common/Filter/RangeFilterTrait.php
- '#Parameter \#1 \$objectValue of method GraphQL\\Type\\Definition\\InterfaceType::resolveType\(\) expects object, array(<string, string>)? given.#'
# https://github.com/phpstan/phpstan-symfony/issues/27
-
message: '#Service "api_platform\.json_schema\.schema_factory" is private\.#'
path: %currentWorkingDirectory%/src/Bridge/Symfony/Bundle/Test/ApiTestAssertionsTrait.php

# Expected, due to optional interfaces
- '#Method ApiPlatform\\Core\\Bridge\\Doctrine\\Orm\\Extension\\QueryCollectionExtensionInterface::applyToCollection\(\) invoked with 5 parameters, 3-4 required\.#'
Expand Down
38 changes: 35 additions & 3 deletions src/Bridge/Symfony/Bundle/Test/ApiTestAssertionsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@

namespace ApiPlatform\Core\Bridge\Symfony\Bundle\Test;

use ApiPlatform\Core\Api\OperationType;
use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\Constraint\ArraySubset;
use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\Constraint\MatchesJsonSchema;
use ApiPlatform\Core\JsonSchema\Schema;
use ApiPlatform\Core\JsonSchema\SchemaFactoryInterface;
use PHPUnit\Framework\ExpectationFailedException;
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
use Symfony\Contracts\HttpClient\ResponseInterface;

/**
Expand All @@ -28,9 +32,9 @@ trait ApiTestAssertionsTrait
use BrowserKitAssertionsTrait;

/**
* Asserts that the retrieved JSON contains has the specified subset.
* Asserts that the retrieved JSON contains the specified subset.
*
* This method delegates to self::assertArraySubset().
* This method delegates to static::assertArraySubset().
*
* @param array|string $subset
*
Expand Down Expand Up @@ -91,18 +95,34 @@ public static function assertJsonEquals($json, string $message = ''): void
public static function assertArraySubset($subset, $array, bool $checkForObjectIdentity = false, string $message = ''): void
{
$constraint = new ArraySubset($subset, $checkForObjectIdentity);

static::assertThat($array, $constraint, $message);
}

/**
* @param array|string $jsonSchema
* @param object|array|string $jsonSchema
*/
public static function assertMatchesJsonSchema($jsonSchema, ?int $checkMode = null, string $message = ''): void
{
$constraint = new MatchesJsonSchema($jsonSchema, $checkMode);

static::assertThat(self::getHttpResponse()->toArray(false), $constraint, $message);
}

public static function assertMatchesResourceCollectionJsonSchema(string $resourceClass, ?string $operationName = null, string $format = 'jsonld'): void
{
$schema = self::getSchemaFactory()->buildSchema($resourceClass, $format, Schema::TYPE_OUTPUT, OperationType::COLLECTION, $operationName);

static::assertMatchesJsonSchema($schema);
}

public static function assertMatchesResourceItemJsonSchema(string $resourceClass, ?string $operationName = null, string $format = 'jsonld'): void
{
$schema = self::getSchemaFactory()->buildSchema($resourceClass, $format, Schema::TYPE_OUTPUT, OperationType::ITEM, $operationName);

static::assertMatchesJsonSchema($schema);
}

private static function getHttpClient(Client $newClient = null): ?Client
{
static $client;
Expand All @@ -126,4 +146,16 @@ private static function getHttpResponse(): ResponseInterface

return $response;
}

private static function getSchemaFactory(): SchemaFactoryInterface
{
try {
/** @var SchemaFactoryInterface $schemaFactory */
$schemaFactory = static::$container->get('api_platform.json_schema.schema_factory');
} catch (ServiceNotFoundException $e) {
throw new \LogicException('You cannot use the resource JSON Schema assertions if the "api_platform.swagger.versions" config is null or empty.');
}

return $schemaFactory;
}
}
47 changes: 39 additions & 8 deletions src/Bridge/Symfony/Bundle/Test/Constraint/MatchesJsonSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,19 @@
*/
final class MatchesJsonSchema extends Constraint
{
/**
* @var object|array
*/
private $schema;
private $checkMode;

/**
* @param array|string $schema
* @param object|array|string $schema
*/
public function __construct($schema, ?int $checkMode = null)
{
$this->schema = \is_string($schema) ? json_decode($schema) : $schema;
$this->checkMode = $checkMode;
$this->schema = \is_array($schema) ? (object) $schema : json_decode($schema);
}

/**
Expand All @@ -46,15 +49,15 @@ public function toString(): string
}

/**
* @param array $other
* {@inheritdoc}
*/
protected function matches($other): bool
{
if (!class_exists(Validator::class)) {
throw new \RuntimeException('The "justinrainbow/json-schema" library must be installed to use "assertMatchesJsonSchema()". Try running "composer require --dev justinrainbow/json-schema".');
throw new \LogicException('The "justinrainbow/json-schema" library must be installed to use "assertMatchesJsonSchema()". Try running "composer require --dev justinrainbow/json-schema".');
}

$other = (object) $other;
$other = $this->normalizeJson($other);

$validator = new Validator();
$validator->validate($other, $this->schema, $this->checkMode);
Expand All @@ -63,14 +66,14 @@ protected function matches($other): bool
}

/**
* @param object $other
* {@inheritdoc}
*/
protected function additionalFailureDescription($other): string
{
$other = (object) $other;
$other = $this->normalizeJson($other);

$validator = new Validator();
$validator->check($other, $this->schema);
$validator->validate($other, $this->schema, $this->checkMode);

$errors = [];
foreach ($validator->getErrors() as $error) {
Expand All @@ -80,4 +83,32 @@ protected function additionalFailureDescription($other): string

return implode("\n", $errors);
}

/**
* Normalizes a JSON document.
*
* Specifically, we should ensure that:
* 1. a JSON object is represented as a PHP object, not as an associative array.
*/
private function normalizeJson($document)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We support validating any JSON document against a JSON Schema now. 🎉

{
if (is_scalar($document) || \is_object($document)) {
return $document;
}

if (!\is_array($document)) {
throw new \InvalidArgumentException('Document must be scalar, array or object.');
}

$document = json_encode($document);
if (!\is_string($document)) {
throw new \UnexpectedValueException('JSON encode failed.');
}
$document = json_decode($document);
if (!\is_array($document) && !\is_object($document)) {
throw new \UnexpectedValueException('JSON decode failed.');
}

return $document;
}
}
30 changes: 19 additions & 11 deletions src/Hydra/JsonSchema/SchemaFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ public function __construct(BaseSchemaFactory $schemaFactory)
/**
* {@inheritdoc}
*/
public function buildSchema(string $resourceClass, string $format = 'jsonld', bool $output = true, ?string $operationType = null, ?string $operationName = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema
public function buildSchema(string $resourceClass, string $format = 'jsonld', string $type = Schema::TYPE_OUTPUT, ?string $operationType = null, ?string $operationName = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema
{
$schema = $this->schemaFactory->buildSchema($resourceClass, $format, $output, $operationType, $operationName, $schema, $serializerContext, $forceCollection);
$schema = $this->schemaFactory->buildSchema($resourceClass, $format, $type, $operationType, $operationName, $schema, $serializerContext, $forceCollection);
if ('jsonld' !== $format) {
return $schema;
}
Expand All @@ -74,24 +74,29 @@ public function buildSchema(string $resourceClass, string $format = 'jsonld', bo
],
'hydra:totalItems' => [
'type' => 'integer',
'minimum' => 1,
'minimum' => 0,
],
'hydra:view' => [
'type' => 'object',
'properties' => [
'@id' => ['type' => 'string'],
'@type' => ['type' => 'string'],
'@id' => [
'type' => 'string',
'format' => 'iri-reference',
],
'@type' => [
'type' => 'string',
],
'hydra:first' => [
'type' => 'integer',
'minimum' => 1,
'type' => 'string',
'format' => 'iri-reference',
],
'hydra:last' => [
'type' => 'integer',
'minimum' => 1,
'type' => 'string',
'format' => 'iri-reference',
],
'hydra:next' => [
'type' => 'integer',
'minimum' => 1,
'type' => 'string',
'format' => 'iri-reference',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't aware of the existence of iri-reference! We should use it everywhere we know that an IRI will be generated!

Copy link
Contributor

@teohhanhui teohhanhui Sep 19, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dunglas Do you have any in mind? (We could do it in another PR.)

Copy link
Member

@soyuka soyuka Sep 19, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

he means in the rest of the core I guess (tests?), so def another pr!

],
],
],
Expand All @@ -116,6 +121,9 @@ public function buildSchema(string $resourceClass, string $format = 'jsonld', bo
],
],
];
$schema['required'] = [
'hydra:member',
];

return $schema;
}
Expand Down
15 changes: 8 additions & 7 deletions src/JsonSchema/Command/JsonSchemaGenerateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace ApiPlatform\Core\JsonSchema\Command;

use ApiPlatform\Core\Api\OperationType;
use ApiPlatform\Core\JsonSchema\Schema;
use ApiPlatform\Core\JsonSchema\SchemaFactoryInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidOptionException;
Expand Down Expand Up @@ -53,7 +54,7 @@ protected function configure()
->addOption('itemOperation', null, InputOption::VALUE_REQUIRED, 'The item operation')
->addOption('collectionOperation', null, InputOption::VALUE_REQUIRED, 'The collection operation')
->addOption('format', null, InputOption::VALUE_REQUIRED, 'The response format', (string) $this->formats[0])
->addOption('type', null, InputOption::VALUE_REQUIRED, 'The type of schema to generate (input or output)', 'input');
->addOption('type', null, InputOption::VALUE_REQUIRED, sprintf('The type of schema to generate (%s or %s)', Schema::TYPE_INPUT, Schema::TYPE_OUTPUT), Schema::TYPE_INPUT);
}

/**
Expand All @@ -71,11 +72,11 @@ protected function execute(InputInterface $input, OutputInterface $output)
$collectionOperation = $input->getOption('collectionOperation');
/** @var string $format */
$format = $input->getOption('format');
/** @var string $outputType */
$outputType = $input->getOption('type');
/** @var string $type */
$type = $input->getOption('type');

if (!\in_array($outputType, ['input', 'output'], true)) {
$io->error('You can only use "input" or "output" for the "type" option');
if (!\in_array($type, [Schema::TYPE_INPUT, Schema::TYPE_OUTPUT], true)) {
$io->error(sprintf('You can only use "%s" or "%s" for the "type" option', Schema::TYPE_INPUT, Schema::TYPE_OUTPUT));

return 1;
}
Expand All @@ -100,10 +101,10 @@ protected function execute(InputInterface $input, OutputInterface $output)
$operationName = $itemOperation ?? $collectionOperation;
}

$schema = $this->schemaFactory->buildSchema($resource, $format, 'output' === $outputType, $operationType, $operationName);
$schema = $this->schemaFactory->buildSchema($resource, $format, $type, $operationType, $operationName);

if (null !== $operationType && null !== $operationName && !$schema->isDefined()) {
$io->error(sprintf('There is no %ss defined for the operation "%s" of the resource "%s".', $outputType, $operationName, $resource));
$io->error(sprintf('There is no %s defined for the operation "%s" of the resource "%s".', $type, $operationName, $resource));

return 1;
}
Expand Down
6 changes: 5 additions & 1 deletion src/JsonSchema/Schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@
*/
final class Schema extends \ArrayObject
{
public const TYPE_INPUT = 'input';
public const TYPE_OUTPUT = 'output';
public const VERSION_JSON_SCHEMA = 'json-schema';
public const VERSION_SWAGGER = 'swagger';
public const VERSION_OPENAPI = 'openapi';
public const VERSION_SWAGGER = 'swagger';

private $version;

Expand All @@ -49,6 +51,8 @@ public function getVersion(): string
}

/**
* {@inheritdoc}
*
* @param bool $includeDefinitions if set to false, definitions will not be included in the resulting array
*/
public function getArrayCopy(bool $includeDefinitions = true): array
Expand Down
22 changes: 11 additions & 11 deletions src/JsonSchema/SchemaFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,20 @@ public function addDistinctFormat(string $format): void
/**
* {@inheritdoc}
*/
public function buildSchema(string $resourceClass, string $format = 'json', bool $output = true, ?string $operationType = null, ?string $operationName = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema
public function buildSchema(string $resourceClass, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?string $operationType = null, ?string $operationName = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema
{
$schema = $schema ?? new Schema();
if (null === $metadata = $this->getMetadata($resourceClass, $output, $operationType, $operationName, $serializerContext)) {
if (null === $metadata = $this->getMetadata($resourceClass, $type, $operationType, $operationName, $serializerContext)) {
return $schema;
}
[$resourceMetadata, $serializerContext, $inputOrOutputClass] = $metadata;

$version = $schema->getVersion();
$definitionName = $this->buildDefinitionName($resourceClass, $format, $output, $operationType, $operationName, $serializerContext);
$definitionName = $this->buildDefinitionName($resourceClass, $format, $type, $operationType, $operationName, $serializerContext);

$method = (null !== $operationType && null !== $operationName) ? $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'method') : 'GET';

if (!$output && !\in_array($method, ['POST', 'PATCH', 'PUT'], true)) {
if (Schema::TYPE_OUTPUT !== $type && !\in_array($method, ['POST', 'PATCH', 'PUT'], true)) {
return $schema;
}

Expand Down Expand Up @@ -196,9 +196,9 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str
$schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = $propertySchema;
}

private function buildDefinitionName(string $resourceClass, string $format = 'json', bool $output = true, ?string $operationType = null, ?string $operationName = null, ?array $serializerContext = null): string
private function buildDefinitionName(string $resourceClass, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?string $operationType = null, ?string $operationName = null, ?array $serializerContext = null): string
{
[$resourceMetadata, $serializerContext, $inputOrOutputClass] = $this->getMetadata($resourceClass, $output, $operationType, $operationName, $serializerContext);
[$resourceMetadata, $serializerContext, $inputOrOutputClass] = $this->getMetadata($resourceClass, $type, $operationType, $operationName, $serializerContext);

$prefix = $resourceMetadata->getShortName();
if (null !== $inputOrOutputClass && $resourceClass !== $inputOrOutputClass) {
Expand All @@ -220,10 +220,10 @@ private function buildDefinitionName(string $resourceClass, string $format = 'js
return $name;
}

private function getMetadata(string $resourceClass, bool $output, ?string $operationType, ?string $operationName, ?array $serializerContext): ?array
private function getMetadata(string $resourceClass, string $type = Schema::TYPE_OUTPUT, ?string $operationType, ?string $operationName, ?array $serializerContext): ?array
{
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
$attribute = $output ? 'output' : 'input';
$attribute = Schema::TYPE_OUTPUT === $type ? 'output' : 'input';
if (null === $operationType || null === $operationName) {
$inputOrOutput = $resourceMetadata->getAttribute($attribute, ['class' => $resourceClass]);
} else {
Expand All @@ -237,14 +237,14 @@ private function getMetadata(string $resourceClass, bool $output, ?string $opera

return [
$resourceMetadata,
$serializerContext ?? $this->getSerializerContext($resourceMetadata, $output, $operationType, $operationName),
$serializerContext ?? $this->getSerializerContext($resourceMetadata, $type, $operationType, $operationName),
$inputOrOutput['class'],
];
}

private function getSerializerContext(ResourceMetadata $resourceMetadata, bool $output, ?string $operationType, ?string $operationName): array
private function getSerializerContext(ResourceMetadata $resourceMetadata, string $type = Schema::TYPE_OUTPUT, ?string $operationType, ?string $operationName): array
{
$attribute = $output ? 'normalization_context' : 'denormalization_context';
$attribute = Schema::TYPE_OUTPUT === $type ? 'normalization_context' : 'denormalization_context';

if (null === $operationType || null === $operationName) {
return $resourceMetadata->getAttribute($attribute, []);
Expand Down
Loading