Skip to content

Commit 4bdb552

Browse files
Add assertions for testing response against JSON Schema from API resource
Co-authored-by: Teoh Han Hui <[email protected]>
1 parent 203341a commit 4bdb552

File tree

15 files changed

+148
-60
lines changed

15 files changed

+148
-60
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"friendsofsymfony/user-bundle": "^2.2@dev",
4444
"guzzlehttp/guzzle": "^6.0",
4545
"jangregor/phpstan-prophecy": "^0.4.2",
46-
"justinrainbow/json-schema": "^5.0",
46+
"justinrainbow/json-schema": "^5.2",
4747
"nelmio/api-doc-bundle": "^2.13.4",
4848
"phpdocumentor/reflection-docblock": "^3.0 || ^4.0",
4949
"phpdocumentor/type-resolver": "^0.3 || ^0.4",

phpstan.neon.dist

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ parameters:
5454
-
5555
message: '#Parameter \#1 \$docblock of method phpDocumentor\\Reflection\\DocBlockFactoryInterface::create\(\) expects string, ReflectionClass given\.#'
5656
path: %currentWorkingDirectory%/src/Metadata/Resource/Factory/PhpDocResourceMetadataFactory.php
57+
-
58+
message: '#Parameter \#1 \$objectValue of method GraphQL\\Type\\Definition\\InterfaceType::resolveType\(\) expects object, array(<string, string>)? given.#'
59+
path: %currentWorkingDirectory%/tests/GraphQl/Type/TypeBuilderTest.php
5760
-
5861
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>\.#'
5962
path: %currentWorkingDirectory%/src/Test/DoctrineMongoDbOdmFilterTestCase.php
@@ -78,7 +81,10 @@ parameters:
7881
-
7982
message: '#Binary operation "\+" between (float\|int\|)?string and 0 results in an error\.#'
8083
path: %currentWorkingDirectory%/src/Bridge/Doctrine/Common/Filter/RangeFilterTrait.php
81-
- '#Parameter \#1 \$objectValue of method GraphQL\\Type\\Definition\\InterfaceType::resolveType\(\) expects object, array(<string, string>)? given.#'
84+
# https://github.com/phpstan/phpstan-symfony/issues/27
85+
-
86+
message: '#Service "api_platform\.json_schema\.schema_factory" is private\.#'
87+
path: %currentWorkingDirectory%/src/Bridge/Symfony/Bundle/Test/ApiTestAssertionsTrait.php
8288

8389
# Expected, due to optional interfaces
8490
- '#Method ApiPlatform\\Core\\Bridge\\Doctrine\\Orm\\Extension\\QueryCollectionExtensionInterface::applyToCollection\(\) invoked with 5 parameters, 3-4 required\.#'

src/Bridge/Symfony/Bundle/Test/ApiTestAssertionsTrait.php

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@
1313

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

16+
use ApiPlatform\Core\Api\OperationType;
1617
use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\Constraint\ArraySubset;
1718
use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\Constraint\MatchesJsonSchema;
19+
use ApiPlatform\Core\JsonSchema\Schema;
20+
use ApiPlatform\Core\JsonSchema\SchemaFactoryInterface;
1821
use PHPUnit\Framework\ExpectationFailedException;
22+
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
1923
use Symfony\Contracts\HttpClient\ResponseInterface;
2024

2125
/**
@@ -28,9 +32,9 @@ trait ApiTestAssertionsTrait
2832
use BrowserKitAssertionsTrait;
2933

3034
/**
31-
* Asserts that the retrieved JSON contains has the specified subset.
35+
* Asserts that the retrieved JSON contains the specified subset.
3236
*
33-
* This method delegates to self::assertArraySubset().
37+
* This method delegates to static::assertArraySubset().
3438
*
3539
* @param array|string $subset
3640
*
@@ -91,6 +95,7 @@ public static function assertJsonEquals($json, string $message = ''): void
9195
public static function assertArraySubset($subset, $array, bool $checkForObjectIdentity = false, string $message = ''): void
9296
{
9397
$constraint = new ArraySubset($subset, $checkForObjectIdentity);
98+
9499
static::assertThat($array, $constraint, $message);
95100
}
96101

@@ -100,9 +105,24 @@ public static function assertArraySubset($subset, $array, bool $checkForObjectId
100105
public static function assertMatchesJsonSchema($jsonSchema, ?int $checkMode = null, string $message = ''): void
101106
{
102107
$constraint = new MatchesJsonSchema($jsonSchema, $checkMode);
108+
103109
static::assertThat(self::getHttpResponse()->toArray(false), $constraint, $message);
104110
}
105111

112+
public static function assertMatchesResourceCollectionJsonSchema(string $resourceClass, ?string $operationName = null, string $format = 'jsonld'): void
113+
{
114+
$schema = self::getSchemaFactory()->buildSchema($resourceClass, $format, Schema::TYPE_OUTPUT, OperationType::COLLECTION, $operationName);
115+
116+
static::assertMatchesJsonSchema((string) json_encode($schema));
117+
}
118+
119+
public static function assertMatchesResourceItemJsonSchema(string $resourceClass, ?string $operationName = null, string $format = 'jsonld'): void
120+
{
121+
$schema = self::getSchemaFactory()->buildSchema($resourceClass, $format, Schema::TYPE_OUTPUT, OperationType::ITEM, $operationName);
122+
123+
static::assertMatchesJsonSchema((string) json_encode($schema));
124+
}
125+
106126
private static function getHttpClient(Client $newClient = null): ?Client
107127
{
108128
static $client;
@@ -126,4 +146,16 @@ private static function getHttpResponse(): ResponseInterface
126146

127147
return $response;
128148
}
149+
150+
private static function getSchemaFactory(): SchemaFactoryInterface
151+
{
152+
try {
153+
/** @var SchemaFactoryInterface $schemaFactory */
154+
$schemaFactory = static::$container->get('api_platform.json_schema.schema_factory');
155+
} catch (ServiceNotFoundException $e) {
156+
throw new \LogicException('You cannot use the resource JSON Schema assertions if the "api_platform.swagger.versions" config is null or empty.');
157+
}
158+
159+
return $schemaFactory;
160+
}
129161
}

src/Bridge/Symfony/Bundle/Test/Constraint/MatchesJsonSchema.php

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Core\Bridge\Symfony\Bundle\Test\Constraint;
1515

16+
use ApiPlatform\Core\JsonSchema\Schema;
1617
use JsonSchema\Validator;
1718
use PHPUnit\Framework\Constraint\Constraint;
1819

@@ -33,8 +34,8 @@ final class MatchesJsonSchema extends Constraint
3334
*/
3435
public function __construct($schema, ?int $checkMode = null)
3536
{
36-
$this->checkMode = $checkMode;
3737
$this->schema = \is_array($schema) ? (object) $schema : json_decode($schema);
38+
$this->checkMode = $checkMode;
3839
}
3940

4041
/**
@@ -46,15 +47,15 @@ public function toString(): string
4647
}
4748

4849
/**
49-
* @param array $other
50+
* @param array|object $other
5051
*/
5152
protected function matches($other): bool
5253
{
5354
if (!class_exists(Validator::class)) {
5455
throw new \RuntimeException('The "justinrainbow/json-schema" library must be installed to use "assertMatchesJsonSchema()". Try running "composer require --dev justinrainbow/json-schema".');
5556
}
5657

57-
$other = (object) $other;
58+
$other = $this->canonicalizeJson($other);
5859

5960
$validator = new Validator();
6061
$validator->validate($other, $this->schema, $this->checkMode);
@@ -63,11 +64,11 @@ protected function matches($other): bool
6364
}
6465

6566
/**
66-
* @param object $other
67+
* @param array|object $other
6768
*/
6869
protected function additionalFailureDescription($other): string
6970
{
70-
$other = (object) $other;
71+
$other = $this->canonicalizeJson($other);
7172

7273
$validator = new Validator();
7374
$validator->check($other, $this->schema);
@@ -80,4 +81,25 @@ protected function additionalFailureDescription($other): string
8081

8182
return implode("\n", $errors);
8283
}
84+
85+
/**
86+
* @param array|object $data A representation of a JSON array or JSON object
87+
*
88+
* @return array|object An array if data is a JSON array, or an object if data is a JSON object
89+
*/
90+
private function canonicalizeJson($data)
91+
{
92+
if (!(\is_array($data) || \is_object($data))) {
93+
throw new \InvalidArgumentException('Data must be array or object.');
94+
}
95+
96+
$data = (string) json_encode($data);
97+
$data = json_decode($data);
98+
99+
if (!(\is_array($data) || \is_object($data))) {
100+
throw new \UnexpectedValueException('JSON encoding / decoding failed.');
101+
}
102+
103+
return $data;
104+
}
83105
}

src/Hydra/JsonSchema/SchemaFactory.php

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ public function __construct(BaseSchemaFactory $schemaFactory)
4747
/**
4848
* {@inheritdoc}
4949
*/
50-
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
50+
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
5151
{
52-
$schema = $this->schemaFactory->buildSchema($resourceClass, $format, $output, $operationType, $operationName, $schema, $serializerContext, $forceCollection);
52+
$schema = $this->schemaFactory->buildSchema($resourceClass, $format, $type, $operationType, $operationName, $schema, $serializerContext, $forceCollection);
5353
if ('jsonld' !== $format) {
5454
return $schema;
5555
}
@@ -74,24 +74,29 @@ public function buildSchema(string $resourceClass, string $format = 'jsonld', bo
7474
],
7575
'hydra:totalItems' => [
7676
'type' => 'integer',
77-
'minimum' => 1,
77+
'minimum' => 0,
7878
],
7979
'hydra:view' => [
8080
'type' => 'object',
8181
'properties' => [
82-
'@id' => ['type' => 'string'],
83-
'@type' => ['type' => 'string'],
82+
'@id' => [
83+
'type' => 'string',
84+
'format' => 'iri-reference',
85+
],
86+
'@type' => [
87+
'type' => 'string',
88+
],
8489
'hydra:first' => [
85-
'type' => 'integer',
86-
'minimum' => 1,
90+
'type' => 'string',
91+
'format' => 'iri-reference',
8792
],
8893
'hydra:last' => [
89-
'type' => 'integer',
90-
'minimum' => 1,
94+
'type' => 'string',
95+
'format' => 'iri-reference',
9196
],
9297
'hydra:next' => [
93-
'type' => 'integer',
94-
'minimum' => 1,
98+
'type' => 'string',
99+
'format' => 'iri-reference',
95100
],
96101
],
97102
],
@@ -116,6 +121,9 @@ public function buildSchema(string $resourceClass, string $format = 'jsonld', bo
116121
],
117122
],
118123
];
124+
$schema['required'] = [
125+
'hydra:member',
126+
];
119127

120128
return $schema;
121129
}

src/JsonSchema/Command/JsonSchemaGenerateCommand.php

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Core\JsonSchema\Command;
1515

1616
use ApiPlatform\Core\Api\OperationType;
17+
use ApiPlatform\Core\JsonSchema\Schema;
1718
use ApiPlatform\Core\JsonSchema\SchemaFactoryInterface;
1819
use Symfony\Component\Console\Command\Command;
1920
use Symfony\Component\Console\Exception\InvalidOptionException;
@@ -53,7 +54,7 @@ protected function configure()
5354
->addOption('itemOperation', null, InputOption::VALUE_REQUIRED, 'The item operation')
5455
->addOption('collectionOperation', null, InputOption::VALUE_REQUIRED, 'The collection operation')
5556
->addOption('format', null, InputOption::VALUE_REQUIRED, 'The response format', (string) $this->formats[0])
56-
->addOption('type', null, InputOption::VALUE_REQUIRED, 'The type of schema to generate (input or output)', 'input');
57+
->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);
5758
}
5859

5960
/**
@@ -71,11 +72,11 @@ protected function execute(InputInterface $input, OutputInterface $output)
7172
$collectionOperation = $input->getOption('collectionOperation');
7273
/** @var string $format */
7374
$format = $input->getOption('format');
74-
/** @var string $outputType */
75-
$outputType = $input->getOption('type');
75+
/** @var string $type */
76+
$type = $input->getOption('type');
7677

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

8081
return 1;
8182
}
@@ -100,10 +101,10 @@ protected function execute(InputInterface $input, OutputInterface $output)
100101
$operationName = $itemOperation ?? $collectionOperation;
101102
}
102103

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

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

108109
return 1;
109110
}

src/JsonSchema/Schema.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@
2727
*/
2828
final class Schema extends \ArrayObject
2929
{
30+
public const TYPE_INPUT = 'input';
31+
public const TYPE_OUTPUT = 'output';
3032
public const VERSION_JSON_SCHEMA = 'json-schema';
31-
public const VERSION_SWAGGER = 'swagger';
3233
public const VERSION_OPENAPI = 'openapi';
34+
public const VERSION_SWAGGER = 'swagger';
3335

3436
private $version;
3537

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

5153
/**
54+
* {@inheritdoc}
55+
*
5256
* @param bool $includeDefinitions if set to false, definitions will not be included in the resulting array
5357
*/
5458
public function getArrayCopy(bool $includeDefinitions = true): array

src/JsonSchema/SchemaFactory.php

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,20 +62,20 @@ public function addDistinctFormat(string $format): void
6262
/**
6363
* {@inheritdoc}
6464
*/
65-
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
65+
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
6666
{
6767
$schema = $schema ?? new Schema();
68-
if (null === $metadata = $this->getMetadata($resourceClass, $output, $operationType, $operationName, $serializerContext)) {
68+
if (null === $metadata = $this->getMetadata($resourceClass, $type, $operationType, $operationName, $serializerContext)) {
6969
return $schema;
7070
}
7171
[$resourceMetadata, $serializerContext, $inputOrOutputClass] = $metadata;
7272

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

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

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

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

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

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

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

238238
return [
239239
$resourceMetadata,
240-
$serializerContext ?? $this->getSerializerContext($resourceMetadata, $output, $operationType, $operationName),
240+
$serializerContext ?? $this->getSerializerContext($resourceMetadata, $type, $operationType, $operationName),
241241
$inputOrOutput['class'],
242242
];
243243
}
244244

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

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

src/JsonSchema/SchemaFactoryInterface.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,5 @@ interface SchemaFactoryInterface
2727
/**
2828
* @throws ResourceClassNotFoundException
2929
*/
30-
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;
30+
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;
3131
}

0 commit comments

Comments
 (0)