Skip to content

Commit bba5ffa

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

File tree

15 files changed

+136
-60
lines changed

15 files changed

+136
-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.3",
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
@@ -57,6 +57,9 @@ parameters:
5757
-
5858
message: '#Parameter \#1 \$docblock of method phpDocumentor\\Reflection\\DocBlockFactoryInterface::create\(\) expects string, ReflectionClass given\.#'
5959
path: %currentWorkingDirectory%/src/Metadata/Resource/Factory/PhpDocResourceMetadataFactory.php
60+
-
61+
message: '#Parameter \#1 \$objectValue of method GraphQL\\Type\\Definition\\InterfaceType::resolveType\(\) expects object, array(<string, string>)? given.#'
62+
path: %currentWorkingDirectory%/tests/GraphQl/Type/TypeBuilderTest.php
6063
-
6164
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>\.#'
6265
path: %currentWorkingDirectory%/src/Test/DoctrineMongoDbOdmFilterTestCase.php
@@ -81,7 +84,10 @@ parameters:
8184
-
8285
message: '#Binary operation "\+" between (float\|int\|)?string and 0 results in an error\.#'
8386
path: %currentWorkingDirectory%/src/Bridge/Doctrine/Common/Filter/RangeFilterTrait.php
84-
- '#Parameter \#1 \$objectValue of method GraphQL\\Type\\Definition\\InterfaceType::resolveType\(\) expects object, array(<string, string>)? given.#'
87+
# https://github.com/phpstan/phpstan-symfony/issues/27
88+
-
89+
message: '#Service "api_platform\.json_schema\.schema_factory" is private\.#'
90+
path: %currentWorkingDirectory%/src/Bridge/Symfony/Bundle/Test/ApiTestAssertionsTrait.php
8591

8692
# Expected, due to optional interfaces
8793
- '#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: 36 additions & 1 deletion
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 Psr\Container\ContainerInterface;
1923
use Symfony\Contracts\HttpClient\ResponseInterface;
2024

2125
/**
@@ -91,18 +95,34 @@ 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

97102
/**
98-
* @param array|string $jsonSchema
103+
* @param Schema|array|string $jsonSchema
99104
*/
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($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($schema);
124+
}
125+
106126
private static function getHttpClient(Client $newClient = null): ?Client
107127
{
108128
static $client;
@@ -126,4 +146,19 @@ private static function getHttpResponse(): ResponseInterface
126146

127147
return $response;
128148
}
149+
150+
private static function getSchemaFactory(): SchemaFactoryInterface
151+
{
152+
static $schemaFactory;
153+
154+
if (null !== $schemaFactory) {
155+
return $schemaFactory;
156+
}
157+
158+
if (!isset(static::$container) || !static::$container instanceof ContainerInterface || !static::$container->has('api_platform.json_schema.schema_factory')) {
159+
throw new \LogicException(sprintf('You cannot use the resource JSON Schema assertions because the "%s" service cannot be fetched from the container.', 'api_platform.json_schema.schema_factory'));
160+
}
161+
162+
return $schemaFactory = static::$container->get('api_platform.json_schema.schema_factory');
163+
}
129164
}

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

Lines changed: 15 additions & 6 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

@@ -29,12 +30,16 @@ final class MatchesJsonSchema extends Constraint
2930
private $checkMode;
3031

3132
/**
32-
* @param array|string $schema
33+
* @param Schema|array|string $schema
3334
*/
3435
public function __construct($schema, ?int $checkMode = null)
3536
{
37+
/** @var array|string $schema */
38+
$schema = $schema instanceof Schema ? json_encode($schema) : $schema;
39+
$schema = \is_array($schema) ? (object) $schema : json_decode($schema);
40+
3641
$this->checkMode = $checkMode;
37-
$this->schema = \is_array($schema) ? (object) $schema : json_decode($schema);
42+
$this->schema = $schema;
3843
}
3944

4045
/**
@@ -46,15 +51,17 @@ public function toString(): string
4651
}
4752

4853
/**
49-
* @param array $other
54+
* @param array|object $other
5055
*/
5156
protected function matches($other): bool
5257
{
5358
if (!class_exists(Validator::class)) {
5459
throw new \RuntimeException('The "justinrainbow/json-schema" library must be installed to use "assertMatchesJsonSchema()". Try running "composer require --dev justinrainbow/json-schema".');
5560
}
5661

57-
$other = (object) $other;
62+
/** @var string $other */
63+
$other = json_encode($other);
64+
$other = json_decode($other);
5865

5966
$validator = new Validator();
6067
$validator->validate($other, $this->schema, $this->checkMode);
@@ -63,11 +70,13 @@ protected function matches($other): bool
6370
}
6471

6572
/**
66-
* @param object $other
73+
* @param array|object $other
6774
*/
6875
protected function additionalFailureDescription($other): string
6976
{
70-
$other = (object) $other;
77+
/** @var string $other */
78+
$other = json_encode($other);
79+
$other = json_decode($other);
7180

7281
$validator = new Validator();
7382
$validator->check($other, $this->schema);

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: 3 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

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
}

src/JsonSchema/TypeFactory.php

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,9 @@ private function getClassType(?string $className, string $format = 'json', ?bool
8282
$version = $schema->getVersion();
8383

8484
$subSchema = new Schema($version);
85-
/*
86-
* @var Schema $schema Prevents a false positive in PHPStan
87-
*/
8885
$subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema
8986

90-
$this->schemaFactory->buildSchema($className, $format, true, null, null, $subSchema, $serializerContext);
87+
$this->schemaFactory->buildSchema($className, $format, Schema::TYPE_OUTPUT, null, null, $subSchema, $serializerContext);
9188

9289
return ['$ref' => $subSchema['$ref']];
9390
}

0 commit comments

Comments
 (0)