├── .gitignore ├── Command └── OpenApiCommand.php ├── Factory ├── OpenApiFactory.php └── OpenApiFactoryInterface.php ├── LICENSE ├── Model ├── Components.php ├── Contact.php ├── Encoding.php ├── Example.php ├── ExtensionTrait.php ├── ExternalDocumentation.php ├── Header.php ├── Info.php ├── License.php ├── Link.php ├── MediaType.php ├── OAuthFlow.php ├── OAuthFlows.php ├── Operation.php ├── Parameter.php ├── PathItem.php ├── Paths.php ├── Reference.php ├── RequestBody.php ├── Response.php ├── Schema.php ├── SecurityScheme.php └── Server.php ├── OpenApi.php ├── Options.php ├── README.md ├── Serializer ├── ApiGatewayNormalizer.php ├── CacheableSupportsMethodInterface.php ├── NormalizeOperationNameTrait.php └── OpenApiNormalizer.php ├── Tests ├── Factory │ └── OpenApiFactoryTest.php ├── Fixtures │ ├── Dummy.php │ ├── DummyFilter.php │ ├── OutputDto.php │ └── RelatedDummy.php └── Serializer │ ├── ApiGatewayNormalizerTest.php │ └── OpenApiNormalizerTest.php ├── composer.json └── phpunit.xml.dist /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /vendor 3 | /.phpunit.result.cache 4 | -------------------------------------------------------------------------------- /Command/OpenApiCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Command; 15 | 16 | use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; 17 | use Symfony\Component\Console\Command\Command; 18 | use Symfony\Component\Console\Input\InputInterface; 19 | use Symfony\Component\Console\Input\InputOption; 20 | use Symfony\Component\Console\Output\OutputInterface; 21 | use Symfony\Component\Console\Style\SymfonyStyle; 22 | use Symfony\Component\Filesystem\Filesystem; 23 | use Symfony\Component\Serializer\Normalizer\NormalizerInterface; 24 | use Symfony\Component\Yaml\Yaml; 25 | 26 | /** 27 | * Dumps Open API documentation. 28 | */ 29 | final class OpenApiCommand extends Command 30 | { 31 | public function __construct(private readonly OpenApiFactoryInterface $openApiFactory, private readonly NormalizerInterface $normalizer) 32 | { 33 | parent::__construct(); 34 | } 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | protected function configure(): void 40 | { 41 | $this 42 | ->setDescription('Dump the Open API documentation') 43 | ->addOption('yaml', 'y', InputOption::VALUE_NONE, 'Dump the documentation in YAML') 44 | ->addOption('output', 'o', InputOption::VALUE_OPTIONAL, 'Write output to file') 45 | ->addOption('spec-version', null, InputOption::VALUE_OPTIONAL, 'Open API version to use (2 or 3) (2 is deprecated)', '3') 46 | ->addOption('api-gateway', null, InputOption::VALUE_NONE, 'Enable the Amazon API Gateway compatibility mode'); 47 | } 48 | 49 | /** 50 | * {@inheritdoc} 51 | */ 52 | protected function execute(InputInterface $input, OutputInterface $output): int 53 | { 54 | $filesystem = new Filesystem(); 55 | $io = new SymfonyStyle($input, $output); 56 | $data = $this->normalizer->normalize($this->openApiFactory->__invoke(), 'json'); 57 | $content = $input->getOption('yaml') 58 | ? Yaml::dump($data, 10, 2, Yaml::DUMP_OBJECT_AS_MAP | Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK) 59 | : (json_encode($data, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES) ?: ''); 60 | 61 | $filename = $input->getOption('output'); 62 | if ($filename && \is_string($filename)) { 63 | $filesystem->dumpFile($filename, $content); 64 | $io->success(sprintf('Data written to %s.', $filename)); 65 | 66 | return \defined(Command::class.'::SUCCESS') ? Command::SUCCESS : 0; 67 | } 68 | 69 | $output->writeln($content); 70 | 71 | return \defined(Command::class.'::SUCCESS') ? Command::SUCCESS : 0; 72 | } 73 | 74 | public static function getDefaultName(): string 75 | { 76 | return 'api:openapi:export'; 77 | } 78 | } 79 | 80 | class_alias(OpenApiCommand::class, \ApiPlatform\Symfony\Bundle\Command\OpenApiCommand::class); 81 | -------------------------------------------------------------------------------- /Factory/OpenApiFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Factory; 15 | 16 | use ApiPlatform\Doctrine\Orm\State\Options as DoctrineOptions; 17 | use ApiPlatform\JsonSchema\Schema; 18 | use ApiPlatform\JsonSchema\SchemaFactoryInterface; 19 | use ApiPlatform\JsonSchema\TypeFactoryInterface; 20 | use ApiPlatform\Metadata\ApiResource; 21 | use ApiPlatform\Metadata\CollectionOperationInterface; 22 | use ApiPlatform\Metadata\HttpOperation; 23 | use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; 24 | use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; 25 | use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; 26 | use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; 27 | use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; 28 | use ApiPlatform\OpenApi\Model; 29 | use ApiPlatform\OpenApi\Model\Components; 30 | use ApiPlatform\OpenApi\Model\Contact; 31 | use ApiPlatform\OpenApi\Model\ExternalDocumentation; 32 | use ApiPlatform\OpenApi\Model\Info; 33 | use ApiPlatform\OpenApi\Model\License; 34 | use ApiPlatform\OpenApi\Model\Link; 35 | use ApiPlatform\OpenApi\Model\MediaType; 36 | use ApiPlatform\OpenApi\Model\OAuthFlow; 37 | use ApiPlatform\OpenApi\Model\OAuthFlows; 38 | use ApiPlatform\OpenApi\Model\Parameter; 39 | use ApiPlatform\OpenApi\Model\PathItem; 40 | use ApiPlatform\OpenApi\Model\Paths; 41 | use ApiPlatform\OpenApi\Model\RequestBody; 42 | use ApiPlatform\OpenApi\Model\Response; 43 | use ApiPlatform\OpenApi\Model\SecurityScheme; 44 | use ApiPlatform\OpenApi\Model\Server; 45 | use ApiPlatform\OpenApi\OpenApi; 46 | use ApiPlatform\OpenApi\Options; 47 | use ApiPlatform\OpenApi\Serializer\NormalizeOperationNameTrait; 48 | use ApiPlatform\State\Pagination\PaginationOptions; 49 | use Psr\Container\ContainerInterface; 50 | use Symfony\Component\PropertyInfo\Type; 51 | use Symfony\Component\Routing\RouteCollection; 52 | use Symfony\Component\Routing\RouterInterface; 53 | 54 | /** 55 | * Generates an Open API v3 specification. 56 | */ 57 | final class OpenApiFactory implements OpenApiFactoryInterface 58 | { 59 | use NormalizeOperationNameTrait; 60 | 61 | public const BASE_URL = 'base_url'; 62 | private readonly Options $openApiOptions; 63 | private readonly PaginationOptions $paginationOptions; 64 | private ?RouteCollection $routeCollection = null; 65 | private ?ContainerInterface $filterLocator = null; 66 | 67 | /** 68 | * @deprecated use SchemaFactory::OPENAPI_DEFINITION_NAME this will be removed in API Platform 4 69 | */ 70 | public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; 71 | 72 | public function __construct(private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly SchemaFactoryInterface $jsonSchemaFactory, private readonly TypeFactoryInterface $jsonSchemaTypeFactory, ContainerInterface $filterLocator, private readonly array $formats = [], ?Options $openApiOptions = null, ?PaginationOptions $paginationOptions = null, private readonly ?RouterInterface $router = null) 73 | { 74 | $this->filterLocator = $filterLocator; 75 | $this->openApiOptions = $openApiOptions ?: new Options('API Platform'); 76 | $this->paginationOptions = $paginationOptions ?: new PaginationOptions(); 77 | } 78 | 79 | /** 80 | * {@inheritdoc} 81 | */ 82 | public function __invoke(array $context = []): OpenApi 83 | { 84 | $baseUrl = $context[self::BASE_URL] ?? '/'; 85 | $contact = null === $this->openApiOptions->getContactUrl() || null === $this->openApiOptions->getContactEmail() ? null : new Contact($this->openApiOptions->getContactName(), $this->openApiOptions->getContactUrl(), $this->openApiOptions->getContactEmail()); 86 | $license = null === $this->openApiOptions->getLicenseName() ? null : new License($this->openApiOptions->getLicenseName(), $this->openApiOptions->getLicenseUrl()); 87 | $info = new Info($this->openApiOptions->getTitle(), $this->openApiOptions->getVersion(), trim($this->openApiOptions->getDescription()), $this->openApiOptions->getTermsOfService(), $contact, $license); 88 | $servers = '/' === $baseUrl || '' === $baseUrl ? [new Server('/')] : [new Server($baseUrl)]; 89 | $paths = new Paths(); 90 | $schemas = new \ArrayObject(); 91 | 92 | foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { 93 | $resourceMetadataCollection = $this->resourceMetadataFactory->create($resourceClass); 94 | 95 | foreach ($resourceMetadataCollection as $resourceMetadata) { 96 | $this->collectPaths($resourceMetadata, $resourceMetadataCollection, $paths, $schemas); 97 | } 98 | } 99 | 100 | $securitySchemes = $this->getSecuritySchemes(); 101 | $securityRequirements = []; 102 | 103 | foreach (array_keys($securitySchemes) as $key) { 104 | $securityRequirements[] = [$key => []]; 105 | } 106 | 107 | return new OpenApi( 108 | $info, 109 | $servers, 110 | $paths, 111 | new Components( 112 | $schemas, 113 | new \ArrayObject(), 114 | new \ArrayObject(), 115 | new \ArrayObject(), 116 | new \ArrayObject(), 117 | new \ArrayObject(), 118 | new \ArrayObject($securitySchemes) 119 | ), 120 | $securityRequirements 121 | ); 122 | } 123 | 124 | private function collectPaths(ApiResource $resource, ResourceMetadataCollection $resourceMetadataCollection, Paths $paths, \ArrayObject $schemas): void 125 | { 126 | if (0 === $resource->getOperations()->count()) { 127 | return; 128 | } 129 | 130 | foreach ($resource->getOperations() as $operationName => $operation) { 131 | $resourceShortName = $operation->getShortName(); 132 | // No path to return 133 | if (null === $operation->getUriTemplate() && null === $operation->getRouteName()) { 134 | continue; 135 | } 136 | 137 | $openapiOperation = $operation->getOpenapi(); 138 | 139 | // Operation ignored from OpenApi 140 | if ($operation instanceof HttpOperation && false === $openapiOperation) { 141 | continue; 142 | } 143 | 144 | $resourceClass = $operation->getClass() ?? $resource->getClass(); 145 | $routeName = $operation->getRouteName() ?? $operation->getName(); 146 | 147 | if (!$this->routeCollection && $this->router) { 148 | $this->routeCollection = $this->router->getRouteCollection(); 149 | } 150 | 151 | if ($this->routeCollection && $routeName && $route = $this->routeCollection->get($routeName)) { 152 | $path = $route->getPath(); 153 | } else { 154 | $path = ($operation->getRoutePrefix() ?? '').$operation->getUriTemplate(); 155 | } 156 | 157 | $path = $this->getPath($path); 158 | $method = $operation->getMethod() ?? HttpOperation::METHOD_GET; 159 | 160 | if (!\in_array($method, PathItem::$methods, true)) { 161 | continue; 162 | } 163 | 164 | if (!\is_object($openapiOperation)) { 165 | $openapiOperation = new Model\Operation(); 166 | } 167 | 168 | // Complete with defaults 169 | $openapiOperation = new Model\Operation( 170 | operationId: null !== $openapiOperation->getOperationId() ? $openapiOperation->getOperationId() : $this->normalizeOperationName($operationName), 171 | tags: null !== $openapiOperation->getTags() ? $openapiOperation->getTags() : [$operation->getShortName() ?: $resourceShortName], 172 | responses: null !== $openapiOperation->getResponses() ? $openapiOperation->getResponses() : [], 173 | summary: null !== $openapiOperation->getSummary() ? $openapiOperation->getSummary() : $this->getPathDescription($resourceShortName, $method, $operation instanceof CollectionOperationInterface), 174 | description: null !== $openapiOperation->getDescription() ? $openapiOperation->getDescription() : $this->getPathDescription($resourceShortName, $method, $operation instanceof CollectionOperationInterface), 175 | externalDocs: $openapiOperation->getExternalDocs(), 176 | parameters: null !== $openapiOperation->getParameters() ? $openapiOperation->getParameters() : [], 177 | requestBody: $openapiOperation->getRequestBody(), 178 | callbacks: $openapiOperation->getCallbacks(), 179 | deprecated: null !== $openapiOperation->getDeprecated() ? $openapiOperation->getDeprecated() : (bool) $operation->getDeprecationReason(), 180 | security: null !== $openapiOperation->getSecurity() ? $openapiOperation->getSecurity() : null, 181 | servers: null !== $openapiOperation->getServers() ? $openapiOperation->getServers() : null, 182 | extensionProperties: $openapiOperation->getExtensionProperties(), 183 | ); 184 | 185 | [$requestMimeTypes, $responseMimeTypes] = $this->getMimeTypes($operation); 186 | 187 | // TODO Remove in 4.0 188 | foreach (['operationId', 'tags', 'summary', 'description', 'security', 'servers'] as $key) { 189 | if (null !== ($operation->getOpenapiContext()[$key] ?? null)) { 190 | trigger_deprecation( 191 | 'api-platform/core', 192 | '3.1', 193 | 'The "openapiContext" option is deprecated, use "openapi" instead.' 194 | ); 195 | $openapiOperation = $openapiOperation->{'with'.ucfirst($key)}($operation->getOpenapiContext()[$key]); 196 | } 197 | } 198 | 199 | // TODO Remove in 4.0 200 | if (null !== ($operation->getOpenapiContext()['externalDocs'] ?? null)) { 201 | trigger_deprecation( 202 | 'api-platform/core', 203 | '3.1', 204 | 'The "openapiContext" option is deprecated, use "openapi" instead.' 205 | ); 206 | $openapiOperation = $openapiOperation->withExternalDocs(new ExternalDocumentation($operation->getOpenapiContext()['externalDocs']['description'] ?? null, $operation->getOpenapiContext()['externalDocs']['url'])); 207 | } 208 | 209 | // TODO Remove in 4.0 210 | if (null !== ($operation->getOpenapiContext()['callbacks'] ?? null)) { 211 | trigger_deprecation( 212 | 'api-platform/core', 213 | '3.1', 214 | 'The "openapiContext" option is deprecated, use "openapi" instead.' 215 | ); 216 | $openapiOperation = $openapiOperation->withCallbacks(new \ArrayObject($operation->getOpenapiContext()['callbacks'])); 217 | } 218 | 219 | // TODO Remove in 4.0 220 | if (null !== ($operation->getOpenapiContext()['deprecated'] ?? null)) { 221 | trigger_deprecation( 222 | 'api-platform/core', 223 | '3.1', 224 | 'The "openapiContext" option is deprecated, use "openapi" instead.' 225 | ); 226 | $openapiOperation = $openapiOperation->withDeprecated((bool) $operation->getOpenapiContext()['deprecated']); 227 | } 228 | 229 | if ($path) { 230 | $pathItem = $paths->getPath($path) ?: new PathItem(); 231 | } else { 232 | $pathItem = new PathItem(); 233 | } 234 | 235 | $forceSchemaCollection = $operation instanceof CollectionOperationInterface && 'GET' === $method; 236 | $schema = new Schema('openapi'); 237 | $schema->setDefinitions($schemas); 238 | 239 | $operationOutputSchemas = []; 240 | 241 | foreach ($responseMimeTypes as $operationFormat) { 242 | $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operation, $schema, null, $forceSchemaCollection); 243 | $operationOutputSchemas[$operationFormat] = $operationOutputSchema; 244 | $this->appendSchemaDefinitions($schemas, $operationOutputSchema->getDefinitions()); 245 | } 246 | 247 | // TODO Remove in 4.0 248 | if ($operation->getOpenapiContext()['parameters'] ?? false) { 249 | trigger_deprecation( 250 | 'api-platform/core', 251 | '3.1', 252 | 'The "openapiContext" option is deprecated, use "openapi" instead.' 253 | ); 254 | $parameters = []; 255 | foreach ($operation->getOpenapiContext()['parameters'] as $parameter) { 256 | $parameters[] = new Parameter($parameter['name'], $parameter['in'], $parameter['description'] ?? '', $parameter['required'] ?? false, $parameter['deprecated'] ?? false, $parameter['allowEmptyValue'] ?? false, $parameter['schema'] ?? [], $parameter['style'] ?? null, $parameter['explode'] ?? false, $parameter['allowReserved '] ?? false, $parameter['example'] ?? null, isset($parameter['examples']) ? new \ArrayObject($parameter['examples']) : null, isset($parameter['content']) ? new \ArrayObject($parameter['content']) : null); 257 | } 258 | $openapiOperation = $openapiOperation->withParameters($parameters); 259 | } 260 | 261 | // Set up parameters 262 | foreach ($operation->getUriVariables() ?? [] as $parameterName => $uriVariable) { 263 | if ($uriVariable->getExpandedValue() ?? false) { 264 | continue; 265 | } 266 | 267 | $parameter = new Parameter($parameterName, 'path', (new \ReflectionClass($uriVariable->getFromClass()))->getShortName().' identifier', true, false, false, ['type' => 'string']); 268 | if ($this->hasParameter($openapiOperation, $parameter)) { 269 | continue; 270 | } 271 | 272 | $openapiOperation = $openapiOperation->withParameter($parameter); 273 | } 274 | 275 | if ($operation instanceof CollectionOperationInterface && HttpOperation::METHOD_POST !== $method) { 276 | foreach (array_merge($this->getPaginationParameters($operation), $this->getFiltersParameters($operation)) as $parameter) { 277 | if ($this->hasParameter($openapiOperation, $parameter)) { 278 | continue; 279 | } 280 | 281 | $openapiOperation = $openapiOperation->withParameter($parameter); 282 | } 283 | } 284 | 285 | $existingResponses = $openapiOperation?->getResponses() ?: []; 286 | // Create responses 287 | switch ($method) { 288 | case HttpOperation::METHOD_GET: 289 | $successStatus = (string) $operation->getStatus() ?: 200; 290 | $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s %s', $resourceShortName, $operation instanceof CollectionOperationInterface ? 'collection' : 'resource'), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas); 291 | break; 292 | case HttpOperation::METHOD_POST: 293 | $successStatus = (string) $operation->getStatus() ?: 201; 294 | 295 | $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s resource created', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection); 296 | 297 | $openapiOperation = $this->buildOpenApiResponse($existingResponses, '400', 'Invalid input', $openapiOperation); 298 | 299 | $openapiOperation = $this->buildOpenApiResponse($existingResponses, '422', 'Unprocessable entity', $openapiOperation); 300 | break; 301 | case HttpOperation::METHOD_PATCH: 302 | case HttpOperation::METHOD_PUT: 303 | $successStatus = (string) $operation->getStatus() ?: 200; 304 | $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s resource updated', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection); 305 | $openapiOperation = $this->buildOpenApiResponse($existingResponses, '400', 'Invalid input', $openapiOperation); 306 | if (!isset($existingResponses[400])) { 307 | $openapiOperation = $openapiOperation->withResponse(400, new Response('Invalid input')); 308 | } 309 | $openapiOperation = $this->buildOpenApiResponse($existingResponses, '422', 'Unprocessable entity', $openapiOperation); 310 | break; 311 | case HttpOperation::METHOD_DELETE: 312 | $successStatus = (string) $operation->getStatus() ?: 204; 313 | 314 | $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s resource deleted', $resourceShortName), $openapiOperation); 315 | 316 | break; 317 | } 318 | 319 | if (!$operation instanceof CollectionOperationInterface && HttpOperation::METHOD_POST !== $operation->getMethod()) { 320 | if (!isset($existingResponses[404])) { 321 | $openapiOperation = $openapiOperation->withResponse(404, new Response('Resource not found')); 322 | } 323 | } 324 | 325 | if (!$openapiOperation->getResponses()) { 326 | $openapiOperation = $openapiOperation->withResponse('default', new Response('Unexpected error')); 327 | } 328 | 329 | if ($contextResponses = $operation->getOpenapiContext()['responses'] ?? false) { 330 | // TODO Remove this "elseif" in 4.0 331 | trigger_deprecation( 332 | 'api-platform/core', 333 | '3.1', 334 | 'The "openapiContext" option is deprecated, use "openapi" instead.' 335 | ); 336 | foreach ($contextResponses as $statusCode => $contextResponse) { 337 | $openapiOperation = $openapiOperation->withResponse($statusCode, new Response($contextResponse['description'] ?? '', isset($contextResponse['content']) ? new \ArrayObject($contextResponse['content']) : null, isset($contextResponse['headers']) ? new \ArrayObject($contextResponse['headers']) : null, isset($contextResponse['links']) ? new \ArrayObject($contextResponse['links']) : null)); 338 | } 339 | } 340 | 341 | if ($contextRequestBody = $operation->getOpenapiContext()['requestBody'] ?? false) { 342 | // TODO Remove this "elseif" in 4.0 343 | trigger_deprecation( 344 | 'api-platform/core', 345 | '3.1', 346 | 'The "openapiContext" option is deprecated, use "openapi" instead.' 347 | ); 348 | $openapiOperation = $openapiOperation->withRequestBody(new RequestBody($contextRequestBody['description'] ?? '', new \ArrayObject($contextRequestBody['content']), $contextRequestBody['required'] ?? false)); 349 | } elseif (null === $openapiOperation->getRequestBody() && \in_array($method, [HttpOperation::METHOD_PATCH, HttpOperation::METHOD_PUT, HttpOperation::METHOD_POST], true)) { 350 | $operationInputSchemas = []; 351 | foreach ($requestMimeTypes as $operationFormat) { 352 | $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection); 353 | $operationInputSchemas[$operationFormat] = $operationInputSchema; 354 | $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); 355 | } 356 | 357 | $openapiOperation = $openapiOperation->withRequestBody(new RequestBody(sprintf('The %s %s resource', HttpOperation::METHOD_POST === $method ? 'new' : 'updated', $resourceShortName), $this->buildContent($requestMimeTypes, $operationInputSchemas), true)); 358 | } 359 | 360 | // TODO Remove in 4.0 361 | if (null !== $operation->getOpenapiContext() && \count($operation->getOpenapiContext())) { 362 | trigger_deprecation( 363 | 'api-platform/core', 364 | '3.1', 365 | 'The "openapiContext" option is deprecated, use "openapi" instead.' 366 | ); 367 | $allowedProperties = array_map(fn (\ReflectionProperty $reflProperty): string => $reflProperty->getName(), (new \ReflectionClass(Model\Operation::class))->getProperties()); 368 | foreach ($operation->getOpenapiContext() as $key => $value) { 369 | $value = match ($key) { 370 | 'externalDocs' => new ExternalDocumentation(description: $value['description'] ?? '', url: $value['url'] ?? ''), 371 | 'requestBody' => new RequestBody(description: $value['description'] ?? '', content: isset($value['content']) ? new \ArrayObject($value['content'] ?? []) : null, required: $value['required'] ?? false), 372 | 'callbacks' => new \ArrayObject($value ?? []), 373 | 'parameters' => $openapiOperation->getParameters(), 374 | default => $value, 375 | }; 376 | 377 | if (\in_array($key, $allowedProperties, true)) { 378 | $openapiOperation = $openapiOperation->{'with'.ucfirst($key)}($value); 379 | continue; 380 | } 381 | 382 | $openapiOperation = $openapiOperation->withExtensionProperty((string) $key, $value); 383 | } 384 | } 385 | 386 | $paths->addPath($path, $pathItem->{'with'.ucfirst($method)}($openapiOperation)); 387 | } 388 | } 389 | 390 | private function buildOpenApiResponse(array $existingResponses, int|string $status, string $description, ?Model\Operation $openapiOperation = null, ?HttpOperation $operation = null, ?array $responseMimeTypes = null, ?array $operationOutputSchemas = null, ?ResourceMetadataCollection $resourceMetadataCollection = null): Model\Operation 391 | { 392 | if (isset($existingResponses[$status])) { 393 | return $openapiOperation; 394 | } 395 | $responseLinks = $responseContent = null; 396 | if ($responseMimeTypes && $operationOutputSchemas) { 397 | $responseContent = $this->buildContent($responseMimeTypes, $operationOutputSchemas); 398 | } 399 | if ($resourceMetadataCollection && $operation) { 400 | $responseLinks = $this->getLinks($resourceMetadataCollection, $operation); 401 | } 402 | 403 | return $openapiOperation->withResponse($status, new Response($description, $responseContent, null, $responseLinks)); 404 | } 405 | 406 | /** 407 | * @return \ArrayObject 408 | */ 409 | private function buildContent(array $responseMimeTypes, array $operationSchemas): \ArrayObject 410 | { 411 | /** @var \ArrayObject $content */ 412 | $content = new \ArrayObject(); 413 | 414 | foreach ($responseMimeTypes as $mimeType => $format) { 415 | $content[$mimeType] = new MediaType(new \ArrayObject($operationSchemas[$format]->getArrayCopy(false))); 416 | } 417 | 418 | return $content; 419 | } 420 | 421 | private function getMimeTypes(HttpOperation $operation): array 422 | { 423 | $requestFormats = $operation->getInputFormats() ?: []; 424 | $responseFormats = $operation->getOutputFormats() ?: []; 425 | 426 | $requestMimeTypes = $this->flattenMimeTypes($requestFormats); 427 | $responseMimeTypes = $this->flattenMimeTypes($responseFormats); 428 | 429 | return [$requestMimeTypes, $responseMimeTypes]; 430 | } 431 | 432 | private function flattenMimeTypes(array $responseFormats): array 433 | { 434 | $responseMimeTypes = []; 435 | foreach ($responseFormats as $responseFormat => $mimeTypes) { 436 | foreach ($mimeTypes as $mimeType) { 437 | $responseMimeTypes[$mimeType] = $responseFormat; 438 | } 439 | } 440 | 441 | return $responseMimeTypes; 442 | } 443 | 444 | /** 445 | * Gets the path for an operation. 446 | * 447 | * If the path ends with the optional _format parameter, it is removed 448 | * as optional path parameters are not yet supported. 449 | * 450 | * @see https://github.com/OAI/OpenAPI-Specification/issues/93 451 | */ 452 | private function getPath(string $path): string 453 | { 454 | // Handle either API Platform's URI Template (rfc6570) or Symfony's route 455 | if (str_ends_with($path, '{._format}') || str_ends_with($path, '.{_format}')) { 456 | $path = substr($path, 0, -10); 457 | } 458 | 459 | return str_starts_with($path, '/') ? $path : '/'.$path; 460 | } 461 | 462 | private function getPathDescription(string $resourceShortName, string $method, bool $isCollection): string 463 | { 464 | switch ($method) { 465 | case 'GET': 466 | $pathSummary = $isCollection ? 'Retrieves the collection of %s resources.' : 'Retrieves a %s resource.'; 467 | break; 468 | case 'POST': 469 | $pathSummary = 'Creates a %s resource.'; 470 | break; 471 | case 'PATCH': 472 | $pathSummary = 'Updates the %s resource.'; 473 | break; 474 | case 'PUT': 475 | $pathSummary = 'Replaces the %s resource.'; 476 | break; 477 | case 'DELETE': 478 | $pathSummary = 'Removes the %s resource.'; 479 | break; 480 | default: 481 | return $resourceShortName; 482 | } 483 | 484 | return sprintf($pathSummary, $resourceShortName); 485 | } 486 | 487 | /** 488 | * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#linkObject. 489 | * 490 | * @return \ArrayObject 491 | */ 492 | private function getLinks(ResourceMetadataCollection $resourceMetadataCollection, HttpOperation $currentOperation): \ArrayObject 493 | { 494 | /** @var \ArrayObject $links */ 495 | $links = new \ArrayObject(); 496 | 497 | // Only compute get links for now 498 | foreach ($resourceMetadataCollection as $resource) { 499 | foreach ($resource->getOperations() as $operationName => $operation) { 500 | $parameters = []; 501 | $method = $operation instanceof HttpOperation ? $operation->getMethod() : HttpOperation::METHOD_GET; 502 | if ( 503 | $operationName === $operation->getName() 504 | || isset($links[$operationName]) 505 | || $operation instanceof CollectionOperationInterface 506 | || HttpOperation::METHOD_GET !== $method 507 | ) { 508 | continue; 509 | } 510 | 511 | // Operation ignored from OpenApi 512 | if ($operation instanceof HttpOperation && false === $operation->getOpenapi()) { 513 | continue; 514 | } 515 | 516 | $operationUriVariables = $operation->getUriVariables(); 517 | foreach ($currentOperation->getUriVariables() ?? [] as $parameterName => $uriVariableDefinition) { 518 | if (!isset($operationUriVariables[$parameterName])) { 519 | continue; 520 | } 521 | 522 | if ($operationUriVariables[$parameterName]->getIdentifiers() === $uriVariableDefinition->getIdentifiers() && $operationUriVariables[$parameterName]->getFromClass() === $uriVariableDefinition->getFromClass()) { 523 | $parameters[$parameterName] = '$request.path.'.$uriVariableDefinition->getIdentifiers()[0]; 524 | } 525 | } 526 | 527 | foreach ($operationUriVariables ?? [] as $parameterName => $uriVariableDefinition) { 528 | if (isset($parameters[$parameterName])) { 529 | continue; 530 | } 531 | 532 | if ($uriVariableDefinition->getFromClass() === $currentOperation->getClass()) { 533 | $parameters[$parameterName] = '$response.body#/'.$uriVariableDefinition->getIdentifiers()[0]; 534 | } 535 | } 536 | 537 | $links[$operationName] = new Link( 538 | $operationName, 539 | new \ArrayObject($parameters), 540 | null, 541 | $operation->getDescription() ?? '' 542 | ); 543 | } 544 | } 545 | 546 | return $links; 547 | } 548 | 549 | /** 550 | * Gets parameters corresponding to enabled filters. 551 | */ 552 | private function getFiltersParameters(CollectionOperationInterface|HttpOperation $operation): array 553 | { 554 | $parameters = []; 555 | 556 | $resourceFilters = $operation->getFilters(); 557 | foreach ($resourceFilters ?? [] as $filterId) { 558 | if (!$this->filterLocator->has($filterId)) { 559 | continue; 560 | } 561 | 562 | $filter = $this->filterLocator->get($filterId); 563 | $entityClass = $operation->getClass(); 564 | if (($options = $operation->getStateOptions()) && $options instanceof DoctrineOptions && $options->getEntityClass()) { 565 | $entityClass = $options->getEntityClass(); 566 | } 567 | 568 | foreach ($filter->getDescription($entityClass) as $name => $data) { 569 | $schema = $data['schema'] ?? (\in_array($data['type'], Type::$builtinTypes, true) ? $this->jsonSchemaTypeFactory->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false)) : ['type' => 'string']); 570 | 571 | $parameters[] = new Parameter( 572 | $name, 573 | 'query', 574 | $data['description'] ?? '', 575 | $data['required'] ?? false, 576 | $data['openapi']['deprecated'] ?? false, 577 | $data['openapi']['allowEmptyValue'] ?? true, 578 | $schema, 579 | 'array' === $schema['type'] && \in_array( 580 | $data['type'], 581 | [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], 582 | true 583 | ) ? 'deepObject' : 'form', 584 | $data['openapi']['explode'] ?? ('array' === $schema['type']), 585 | $data['openapi']['allowReserved'] ?? false, 586 | $data['openapi']['example'] ?? null, 587 | isset($data['openapi']['examples'] 588 | ) ? new \ArrayObject($data['openapi']['examples']) : null 589 | ); 590 | } 591 | } 592 | 593 | return $parameters; 594 | } 595 | 596 | private function getPaginationParameters(CollectionOperationInterface|HttpOperation $operation): array 597 | { 598 | if (!$this->paginationOptions->isPaginationEnabled()) { 599 | return []; 600 | } 601 | 602 | $parameters = []; 603 | 604 | if ($operation->getPaginationEnabled() ?? $this->paginationOptions->isPaginationEnabled()) { 605 | $parameters[] = new Parameter($this->paginationOptions->getPaginationPageParameterName(), 'query', 'The collection page number', false, false, true, ['type' => 'integer', 'default' => 1]); 606 | 607 | if ($operation->getPaginationClientItemsPerPage() ?? $this->paginationOptions->getClientItemsPerPage()) { 608 | $schema = [ 609 | 'type' => 'integer', 610 | 'default' => $operation->getPaginationItemsPerPage() ?? $this->paginationOptions->getItemsPerPage(), 611 | 'minimum' => 0, 612 | ]; 613 | 614 | if (null !== $maxItemsPerPage = ($operation->getPaginationMaximumItemsPerPage() ?? $this->paginationOptions->getMaximumItemsPerPage())) { 615 | $schema['maximum'] = $maxItemsPerPage; 616 | } 617 | 618 | $parameters[] = new Parameter($this->paginationOptions->getItemsPerPageParameterName(), 'query', 'The number of items per page', false, false, true, $schema); 619 | } 620 | } 621 | 622 | if ($operation->getPaginationClientEnabled() ?? $this->paginationOptions->isPaginationClientEnabled()) { 623 | $parameters[] = new Parameter($this->paginationOptions->getPaginationClientEnabledParameterName(), 'query', 'Enable or disable pagination', false, false, true, ['type' => 'boolean']); 624 | } 625 | 626 | return $parameters; 627 | } 628 | 629 | private function getOauthSecurityScheme(): SecurityScheme 630 | { 631 | $oauthFlow = new OAuthFlow($this->openApiOptions->getOAuthAuthorizationUrl(), $this->openApiOptions->getOAuthTokenUrl() ?: null, $this->openApiOptions->getOAuthRefreshUrl() ?: null, new \ArrayObject($this->openApiOptions->getOAuthScopes())); 632 | $description = sprintf( 633 | 'OAuth 2.0 %s Grant', 634 | strtolower(preg_replace('/[A-Z]/', ' \\0', lcfirst($this->openApiOptions->getOAuthFlow()))) 635 | ); 636 | $implicit = $password = $clientCredentials = $authorizationCode = null; 637 | 638 | switch ($this->openApiOptions->getOAuthFlow()) { 639 | case 'implicit': 640 | $implicit = $oauthFlow; 641 | break; 642 | case 'password': 643 | $password = $oauthFlow; 644 | break; 645 | case 'application': 646 | case 'clientCredentials': 647 | $clientCredentials = $oauthFlow; 648 | break; 649 | case 'accessCode': 650 | case 'authorizationCode': 651 | $authorizationCode = $oauthFlow; 652 | break; 653 | default: 654 | throw new \LogicException('OAuth flow must be one of: implicit, password, clientCredentials, authorizationCode'); 655 | } 656 | 657 | return new SecurityScheme($this->openApiOptions->getOAuthType(), $description, null, null, null, null, new OAuthFlows($implicit, $password, $clientCredentials, $authorizationCode), null); 658 | } 659 | 660 | private function getSecuritySchemes(): array 661 | { 662 | $securitySchemes = []; 663 | 664 | if ($this->openApiOptions->getOAuthEnabled()) { 665 | $securitySchemes['oauth'] = $this->getOauthSecurityScheme(); 666 | } 667 | 668 | foreach ($this->openApiOptions->getApiKeys() as $key => $apiKey) { 669 | $description = sprintf('Value for the %s %s parameter.', $apiKey['name'], $apiKey['type']); 670 | $securitySchemes[$key] = new SecurityScheme('apiKey', $description, $apiKey['name'], $apiKey['type']); 671 | } 672 | 673 | return $securitySchemes; 674 | } 675 | 676 | private function appendSchemaDefinitions(\ArrayObject $schemas, \ArrayObject $definitions): void 677 | { 678 | foreach ($definitions as $key => $value) { 679 | $schemas[$key] = $value; 680 | } 681 | } 682 | 683 | private function hasParameter(Model\Operation $operation, Parameter $parameter): bool 684 | { 685 | foreach ($operation->getParameters() as $existingParameter) { 686 | if ($existingParameter->getName() === $parameter->getName() && $existingParameter->getIn() === $parameter->getIn()) { 687 | return true; 688 | } 689 | } 690 | 691 | return false; 692 | } 693 | } 694 | -------------------------------------------------------------------------------- /Factory/OpenApiFactoryInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Factory; 15 | 16 | use ApiPlatform\OpenApi\OpenApi; 17 | 18 | interface OpenApiFactoryInterface 19 | { 20 | /** 21 | * Creates an OpenApi class. 22 | */ 23 | public function __invoke(array $context = []): OpenApi; 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT license 2 | 3 | Copyright (c) 2015-present Kévin Dunglas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Model/Components.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class Components 17 | { 18 | use ExtensionTrait; 19 | 20 | private ?\ArrayObject $schemas; 21 | 22 | /** 23 | * @param \ArrayObject|\ArrayObject $schemas 24 | * @param \ArrayObject|\ArrayObject $responses 25 | * @param \ArrayObject|\ArrayObject $parameters 26 | * @param \ArrayObject|\ArrayObject $examples 27 | * @param \ArrayObject|\ArrayObject $requestBodies 28 | * @param \ArrayObject|\ArrayObject $headers 29 | * @param \ArrayObject|\ArrayObject $headers 30 | * @param \ArrayObject|\ArrayObject $links 31 | * @param \ArrayObject>|\ArrayObject> $callbacks 32 | * @param \ArrayObject|\ArrayObject $pathItems 33 | */ 34 | public function __construct(?\ArrayObject $schemas = null, private ?\ArrayObject $responses = null, private ?\ArrayObject $parameters = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $requestBodies = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $securitySchemes = null, private ?\ArrayObject $links = null, private ?\ArrayObject $callbacks = null, private ?\ArrayObject $pathItems = null) 35 | { 36 | $schemas?->ksort(); 37 | 38 | $this->schemas = $schemas; 39 | } 40 | 41 | public function getSchemas(): ?\ArrayObject 42 | { 43 | return $this->schemas; 44 | } 45 | 46 | public function getResponses(): ?\ArrayObject 47 | { 48 | return $this->responses; 49 | } 50 | 51 | public function getParameters(): ?\ArrayObject 52 | { 53 | return $this->parameters; 54 | } 55 | 56 | public function getExamples(): ?\ArrayObject 57 | { 58 | return $this->examples; 59 | } 60 | 61 | public function getRequestBodies(): ?\ArrayObject 62 | { 63 | return $this->requestBodies; 64 | } 65 | 66 | public function getHeaders(): ?\ArrayObject 67 | { 68 | return $this->headers; 69 | } 70 | 71 | public function getSecuritySchemes(): ?\ArrayObject 72 | { 73 | return $this->securitySchemes; 74 | } 75 | 76 | public function getLinks(): ?\ArrayObject 77 | { 78 | return $this->links; 79 | } 80 | 81 | public function getCallbacks(): ?\ArrayObject 82 | { 83 | return $this->callbacks; 84 | } 85 | 86 | public function getPathItems(): ?\ArrayObject 87 | { 88 | return $this->pathItems; 89 | } 90 | 91 | public function withSchemas(\ArrayObject $schemas): self 92 | { 93 | $clone = clone $this; 94 | $clone->schemas = $schemas; 95 | 96 | return $clone; 97 | } 98 | 99 | public function withResponses(\ArrayObject $responses): self 100 | { 101 | $clone = clone $this; 102 | $clone->responses = $responses; 103 | 104 | return $clone; 105 | } 106 | 107 | public function withParameters(\ArrayObject $parameters): self 108 | { 109 | $clone = clone $this; 110 | $clone->parameters = $parameters; 111 | 112 | return $clone; 113 | } 114 | 115 | public function withExamples(\ArrayObject $examples): self 116 | { 117 | $clone = clone $this; 118 | $clone->examples = $examples; 119 | 120 | return $clone; 121 | } 122 | 123 | public function withRequestBodies(\ArrayObject $requestBodies): self 124 | { 125 | $clone = clone $this; 126 | $clone->requestBodies = $requestBodies; 127 | 128 | return $clone; 129 | } 130 | 131 | public function withHeaders(\ArrayObject $headers): self 132 | { 133 | $clone = clone $this; 134 | $clone->headers = $headers; 135 | 136 | return $clone; 137 | } 138 | 139 | public function withSecuritySchemes(\ArrayObject $securitySchemes): self 140 | { 141 | $clone = clone $this; 142 | $clone->securitySchemes = $securitySchemes; 143 | 144 | return $clone; 145 | } 146 | 147 | public function withLinks(\ArrayObject $links): self 148 | { 149 | $clone = clone $this; 150 | $clone->links = $links; 151 | 152 | return $clone; 153 | } 154 | 155 | public function withCallbacks(\ArrayObject $callbacks): self 156 | { 157 | $clone = clone $this; 158 | $clone->callbacks = $callbacks; 159 | 160 | return $clone; 161 | } 162 | 163 | public function withPathItems(\ArrayObject $pathItems): self 164 | { 165 | $clone = clone $this; 166 | $clone->pathItems = $pathItems; 167 | 168 | return $clone; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /Model/Contact.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class Contact 17 | { 18 | use ExtensionTrait; 19 | 20 | public function __construct(private ?string $name = null, private ?string $url = null, private ?string $email = null) 21 | { 22 | } 23 | 24 | public function getName(): ?string 25 | { 26 | return $this->name; 27 | } 28 | 29 | public function getUrl(): ?string 30 | { 31 | return $this->url; 32 | } 33 | 34 | public function getEmail(): ?string 35 | { 36 | return $this->email; 37 | } 38 | 39 | public function withName(?string $name): self 40 | { 41 | $clone = clone $this; 42 | $clone->name = $name; 43 | 44 | return $clone; 45 | } 46 | 47 | public function withUrl(?string $url): self 48 | { 49 | $clone = clone $this; 50 | $clone->url = $url; 51 | 52 | return $clone; 53 | } 54 | 55 | public function withEmail(?string $email): self 56 | { 57 | $clone = clone $this; 58 | $clone->email = $email; 59 | 60 | return $clone; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Model/Encoding.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class Encoding 17 | { 18 | use ExtensionTrait; 19 | 20 | public function __construct(private string $contentType = '', private ?\ArrayObject $headers = null, private string $style = '', private bool $explode = false, private bool $allowReserved = false) 21 | { 22 | } 23 | 24 | public function getContentType(): string 25 | { 26 | return $this->contentType; 27 | } 28 | 29 | public function getHeaders(): ?\ArrayObject 30 | { 31 | return $this->headers; 32 | } 33 | 34 | public function getStyle(): string 35 | { 36 | return $this->style; 37 | } 38 | 39 | public function canExplode(): bool 40 | { 41 | return $this->explode; 42 | } 43 | 44 | public function getExplode(): bool 45 | { 46 | return $this->explode; 47 | } 48 | 49 | public function canAllowReserved(): bool 50 | { 51 | return $this->allowReserved; 52 | } 53 | 54 | public function getAllowReserved(): bool 55 | { 56 | return $this->allowReserved; 57 | } 58 | 59 | public function withContentType(string $contentType): self 60 | { 61 | $clone = clone $this; 62 | $clone->contentType = $contentType; 63 | 64 | return $clone; 65 | } 66 | 67 | public function withHeaders(?\ArrayObject $headers): self 68 | { 69 | $clone = clone $this; 70 | $clone->headers = $headers; 71 | 72 | return $clone; 73 | } 74 | 75 | public function withStyle(string $style): self 76 | { 77 | $clone = clone $this; 78 | $clone->style = $style; 79 | 80 | return $clone; 81 | } 82 | 83 | public function withExplode(bool $explode): self 84 | { 85 | $clone = clone $this; 86 | $clone->explode = $explode; 87 | 88 | return $clone; 89 | } 90 | 91 | public function withAllowReserved(bool $allowReserved): self 92 | { 93 | $clone = clone $this; 94 | $clone->allowReserved = $allowReserved; 95 | 96 | return $clone; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Model/Example.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class Example 17 | { 18 | use ExtensionTrait; 19 | 20 | public function __construct(private ?string $summary = null, private ?string $description = null, private mixed $value = null, private ?string $externalValue = null) 21 | { 22 | } 23 | 24 | public function getSummary(): ?string 25 | { 26 | return $this->summary; 27 | } 28 | 29 | public function withSummary(string $summary): self 30 | { 31 | $clone = clone $this; 32 | $clone->summary = $summary; 33 | 34 | return $clone; 35 | } 36 | 37 | public function getDescription(): ?string 38 | { 39 | return $this->description; 40 | } 41 | 42 | public function withDescription(string $description): self 43 | { 44 | $clone = clone $this; 45 | $clone->description = $description; 46 | 47 | return $clone; 48 | } 49 | 50 | public function getValue(): mixed 51 | { 52 | return $this->value; 53 | } 54 | 55 | public function withValue(mixed $value): self 56 | { 57 | $clone = clone $this; 58 | $clone->value = $value; 59 | 60 | return $clone; 61 | } 62 | 63 | public function getExternalValue(): ?string 64 | { 65 | return $this->externalValue; 66 | } 67 | 68 | public function withExternalValue(string $externalValue): self 69 | { 70 | $clone = clone $this; 71 | $clone->externalValue = $externalValue; 72 | 73 | return $clone; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Model/ExtensionTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | trait ExtensionTrait 17 | { 18 | private array $extensionProperties = []; 19 | 20 | public function withExtensionProperty(string $key, $value): mixed 21 | { 22 | if (!str_starts_with($key, 'x-')) { 23 | $key = 'x-'.$key; 24 | } 25 | 26 | $clone = clone $this; 27 | $clone->extensionProperties[$key] = $value; 28 | 29 | return $clone; 30 | } 31 | 32 | public function getExtensionProperties(): array 33 | { 34 | return $this->extensionProperties; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Model/ExternalDocumentation.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class ExternalDocumentation 17 | { 18 | use ExtensionTrait; 19 | 20 | public function __construct(private string $description = '', private string $url = '') 21 | { 22 | } 23 | 24 | public function getDescription(): string 25 | { 26 | return $this->description; 27 | } 28 | 29 | public function getUrl(): string 30 | { 31 | return $this->url; 32 | } 33 | 34 | public function withDescription(string $description): self 35 | { 36 | $clone = clone $this; 37 | $clone->description = $description; 38 | 39 | return $clone; 40 | } 41 | 42 | public function withUrl(string $url): self 43 | { 44 | $clone = clone $this; 45 | $clone->url = $url; 46 | 47 | return $clone; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Model/Header.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class Header 17 | { 18 | use ExtensionTrait; 19 | 20 | public function __construct(private readonly string $in = 'header', private string $description = '', private bool $required = false, private bool $deprecated = false, private bool $allowEmptyValue = false, private array $schema = [], private ?string $style = null, private bool $explode = false, private bool $allowReserved = false, private $example = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $content = null) 21 | { 22 | if (null === $style) { 23 | $this->style = 'simple'; 24 | } 25 | } 26 | 27 | public function getIn(): string 28 | { 29 | return $this->in; 30 | } 31 | 32 | public function getDescription(): string 33 | { 34 | return $this->description; 35 | } 36 | 37 | public function getRequired(): bool 38 | { 39 | return $this->required; 40 | } 41 | 42 | public function getDeprecated(): bool 43 | { 44 | return $this->deprecated; 45 | } 46 | 47 | public function canAllowEmptyValue(): bool 48 | { 49 | return $this->allowEmptyValue; 50 | } 51 | 52 | public function getAllowEmptyValue(): bool 53 | { 54 | return $this->allowEmptyValue; 55 | } 56 | 57 | public function getSchema(): array 58 | { 59 | return $this->schema; 60 | } 61 | 62 | public function getStyle(): string 63 | { 64 | return $this->style; 65 | } 66 | 67 | public function canExplode(): bool 68 | { 69 | return $this->explode; 70 | } 71 | 72 | public function getExplode(): bool 73 | { 74 | return $this->explode; 75 | } 76 | 77 | public function canAllowReserved(): bool 78 | { 79 | return $this->allowReserved; 80 | } 81 | 82 | public function getAllowReserved(): bool 83 | { 84 | return $this->allowReserved; 85 | } 86 | 87 | public function getExample() 88 | { 89 | return $this->example; 90 | } 91 | 92 | public function getExamples(): ?\ArrayObject 93 | { 94 | return $this->examples; 95 | } 96 | 97 | public function getContent(): ?\ArrayObject 98 | { 99 | return $this->content; 100 | } 101 | 102 | public function withDescription(string $description): self 103 | { 104 | $clone = clone $this; 105 | $clone->description = $description; 106 | 107 | return $clone; 108 | } 109 | 110 | public function withRequired(bool $required): self 111 | { 112 | $clone = clone $this; 113 | $clone->required = $required; 114 | 115 | return $clone; 116 | } 117 | 118 | public function withDeprecated(bool $deprecated): self 119 | { 120 | $clone = clone $this; 121 | $clone->deprecated = $deprecated; 122 | 123 | return $clone; 124 | } 125 | 126 | public function withAllowEmptyValue(bool $allowEmptyValue): self 127 | { 128 | $clone = clone $this; 129 | $clone->allowEmptyValue = $allowEmptyValue; 130 | 131 | return $clone; 132 | } 133 | 134 | public function withSchema(array $schema): self 135 | { 136 | $clone = clone $this; 137 | $clone->schema = $schema; 138 | 139 | return $clone; 140 | } 141 | 142 | public function withStyle(string $style): self 143 | { 144 | $clone = clone $this; 145 | $clone->style = $style; 146 | 147 | return $clone; 148 | } 149 | 150 | public function withExplode(bool $explode): self 151 | { 152 | $clone = clone $this; 153 | $clone->explode = $explode; 154 | 155 | return $clone; 156 | } 157 | 158 | public function withAllowReserved(bool $allowReserved): self 159 | { 160 | $clone = clone $this; 161 | $clone->allowReserved = $allowReserved; 162 | 163 | return $clone; 164 | } 165 | 166 | public function withExample($example): self 167 | { 168 | $clone = clone $this; 169 | $clone->example = $example; 170 | 171 | return $clone; 172 | } 173 | 174 | public function withExamples(\ArrayObject $examples): self 175 | { 176 | $clone = clone $this; 177 | $clone->examples = $examples; 178 | 179 | return $clone; 180 | } 181 | 182 | public function withContent(\ArrayObject $content): self 183 | { 184 | $clone = clone $this; 185 | $clone->content = $content; 186 | 187 | return $clone; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /Model/Info.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class Info 17 | { 18 | use ExtensionTrait; 19 | 20 | public function __construct(private string $title, private string $version, private string $description = '', private ?string $termsOfService = null, private ?Contact $contact = null, private ?License $license = null, private ?string $summary = null) 21 | { 22 | } 23 | 24 | public function getTitle(): string 25 | { 26 | return $this->title; 27 | } 28 | 29 | public function getDescription(): string 30 | { 31 | return $this->description; 32 | } 33 | 34 | public function getTermsOfService(): ?string 35 | { 36 | return $this->termsOfService; 37 | } 38 | 39 | public function getContact(): ?Contact 40 | { 41 | return $this->contact; 42 | } 43 | 44 | public function getLicense(): ?License 45 | { 46 | return $this->license; 47 | } 48 | 49 | public function getVersion(): string 50 | { 51 | return $this->version; 52 | } 53 | 54 | public function getSummary(): ?string 55 | { 56 | return $this->summary; 57 | } 58 | 59 | public function withTitle(string $title): self 60 | { 61 | $info = clone $this; 62 | $info->title = $title; 63 | 64 | return $info; 65 | } 66 | 67 | public function withDescription(string $description): self 68 | { 69 | $clone = clone $this; 70 | $clone->description = $description; 71 | 72 | return $clone; 73 | } 74 | 75 | public function withTermsOfService(string $termsOfService): self 76 | { 77 | $clone = clone $this; 78 | $clone->termsOfService = $termsOfService; 79 | 80 | return $clone; 81 | } 82 | 83 | public function withContact(Contact $contact): self 84 | { 85 | $clone = clone $this; 86 | $clone->contact = $contact; 87 | 88 | return $clone; 89 | } 90 | 91 | public function withLicense(License $license): self 92 | { 93 | $clone = clone $this; 94 | $clone->license = $license; 95 | 96 | return $clone; 97 | } 98 | 99 | public function withVersion(string $version): self 100 | { 101 | $clone = clone $this; 102 | $clone->version = $version; 103 | 104 | return $clone; 105 | } 106 | 107 | public function withSummary(string $summary): self 108 | { 109 | $clone = clone $this; 110 | $clone->summary = $summary; 111 | 112 | return $clone; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Model/License.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class License 17 | { 18 | use ExtensionTrait; 19 | 20 | public function __construct(private string $name, private ?string $url = null, private ?string $identifier = null) 21 | { 22 | } 23 | 24 | public function getName(): string 25 | { 26 | return $this->name; 27 | } 28 | 29 | public function getUrl(): ?string 30 | { 31 | return $this->url; 32 | } 33 | 34 | public function getIdentifier(): ?string 35 | { 36 | return $this->identifier; 37 | } 38 | 39 | public function withName(string $name): self 40 | { 41 | $clone = clone $this; 42 | $clone->name = $name; 43 | 44 | return $clone; 45 | } 46 | 47 | public function withUrl(?string $url): self 48 | { 49 | $clone = clone $this; 50 | $clone->url = $url; 51 | 52 | return $clone; 53 | } 54 | 55 | public function withIdentifier(?string $identifier): self 56 | { 57 | $clone = clone $this; 58 | $clone->identifier = $identifier; 59 | 60 | return $clone; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Model/Link.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class Link 17 | { 18 | use ExtensionTrait; 19 | 20 | public function __construct(private string $operationId, private ?\ArrayObject $parameters = null, private $requestBody = null, private string $description = '', private ?Server $server = null) 21 | { 22 | } 23 | 24 | public function getOperationId(): string 25 | { 26 | return $this->operationId; 27 | } 28 | 29 | public function getParameters(): \ArrayObject 30 | { 31 | return $this->parameters; 32 | } 33 | 34 | public function getRequestBody() 35 | { 36 | return $this->requestBody; 37 | } 38 | 39 | public function getDescription(): string 40 | { 41 | return $this->description; 42 | } 43 | 44 | public function getServer(): ?Server 45 | { 46 | return $this->server; 47 | } 48 | 49 | public function withOperationId(string $operationId): self 50 | { 51 | $clone = clone $this; 52 | $clone->operationId = $operationId; 53 | 54 | return $clone; 55 | } 56 | 57 | public function withParameters(\ArrayObject $parameters): self 58 | { 59 | $clone = clone $this; 60 | $clone->parameters = $parameters; 61 | 62 | return $clone; 63 | } 64 | 65 | public function withRequestBody($requestBody): self 66 | { 67 | $clone = clone $this; 68 | $clone->requestBody = $requestBody; 69 | 70 | return $clone; 71 | } 72 | 73 | public function withDescription(string $description): self 74 | { 75 | $clone = clone $this; 76 | $clone->description = $description; 77 | 78 | return $clone; 79 | } 80 | 81 | public function withServer(Server $server): self 82 | { 83 | $clone = clone $this; 84 | $clone->server = $server; 85 | 86 | return $clone; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Model/MediaType.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class MediaType 17 | { 18 | use ExtensionTrait; 19 | 20 | public function __construct(private ?\ArrayObject $schema = null, private $example = null, private ?\ArrayObject $examples = null, private ?Encoding $encoding = null) 21 | { 22 | } 23 | 24 | public function getSchema(): ?\ArrayObject 25 | { 26 | return $this->schema; 27 | } 28 | 29 | public function getExample() 30 | { 31 | return $this->example; 32 | } 33 | 34 | public function getExamples(): ?\ArrayObject 35 | { 36 | return $this->examples; 37 | } 38 | 39 | public function getEncoding(): ?Encoding 40 | { 41 | return $this->encoding; 42 | } 43 | 44 | public function withSchema(\ArrayObject $schema): self 45 | { 46 | $clone = clone $this; 47 | $clone->schema = $schema; 48 | 49 | return $clone; 50 | } 51 | 52 | public function withExample($example): self 53 | { 54 | $clone = clone $this; 55 | $clone->example = $example; 56 | 57 | return $clone; 58 | } 59 | 60 | public function withExamples(\ArrayObject $examples): self 61 | { 62 | $clone = clone $this; 63 | $clone->examples = $examples; 64 | 65 | return $clone; 66 | } 67 | 68 | public function withEncoding(Encoding $encoding): self 69 | { 70 | $clone = clone $this; 71 | $clone->encoding = $encoding; 72 | 73 | return $clone; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Model/OAuthFlow.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class OAuthFlow 17 | { 18 | use ExtensionTrait; 19 | 20 | public function __construct(private ?string $authorizationUrl = null, private ?string $tokenUrl = null, private ?string $refreshUrl = null, private ?\ArrayObject $scopes = null) 21 | { 22 | } 23 | 24 | public function getAuthorizationUrl(): ?string 25 | { 26 | return $this->authorizationUrl; 27 | } 28 | 29 | public function getTokenUrl(): ?string 30 | { 31 | return $this->tokenUrl; 32 | } 33 | 34 | public function getRefreshUrl(): ?string 35 | { 36 | return $this->refreshUrl; 37 | } 38 | 39 | public function getScopes(): \ArrayObject 40 | { 41 | return $this->scopes; 42 | } 43 | 44 | public function withAuthorizationUrl(string $authorizationUrl): self 45 | { 46 | $clone = clone $this; 47 | $clone->authorizationUrl = $authorizationUrl; 48 | 49 | return $clone; 50 | } 51 | 52 | public function withTokenUrl(string $tokenUrl): self 53 | { 54 | $clone = clone $this; 55 | $clone->tokenUrl = $tokenUrl; 56 | 57 | return $clone; 58 | } 59 | 60 | public function withRefreshUrl(string $refreshUrl): self 61 | { 62 | $clone = clone $this; 63 | $clone->refreshUrl = $refreshUrl; 64 | 65 | return $clone; 66 | } 67 | 68 | public function withScopes(\ArrayObject $scopes): self 69 | { 70 | $clone = clone $this; 71 | $clone->scopes = $scopes; 72 | 73 | return $clone; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Model/OAuthFlows.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class OAuthFlows 17 | { 18 | use ExtensionTrait; 19 | 20 | public function __construct(private ?OAuthFlow $implicit = null, private ?OAuthFlow $password = null, private ?OAuthFlow $clientCredentials = null, private ?OAuthFlow $authorizationCode = null) 21 | { 22 | } 23 | 24 | public function getImplicit(): ?OAuthFlow 25 | { 26 | return $this->implicit; 27 | } 28 | 29 | public function getPassword(): ?OAuthFlow 30 | { 31 | return $this->password; 32 | } 33 | 34 | public function getClientCredentials(): ?OAuthFlow 35 | { 36 | return $this->clientCredentials; 37 | } 38 | 39 | public function getAuthorizationCode(): ?OAuthFlow 40 | { 41 | return $this->authorizationCode; 42 | } 43 | 44 | public function withImplicit(OAuthFlow $implicit): self 45 | { 46 | $clone = clone $this; 47 | $clone->implicit = $implicit; 48 | 49 | return $clone; 50 | } 51 | 52 | public function withPassword(OAuthFlow $password): self 53 | { 54 | $clone = clone $this; 55 | $clone->password = $password; 56 | 57 | return $clone; 58 | } 59 | 60 | public function withClientCredentials(OAuthFlow $clientCredentials): self 61 | { 62 | $clone = clone $this; 63 | $clone->clientCredentials = $clientCredentials; 64 | 65 | return $clone; 66 | } 67 | 68 | public function withAuthorizationCode(OAuthFlow $authorizationCode): self 69 | { 70 | $clone = clone $this; 71 | $clone->authorizationCode = $authorizationCode; 72 | 73 | return $clone; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Model/Operation.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class Operation 17 | { 18 | use ExtensionTrait; 19 | 20 | public function __construct(private ?string $operationId = null, private ?array $tags = null, private ?array $responses = null, private ?string $summary = null, private ?string $description = null, private ?ExternalDocumentation $externalDocs = null, private ?array $parameters = null, private ?RequestBody $requestBody = null, private ?\ArrayObject $callbacks = null, private ?bool $deprecated = null, private ?array $security = null, private ?array $servers = null, array $extensionProperties = []) 21 | { 22 | $this->extensionProperties = $extensionProperties; 23 | } 24 | 25 | public function addResponse(Response $response, $status = 'default'): self 26 | { 27 | $this->responses[$status] = $response; 28 | 29 | return $this; 30 | } 31 | 32 | public function getOperationId(): ?string 33 | { 34 | return $this->operationId; 35 | } 36 | 37 | public function getTags(): ?array 38 | { 39 | return $this->tags; 40 | } 41 | 42 | public function getResponses(): ?array 43 | { 44 | return $this->responses; 45 | } 46 | 47 | public function getSummary(): ?string 48 | { 49 | return $this->summary; 50 | } 51 | 52 | public function getDescription(): ?string 53 | { 54 | return $this->description; 55 | } 56 | 57 | public function getExternalDocs(): ?ExternalDocumentation 58 | { 59 | return $this->externalDocs; 60 | } 61 | 62 | public function getParameters(): ?array 63 | { 64 | return $this->parameters; 65 | } 66 | 67 | public function getRequestBody(): ?RequestBody 68 | { 69 | return $this->requestBody; 70 | } 71 | 72 | public function getCallbacks(): ?\ArrayObject 73 | { 74 | return $this->callbacks; 75 | } 76 | 77 | public function getDeprecated(): ?bool 78 | { 79 | return $this->deprecated; 80 | } 81 | 82 | public function getSecurity(): ?array 83 | { 84 | return $this->security; 85 | } 86 | 87 | public function getServers(): ?array 88 | { 89 | return $this->servers; 90 | } 91 | 92 | public function withOperationId(string $operationId): self 93 | { 94 | $clone = clone $this; 95 | $clone->operationId = $operationId; 96 | 97 | return $clone; 98 | } 99 | 100 | public function withTags(array $tags): self 101 | { 102 | $clone = clone $this; 103 | $clone->tags = $tags; 104 | 105 | return $clone; 106 | } 107 | 108 | public function withResponses(array $responses): self 109 | { 110 | $clone = clone $this; 111 | $clone->responses = $responses; 112 | 113 | return $clone; 114 | } 115 | 116 | public function withResponse(int|string $status, Response $response): self 117 | { 118 | $clone = clone $this; 119 | if (!\is_array($clone->responses)) { 120 | $clone->responses = []; 121 | } 122 | $clone->responses[(string) $status] = $response; 123 | 124 | return $clone; 125 | } 126 | 127 | public function withSummary(string $summary): self 128 | { 129 | $clone = clone $this; 130 | $clone->summary = $summary; 131 | 132 | return $clone; 133 | } 134 | 135 | public function withDescription(string $description): self 136 | { 137 | $clone = clone $this; 138 | $clone->description = $description; 139 | 140 | return $clone; 141 | } 142 | 143 | public function withExternalDocs(ExternalDocumentation $externalDocs): self 144 | { 145 | $clone = clone $this; 146 | $clone->externalDocs = $externalDocs; 147 | 148 | return $clone; 149 | } 150 | 151 | public function withParameters(array $parameters): self 152 | { 153 | $clone = clone $this; 154 | $clone->parameters = $parameters; 155 | 156 | return $clone; 157 | } 158 | 159 | public function withParameter(Parameter $parameter): self 160 | { 161 | $clone = clone $this; 162 | if (!\is_array($clone->parameters)) { 163 | $clone->parameters = []; 164 | } 165 | $clone->parameters[] = $parameter; 166 | 167 | return $clone; 168 | } 169 | 170 | public function withRequestBody(?RequestBody $requestBody = null): self 171 | { 172 | $clone = clone $this; 173 | $clone->requestBody = $requestBody; 174 | 175 | return $clone; 176 | } 177 | 178 | public function withCallbacks(\ArrayObject $callbacks): self 179 | { 180 | $clone = clone $this; 181 | $clone->callbacks = $callbacks; 182 | 183 | return $clone; 184 | } 185 | 186 | public function withDeprecated(bool $deprecated): self 187 | { 188 | $clone = clone $this; 189 | $clone->deprecated = $deprecated; 190 | 191 | return $clone; 192 | } 193 | 194 | public function withSecurity(?array $security = null): self 195 | { 196 | $clone = clone $this; 197 | $clone->security = $security; 198 | 199 | return $clone; 200 | } 201 | 202 | public function withServers(?array $servers = null): self 203 | { 204 | $clone = clone $this; 205 | $clone->servers = $servers; 206 | 207 | return $clone; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /Model/Parameter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class Parameter 17 | { 18 | use ExtensionTrait; 19 | 20 | public function __construct(private string $name, private string $in, private string $description = '', private bool $required = false, private bool $deprecated = false, private bool $allowEmptyValue = false, private array $schema = [], private ?string $style = null, private bool $explode = false, private bool $allowReserved = false, private $example = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $content = null) 21 | { 22 | if (null === $style) { 23 | if ('query' === $in || 'cookie' === $in) { 24 | $this->style = 'form'; 25 | } elseif ('path' === $in || 'header' === $in) { 26 | $this->style = 'simple'; 27 | } 28 | } 29 | } 30 | 31 | public function getName(): string 32 | { 33 | return $this->name; 34 | } 35 | 36 | public function getIn(): string 37 | { 38 | return $this->in; 39 | } 40 | 41 | public function getDescription(): string 42 | { 43 | return $this->description; 44 | } 45 | 46 | public function getRequired(): bool 47 | { 48 | return $this->required; 49 | } 50 | 51 | public function getDeprecated(): bool 52 | { 53 | return $this->deprecated; 54 | } 55 | 56 | public function canAllowEmptyValue(): bool 57 | { 58 | return $this->allowEmptyValue; 59 | } 60 | 61 | public function getAllowEmptyValue(): bool 62 | { 63 | return $this->allowEmptyValue; 64 | } 65 | 66 | public function getSchema(): array 67 | { 68 | return $this->schema; 69 | } 70 | 71 | public function getStyle(): string 72 | { 73 | return $this->style; 74 | } 75 | 76 | public function canExplode(): bool 77 | { 78 | return $this->explode; 79 | } 80 | 81 | public function getExplode(): bool 82 | { 83 | return $this->explode; 84 | } 85 | 86 | public function canAllowReserved(): bool 87 | { 88 | return $this->allowReserved; 89 | } 90 | 91 | public function getAllowReserved(): bool 92 | { 93 | return $this->allowReserved; 94 | } 95 | 96 | public function getExample() 97 | { 98 | return $this->example; 99 | } 100 | 101 | public function getExamples(): ?\ArrayObject 102 | { 103 | return $this->examples; 104 | } 105 | 106 | public function getContent(): ?\ArrayObject 107 | { 108 | return $this->content; 109 | } 110 | 111 | public function withName(string $name): self 112 | { 113 | $clone = clone $this; 114 | $clone->name = $name; 115 | 116 | return $clone; 117 | } 118 | 119 | public function withIn(string $in): self 120 | { 121 | $clone = clone $this; 122 | $clone->in = $in; 123 | 124 | return $clone; 125 | } 126 | 127 | public function withDescription(string $description): self 128 | { 129 | $clone = clone $this; 130 | $clone->description = $description; 131 | 132 | return $clone; 133 | } 134 | 135 | public function withRequired(bool $required): self 136 | { 137 | $clone = clone $this; 138 | $clone->required = $required; 139 | 140 | return $clone; 141 | } 142 | 143 | public function withDeprecated(bool $deprecated): self 144 | { 145 | $clone = clone $this; 146 | $clone->deprecated = $deprecated; 147 | 148 | return $clone; 149 | } 150 | 151 | public function withAllowEmptyValue(bool $allowEmptyValue): self 152 | { 153 | $clone = clone $this; 154 | $clone->allowEmptyValue = $allowEmptyValue; 155 | 156 | return $clone; 157 | } 158 | 159 | public function withSchema(array $schema): self 160 | { 161 | $clone = clone $this; 162 | $clone->schema = $schema; 163 | 164 | return $clone; 165 | } 166 | 167 | public function withStyle(string $style): self 168 | { 169 | $clone = clone $this; 170 | $clone->style = $style; 171 | 172 | return $clone; 173 | } 174 | 175 | public function withExplode(bool $explode): self 176 | { 177 | $clone = clone $this; 178 | $clone->explode = $explode; 179 | 180 | return $clone; 181 | } 182 | 183 | public function withAllowReserved(bool $allowReserved): self 184 | { 185 | $clone = clone $this; 186 | $clone->allowReserved = $allowReserved; 187 | 188 | return $clone; 189 | } 190 | 191 | public function withExample($example): self 192 | { 193 | $clone = clone $this; 194 | $clone->example = $example; 195 | 196 | return $clone; 197 | } 198 | 199 | public function withExamples(\ArrayObject $examples): self 200 | { 201 | $clone = clone $this; 202 | $clone->examples = $examples; 203 | 204 | return $clone; 205 | } 206 | 207 | public function withContent(\ArrayObject $content): self 208 | { 209 | $clone = clone $this; 210 | $clone->content = $content; 211 | 212 | return $clone; 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /Model/PathItem.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class PathItem 17 | { 18 | use ExtensionTrait; 19 | 20 | public static array $methods = ['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH', 'TRACE']; 21 | 22 | public function __construct(private ?string $ref = null, private ?string $summary = null, private ?string $description = null, private ?Operation $get = null, private ?Operation $put = null, private ?Operation $post = null, private ?Operation $delete = null, private ?Operation $options = null, private ?Operation $head = null, private ?Operation $patch = null, private ?Operation $trace = null, private ?array $servers = null, private array $parameters = []) 23 | { 24 | } 25 | 26 | public function getRef(): ?string 27 | { 28 | return $this->ref; 29 | } 30 | 31 | public function getSummary(): ?string 32 | { 33 | return $this->summary; 34 | } 35 | 36 | public function getDescription(): ?string 37 | { 38 | return $this->description; 39 | } 40 | 41 | public function getGet(): ?Operation 42 | { 43 | return $this->get; 44 | } 45 | 46 | public function getPut(): ?Operation 47 | { 48 | return $this->put; 49 | } 50 | 51 | public function getPost(): ?Operation 52 | { 53 | return $this->post; 54 | } 55 | 56 | public function getDelete(): ?Operation 57 | { 58 | return $this->delete; 59 | } 60 | 61 | public function getOptions(): ?Operation 62 | { 63 | return $this->options; 64 | } 65 | 66 | public function getHead(): ?Operation 67 | { 68 | return $this->head; 69 | } 70 | 71 | public function getPatch(): ?Operation 72 | { 73 | return $this->patch; 74 | } 75 | 76 | public function getTrace(): ?Operation 77 | { 78 | return $this->trace; 79 | } 80 | 81 | public function getServers(): ?array 82 | { 83 | return $this->servers; 84 | } 85 | 86 | public function getParameters(): array 87 | { 88 | return $this->parameters; 89 | } 90 | 91 | public function withRef(string $ref): self 92 | { 93 | $clone = clone $this; 94 | $clone->ref = $ref; 95 | 96 | return $clone; 97 | } 98 | 99 | public function withSummary(string $summary): self 100 | { 101 | $clone = clone $this; 102 | $clone->summary = $summary; 103 | 104 | return $clone; 105 | } 106 | 107 | public function withDescription(string $description): self 108 | { 109 | $clone = clone $this; 110 | $clone->description = $description; 111 | 112 | return $clone; 113 | } 114 | 115 | public function withGet(?Operation $get): self 116 | { 117 | $clone = clone $this; 118 | $clone->get = $get; 119 | 120 | return $clone; 121 | } 122 | 123 | public function withPut(?Operation $put): self 124 | { 125 | $clone = clone $this; 126 | $clone->put = $put; 127 | 128 | return $clone; 129 | } 130 | 131 | public function withPost(?Operation $post): self 132 | { 133 | $clone = clone $this; 134 | $clone->post = $post; 135 | 136 | return $clone; 137 | } 138 | 139 | public function withDelete(?Operation $delete): self 140 | { 141 | $clone = clone $this; 142 | $clone->delete = $delete; 143 | 144 | return $clone; 145 | } 146 | 147 | public function withOptions(Operation $options): self 148 | { 149 | $clone = clone $this; 150 | $clone->options = $options; 151 | 152 | return $clone; 153 | } 154 | 155 | public function withHead(Operation $head): self 156 | { 157 | $clone = clone $this; 158 | $clone->head = $head; 159 | 160 | return $clone; 161 | } 162 | 163 | public function withPatch(?Operation $patch): self 164 | { 165 | $clone = clone $this; 166 | $clone->patch = $patch; 167 | 168 | return $clone; 169 | } 170 | 171 | public function withTrace(Operation $trace): self 172 | { 173 | $clone = clone $this; 174 | $clone->trace = $trace; 175 | 176 | return $clone; 177 | } 178 | 179 | public function withServers(?array $servers = null): self 180 | { 181 | $clone = clone $this; 182 | $clone->servers = $servers; 183 | 184 | return $clone; 185 | } 186 | 187 | public function withParameters(array $parameters): self 188 | { 189 | $clone = clone $this; 190 | $clone->parameters = $parameters; 191 | 192 | return $clone; 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /Model/Paths.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class Paths 17 | { 18 | private array $paths = []; 19 | 20 | public function addPath(string $path, PathItem $pathItem): void 21 | { 22 | $this->paths[$path] = $pathItem; 23 | 24 | ksort($this->paths); 25 | } 26 | 27 | public function getPath(string $path): ?PathItem 28 | { 29 | return $this->paths[$path] ?? null; 30 | } 31 | 32 | public function getPaths(): array 33 | { 34 | return $this->paths; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Model/Reference.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | use Symfony\Component\Serializer\Annotation\SerializedName; 17 | 18 | final class Reference 19 | { 20 | use ExtensionTrait; 21 | 22 | public function __construct(private string $ref, private ?string $summary = null, private ?string $description = null) 23 | { 24 | } 25 | 26 | public function getSummary(): ?string 27 | { 28 | return $this->summary; 29 | } 30 | 31 | public function withSummary(string $summary): self 32 | { 33 | $clone = clone $this; 34 | $clone->summary = $summary; 35 | 36 | return $clone; 37 | } 38 | 39 | public function getDescription(): ?string 40 | { 41 | return $this->description; 42 | } 43 | 44 | public function withDescription(string $description): self 45 | { 46 | $clone = clone $this; 47 | $clone->description = $description; 48 | 49 | return $clone; 50 | } 51 | 52 | #[SerializedName('$ref')] 53 | public function getRef(): string 54 | { 55 | return $this->ref; 56 | } 57 | 58 | public function withRef(string $ref): self 59 | { 60 | $clone = clone $this; 61 | $clone->ref = $ref; 62 | 63 | return $clone; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Model/RequestBody.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class RequestBody 17 | { 18 | use ExtensionTrait; 19 | 20 | public function __construct(private string $description = '', private ?\ArrayObject $content = null, private bool $required = false) 21 | { 22 | } 23 | 24 | public function getDescription(): string 25 | { 26 | return $this->description; 27 | } 28 | 29 | public function getContent(): \ArrayObject 30 | { 31 | return $this->content; 32 | } 33 | 34 | public function getRequired(): bool 35 | { 36 | return $this->required; 37 | } 38 | 39 | public function withDescription(string $description): self 40 | { 41 | $clone = clone $this; 42 | $clone->description = $description; 43 | 44 | return $clone; 45 | } 46 | 47 | public function withContent(\ArrayObject $content): self 48 | { 49 | $clone = clone $this; 50 | $clone->content = $content; 51 | 52 | return $clone; 53 | } 54 | 55 | public function withRequired(bool $required): self 56 | { 57 | $clone = clone $this; 58 | $clone->required = $required; 59 | 60 | return $clone; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Model/Response.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class Response 17 | { 18 | use ExtensionTrait; 19 | 20 | public function __construct(private string $description = '', private ?\ArrayObject $content = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $links = null) 21 | { 22 | } 23 | 24 | public function getDescription(): string 25 | { 26 | return $this->description; 27 | } 28 | 29 | public function getContent(): ?\ArrayObject 30 | { 31 | return $this->content; 32 | } 33 | 34 | public function getHeaders(): ?\ArrayObject 35 | { 36 | return $this->headers; 37 | } 38 | 39 | public function getLinks(): ?\ArrayObject 40 | { 41 | return $this->links; 42 | } 43 | 44 | public function withDescription(string $description): self 45 | { 46 | $clone = clone $this; 47 | $clone->description = $description; 48 | 49 | return $clone; 50 | } 51 | 52 | public function withContent(\ArrayObject $content): self 53 | { 54 | $clone = clone $this; 55 | $clone->content = $content; 56 | 57 | return $clone; 58 | } 59 | 60 | public function withHeaders(\ArrayObject $headers): self 61 | { 62 | $clone = clone $this; 63 | $clone->headers = $headers; 64 | 65 | return $clone; 66 | } 67 | 68 | public function withLinks(\ArrayObject $links): self 69 | { 70 | $clone = clone $this; 71 | $clone->links = $links; 72 | 73 | return $clone; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Model/Schema.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | use ApiPlatform\JsonSchema\Schema as JsonSchema; 17 | 18 | final class Schema extends \ArrayObject 19 | { 20 | use ExtensionTrait; 21 | private readonly JsonSchema $schema; 22 | 23 | public function __construct(private $discriminator = null, private bool $readOnly = false, private bool $writeOnly = false, private ?string $xml = null, private $externalDocs = null, private $example = null, private bool $deprecated = false) 24 | { 25 | $this->schema = new JsonSchema(); 26 | 27 | parent::__construct([]); 28 | } 29 | 30 | public function setDefinitions(array $definitions): void 31 | { 32 | $this->schema->setDefinitions(new \ArrayObject($definitions)); 33 | } 34 | 35 | /** 36 | * {@inheritdoc} 37 | */ 38 | public function getArrayCopy(): array 39 | { 40 | $schema = parent::getArrayCopy(); 41 | unset($schema['schema']); 42 | 43 | return $schema; 44 | } 45 | 46 | public function getDefinitions(): \ArrayObject 47 | { 48 | return new \ArrayObject(array_merge($this->schema->getArrayCopy(true), $this->getArrayCopy())); 49 | } 50 | 51 | public function getDiscriminator() 52 | { 53 | return $this->discriminator; 54 | } 55 | 56 | public function getReadOnly(): bool 57 | { 58 | return $this->readOnly; 59 | } 60 | 61 | public function getWriteOnly(): bool 62 | { 63 | return $this->writeOnly; 64 | } 65 | 66 | public function getXml(): string 67 | { 68 | return $this->xml; 69 | } 70 | 71 | public function getExternalDocs() 72 | { 73 | return $this->externalDocs; 74 | } 75 | 76 | public function getExample() 77 | { 78 | return $this->example; 79 | } 80 | 81 | public function getDeprecated(): bool 82 | { 83 | return $this->deprecated; 84 | } 85 | 86 | public function withDiscriminator($discriminator): self 87 | { 88 | $clone = clone $this; 89 | $clone->discriminator = $discriminator; 90 | 91 | return $clone; 92 | } 93 | 94 | public function withReadOnly(bool $readOnly): self 95 | { 96 | $clone = clone $this; 97 | $clone->readOnly = $readOnly; 98 | 99 | return $clone; 100 | } 101 | 102 | public function withWriteOnly(bool $writeOnly): self 103 | { 104 | $clone = clone $this; 105 | $clone->writeOnly = $writeOnly; 106 | 107 | return $clone; 108 | } 109 | 110 | public function withXml(string $xml): self 111 | { 112 | $clone = clone $this; 113 | $clone->xml = $xml; 114 | 115 | return $clone; 116 | } 117 | 118 | public function withExternalDocs($externalDocs): self 119 | { 120 | $clone = clone $this; 121 | $clone->externalDocs = $externalDocs; 122 | 123 | return $clone; 124 | } 125 | 126 | public function withExample($example): self 127 | { 128 | $clone = clone $this; 129 | $clone->example = $example; 130 | 131 | return $clone; 132 | } 133 | 134 | public function withDeprecated(bool $deprecated): self 135 | { 136 | $clone = clone $this; 137 | $clone->deprecated = $deprecated; 138 | 139 | return $clone; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /Model/SecurityScheme.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class SecurityScheme 17 | { 18 | use ExtensionTrait; 19 | 20 | public function __construct(private ?string $type = null, private string $description = '', private ?string $name = null, private ?string $in = null, private ?string $scheme = null, private ?string $bearerFormat = null, private ?OAuthFlows $flows = null, private ?string $openIdConnectUrl = null) 21 | { 22 | } 23 | 24 | public function getType(): string 25 | { 26 | return $this->type; 27 | } 28 | 29 | public function getDescription(): string 30 | { 31 | return $this->description; 32 | } 33 | 34 | public function getName(): ?string 35 | { 36 | return $this->name; 37 | } 38 | 39 | public function getIn(): ?string 40 | { 41 | return $this->in; 42 | } 43 | 44 | public function getScheme(): ?string 45 | { 46 | return $this->scheme; 47 | } 48 | 49 | public function getBearerFormat(): ?string 50 | { 51 | return $this->bearerFormat; 52 | } 53 | 54 | public function getFlows(): ?OAuthFlows 55 | { 56 | return $this->flows; 57 | } 58 | 59 | public function getOpenIdConnectUrl(): ?string 60 | { 61 | return $this->openIdConnectUrl; 62 | } 63 | 64 | public function withType(string $type): self 65 | { 66 | $clone = clone $this; 67 | $clone->type = $type; 68 | 69 | return $clone; 70 | } 71 | 72 | public function withDescription(string $description): self 73 | { 74 | $clone = clone $this; 75 | $clone->description = $description; 76 | 77 | return $clone; 78 | } 79 | 80 | public function withName(string $name): self 81 | { 82 | $clone = clone $this; 83 | $clone->name = $name; 84 | 85 | return $clone; 86 | } 87 | 88 | public function withIn(string $in): self 89 | { 90 | $clone = clone $this; 91 | $clone->in = $in; 92 | 93 | return $clone; 94 | } 95 | 96 | public function withScheme(string $scheme): self 97 | { 98 | $clone = clone $this; 99 | $clone->scheme = $scheme; 100 | 101 | return $clone; 102 | } 103 | 104 | public function withBearerFormat(string $bearerFormat): self 105 | { 106 | $clone = clone $this; 107 | $clone->bearerFormat = $bearerFormat; 108 | 109 | return $clone; 110 | } 111 | 112 | public function withFlows(OAuthFlows $flows): self 113 | { 114 | $clone = clone $this; 115 | $clone->flows = $flows; 116 | 117 | return $clone; 118 | } 119 | 120 | public function withOpenIdConnectUrl(string $openIdConnectUrl): self 121 | { 122 | $clone = clone $this; 123 | $clone->openIdConnectUrl = $openIdConnectUrl; 124 | 125 | return $clone; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Model/Server.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Model; 15 | 16 | final class Server 17 | { 18 | use ExtensionTrait; 19 | 20 | public function __construct(private string $url, private string $description = '', private ?\ArrayObject $variables = null) 21 | { 22 | } 23 | 24 | public function getUrl(): string 25 | { 26 | return $this->url; 27 | } 28 | 29 | public function getDescription(): string 30 | { 31 | return $this->description; 32 | } 33 | 34 | public function getVariables(): ?\ArrayObject 35 | { 36 | return $this->variables; 37 | } 38 | 39 | public function withUrl(string $url): self 40 | { 41 | $clone = clone $this; 42 | $clone->url = $url; 43 | 44 | return $clone; 45 | } 46 | 47 | public function withDescription(string $description): self 48 | { 49 | $clone = clone $this; 50 | $clone->description = $description; 51 | 52 | return $clone; 53 | } 54 | 55 | public function withVariables(\ArrayObject $variables): self 56 | { 57 | $clone = clone $this; 58 | $clone->variables = $variables; 59 | 60 | return $clone; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /OpenApi.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi; 15 | 16 | use ApiPlatform\OpenApi\Model\Components; 17 | use ApiPlatform\OpenApi\Model\ExtensionTrait; 18 | use ApiPlatform\OpenApi\Model\Info; 19 | use ApiPlatform\OpenApi\Model\Paths; 20 | 21 | final class OpenApi 22 | { 23 | use ExtensionTrait; 24 | 25 | // We're actually supporting 3.1 but swagger ui has a version constraint 26 | // public const VERSION = '3.1.0'; 27 | public const VERSION = '3.0.0'; 28 | 29 | private string $openapi = self::VERSION; 30 | 31 | public function __construct(private Info $info, private array $servers, private Paths $paths, private ?Components $components = null, private array $security = [], private array $tags = [], private $externalDocs = null, private ?string $jsonSchemaDialect = null, private readonly ?\ArrayObject $webhooks = null) 32 | { 33 | } 34 | 35 | public function getOpenapi(): string 36 | { 37 | return $this->openapi; 38 | } 39 | 40 | public function getInfo(): Info 41 | { 42 | return $this->info; 43 | } 44 | 45 | public function getServers(): array 46 | { 47 | return $this->servers; 48 | } 49 | 50 | public function getPaths(): Paths 51 | { 52 | return $this->paths; 53 | } 54 | 55 | public function getComponents(): Components 56 | { 57 | return $this->components; 58 | } 59 | 60 | public function getSecurity(): array 61 | { 62 | return $this->security; 63 | } 64 | 65 | public function getTags(): array 66 | { 67 | return $this->tags; 68 | } 69 | 70 | public function getExternalDocs(): ?array 71 | { 72 | return $this->externalDocs; 73 | } 74 | 75 | public function getJsonSchemaDialect(): ?string 76 | { 77 | return $this->jsonSchemaDialect; 78 | } 79 | 80 | public function getWebhooks(): ?\ArrayObject 81 | { 82 | return $this->webhooks; 83 | } 84 | 85 | public function withOpenapi(string $openapi): self 86 | { 87 | $clone = clone $this; 88 | $clone->openapi = $openapi; 89 | 90 | return $clone; 91 | } 92 | 93 | public function withInfo(Info $info): self 94 | { 95 | $clone = clone $this; 96 | $clone->info = $info; 97 | 98 | return $clone; 99 | } 100 | 101 | public function withServers(array $servers): self 102 | { 103 | $clone = clone $this; 104 | $clone->servers = $servers; 105 | 106 | return $clone; 107 | } 108 | 109 | public function withPaths(Paths $paths): self 110 | { 111 | $clone = clone $this; 112 | $clone->paths = $paths; 113 | 114 | return $clone; 115 | } 116 | 117 | public function withComponents(Components $components): self 118 | { 119 | $clone = clone $this; 120 | $clone->components = $components; 121 | 122 | return $clone; 123 | } 124 | 125 | public function withSecurity(array $security): self 126 | { 127 | $clone = clone $this; 128 | $clone->security = $security; 129 | 130 | return $clone; 131 | } 132 | 133 | public function withTags(array $tags): self 134 | { 135 | $clone = clone $this; 136 | $clone->tags = $tags; 137 | 138 | return $clone; 139 | } 140 | 141 | public function withExternalDocs(array $externalDocs): self 142 | { 143 | $clone = clone $this; 144 | $clone->externalDocs = $externalDocs; 145 | 146 | return $clone; 147 | } 148 | 149 | public function withJsonSchemaDialect(?string $jsonSchemaDialect): self 150 | { 151 | $clone = clone $this; 152 | $clone->jsonSchemaDialect = $jsonSchemaDialect; 153 | 154 | return $clone; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Options.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi; 15 | 16 | final class Options 17 | { 18 | public function __construct(private readonly string $title, private readonly string $description = '', private readonly string $version = '', private readonly bool $oAuthEnabled = false, private readonly ?string $oAuthType = null, private readonly ?string $oAuthFlow = null, private readonly ?string $oAuthTokenUrl = null, private readonly ?string $oAuthAuthorizationUrl = null, private readonly ?string $oAuthRefreshUrl = null, private readonly array $oAuthScopes = [], private readonly array $apiKeys = [], private readonly ?string $contactName = null, private readonly ?string $contactUrl = null, private readonly ?string $contactEmail = null, private readonly ?string $termsOfService = null, private readonly ?string $licenseName = null, private readonly ?string $licenseUrl = null) 19 | { 20 | } 21 | 22 | public function getTitle(): string 23 | { 24 | return $this->title; 25 | } 26 | 27 | public function getDescription(): string 28 | { 29 | return $this->description; 30 | } 31 | 32 | public function getVersion(): string 33 | { 34 | return $this->version; 35 | } 36 | 37 | public function getOAuthEnabled(): bool 38 | { 39 | return $this->oAuthEnabled; 40 | } 41 | 42 | public function getOAuthType(): ?string 43 | { 44 | return $this->oAuthType; 45 | } 46 | 47 | public function getOAuthFlow(): ?string 48 | { 49 | return $this->oAuthFlow; 50 | } 51 | 52 | public function getOAuthTokenUrl(): ?string 53 | { 54 | return $this->oAuthTokenUrl; 55 | } 56 | 57 | public function getOAuthAuthorizationUrl(): ?string 58 | { 59 | return $this->oAuthAuthorizationUrl; 60 | } 61 | 62 | public function getOAuthRefreshUrl(): ?string 63 | { 64 | return $this->oAuthRefreshUrl; 65 | } 66 | 67 | public function getOAuthScopes(): array 68 | { 69 | return $this->oAuthScopes; 70 | } 71 | 72 | public function getApiKeys(): array 73 | { 74 | return $this->apiKeys; 75 | } 76 | 77 | public function getContactName(): ?string 78 | { 79 | return $this->contactName; 80 | } 81 | 82 | public function getContactUrl(): ?string 83 | { 84 | return $this->contactUrl; 85 | } 86 | 87 | public function getContactEmail(): ?string 88 | { 89 | return $this->contactEmail; 90 | } 91 | 92 | public function getTermsOfService(): ?string 93 | { 94 | return $this->termsOfService; 95 | } 96 | 97 | public function getLicenseName(): ?string 98 | { 99 | return $this->licenseName; 100 | } 101 | 102 | public function getLicenseUrl(): ?string 103 | { 104 | return $this->licenseUrl; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # API Platform - OpenAPI 2 | 3 | Models to build and serialize an OpenAPI specification. 4 | 5 | ## Resources 6 | 7 | - [Documentation](https://api-platform.com/docs) 8 | - [Report issues](https://github.com/api-platform/core/issues) and [send Pull Requests](https://github.com/api-platform/core/pulls) on the [main API Platform repository](https://github.com/api-platform/core) 9 | -------------------------------------------------------------------------------- /Serializer/ApiGatewayNormalizer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Serializer; 15 | 16 | use Symfony\Component\Serializer\Exception\UnexpectedValueException; 17 | use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface as BaseCacheableSupportsMethodInterface; 18 | use Symfony\Component\Serializer\Normalizer\NormalizerInterface; 19 | use Symfony\Component\Serializer\Serializer; 20 | 21 | /** 22 | * Removes features unsupported by Amazon API Gateway. 23 | * 24 | * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-known-issues.html 25 | * 26 | * @internal 27 | * 28 | * @author Vincent Chalamon 29 | */ 30 | final class ApiGatewayNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface 31 | { 32 | public const API_GATEWAY = 'api_gateway'; 33 | private array $defaultContext = [ 34 | self::API_GATEWAY => false, 35 | ]; 36 | 37 | public function __construct(private readonly NormalizerInterface $documentationNormalizer, $defaultContext = []) 38 | { 39 | $this->defaultContext = array_merge($this->defaultContext, $defaultContext); 40 | } 41 | 42 | /** 43 | * {@inheritdoc} 44 | * 45 | * @throws UnexpectedValueException 46 | */ 47 | public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null 48 | { 49 | $data = $this->documentationNormalizer->normalize($object, $format, $context); 50 | if (!\is_array($data)) { 51 | throw new UnexpectedValueException('Expected data to be an array'); 52 | } 53 | 54 | if (!($context[self::API_GATEWAY] ?? $this->defaultContext[self::API_GATEWAY])) { 55 | return $data; 56 | } 57 | 58 | if (empty($data['basePath'])) { 59 | $data['basePath'] = '/'; 60 | } 61 | 62 | foreach ($data['paths'] as $path => $operations) { 63 | foreach ($operations as $operation => $options) { 64 | if (isset($options['parameters'])) { 65 | foreach ($options['parameters'] as $key => $parameter) { 66 | if (!preg_match('/^[a-zA-Z0-9._$-]+$/', (string) $parameter['name'])) { 67 | unset($data['paths'][$path][$operation]['parameters'][$key]); 68 | } 69 | if (isset($parameter['schema']['$ref']) && $this->isLocalRef($parameter['schema']['$ref'])) { 70 | $data['paths'][$path][$operation]['parameters'][$key]['schema']['$ref'] = $this->normalizeRef($parameter['schema']['$ref']); 71 | } 72 | } 73 | $data['paths'][$path][$operation]['parameters'] = array_values($data['paths'][$path][$operation]['parameters']); 74 | } 75 | if (isset($options['responses'])) { 76 | foreach ($options['responses'] as $statusCode => $response) { 77 | if (isset($response['schema']['items']['$ref']) && $this->isLocalRef($response['schema']['items']['$ref'])) { 78 | $data['paths'][$path][$operation]['responses'][$statusCode]['schema']['items']['$ref'] = $this->normalizeRef($response['schema']['items']['$ref']); 79 | } 80 | if (isset($response['schema']['$ref']) && $this->isLocalRef($response['schema']['$ref'])) { 81 | $data['paths'][$path][$operation]['responses'][$statusCode]['schema']['$ref'] = $this->normalizeRef($response['schema']['$ref']); 82 | } 83 | } 84 | } 85 | } 86 | } 87 | 88 | foreach ($data['components']['schemas'] as $definition => $options) { 89 | if (!isset($options['properties'])) { 90 | continue; 91 | } 92 | foreach ($options['properties'] as $property => $propertyOptions) { 93 | if (isset($propertyOptions['readOnly'])) { 94 | unset($data['components']['schemas'][$definition]['properties'][$property]['readOnly']); 95 | } 96 | if (isset($propertyOptions['$ref']) && $this->isLocalRef($propertyOptions['$ref'])) { 97 | $data['components']['schemas'][$definition]['properties'][$property]['$ref'] = $this->normalizeRef($propertyOptions['$ref']); 98 | } 99 | if (isset($propertyOptions['items']['$ref']) && $this->isLocalRef($propertyOptions['items']['$ref'])) { 100 | $data['components']['schemas'][$definition]['properties'][$property]['items']['$ref'] = $this->normalizeRef($propertyOptions['items']['$ref']); 101 | } 102 | } 103 | } 104 | 105 | // $data['definitions'] is an instance of \ArrayObject 106 | foreach (array_keys($data['components']['schemas']) as $definition) { 107 | if (!preg_match('/^[0-9A-Za-z]+$/', (string) $definition)) { 108 | $data['components']['schemas'][preg_replace('/[^0-9A-Za-z]/', '', (string) $definition)] = $data['components']['schemas'][$definition]; 109 | unset($data['components']['schemas'][$definition]); 110 | } 111 | } 112 | 113 | return $data; 114 | } 115 | 116 | /** 117 | * {@inheritdoc} 118 | */ 119 | public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool 120 | { 121 | return $this->documentationNormalizer->supportsNormalization($data, $format); 122 | } 123 | 124 | public function getSupportedTypes($format): array 125 | { 126 | // @deprecated remove condition when support for symfony versions under 6.3 is dropped 127 | if (!method_exists($this->documentationNormalizer, 'getSupportedTypes')) { 128 | return ['*' => $this->documentationNormalizer instanceof BaseCacheableSupportsMethodInterface && $this->documentationNormalizer->hasCacheableSupportsMethod()]; 129 | } 130 | 131 | return $this->documentationNormalizer->getSupportedTypes($format); 132 | } 133 | 134 | /** 135 | * {@inheritdoc} 136 | */ 137 | public function hasCacheableSupportsMethod(): bool 138 | { 139 | if (method_exists(Serializer::class, 'getSupportedTypes')) { 140 | trigger_deprecation( 141 | 'api-platform/core', 142 | '3.1', 143 | 'The "%s()" method is deprecated, use "getSupportedTypes()" instead.', 144 | __METHOD__ 145 | ); 146 | } 147 | 148 | return $this->documentationNormalizer instanceof BaseCacheableSupportsMethodInterface && $this->documentationNormalizer->hasCacheableSupportsMethod(); 149 | } 150 | 151 | private function isLocalRef(string $ref): bool 152 | { 153 | return str_starts_with($ref, '#/'); 154 | } 155 | 156 | private function normalizeRef(string $ref): string 157 | { 158 | $refParts = explode('/', $ref); 159 | 160 | $schemaName = array_pop($refParts); 161 | $schemaName = preg_replace('/[^0-9A-Za-z]/', '', $schemaName); 162 | $refParts[] = $schemaName; 163 | 164 | return implode('/', $refParts); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /Serializer/CacheableSupportsMethodInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Serializer; 15 | 16 | use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface as BaseCacheableSupportsMethodInterface; 17 | use Symfony\Component\Serializer\Serializer; 18 | 19 | if (method_exists(Serializer::class, 'getSupportedTypes')) { 20 | /** 21 | * Backward compatibility layer for getSupportedTypes(). 22 | * 23 | * @internal 24 | * 25 | * @author Kévin Dunglas 26 | * 27 | * @todo remove this interface when dropping support for Serializer < 6.3 28 | */ 29 | interface CacheableSupportsMethodInterface 30 | { 31 | public function getSupportedTypes(?string $format): array; 32 | } 33 | } else { 34 | /** 35 | * Backward compatibility layer for NormalizerInterface::getSupportedTypes(). 36 | * 37 | * @internal 38 | * 39 | * @author Kévin Dunglas 40 | * 41 | * @todo remove this interface when dropping support for Serializer < 6.3 42 | */ 43 | interface CacheableSupportsMethodInterface extends BaseCacheableSupportsMethodInterface 44 | { 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Serializer/NormalizeOperationNameTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Serializer; 15 | 16 | /** 17 | * Transforms the operation name to a readable operation id. 18 | * 19 | * @author soyuka 20 | */ 21 | trait NormalizeOperationNameTrait 22 | { 23 | private function normalizeOperationName(string $operationName): string 24 | { 25 | return preg_replace('/^_/', '', str_replace(['/', '{._format}', '{', '}'], ['', '', '_', ''], $operationName)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Serializer/OpenApiNormalizer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Serializer; 15 | 16 | use ApiPlatform\OpenApi\Model\Paths; 17 | use ApiPlatform\OpenApi\OpenApi; 18 | use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; 19 | use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; 20 | use Symfony\Component\Serializer\Normalizer\NormalizerInterface; 21 | use Symfony\Component\Serializer\Serializer; 22 | 23 | /** 24 | * Generates an OpenAPI v3 specification. 25 | */ 26 | final class OpenApiNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface 27 | { 28 | public const FORMAT = 'json'; 29 | private const EXTENSION_PROPERTIES_KEY = 'extensionProperties'; 30 | 31 | public function __construct(private readonly NormalizerInterface $decorated) 32 | { 33 | } 34 | 35 | /** 36 | * {@inheritdoc} 37 | */ 38 | public function normalize(mixed $object, ?string $format = null, array $context = []): array 39 | { 40 | $pathsCallback = static fn ($innerObject): array => $innerObject instanceof Paths ? $innerObject->getPaths() : []; 41 | $context[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS] = true; 42 | $context[AbstractObjectNormalizer::SKIP_NULL_VALUES] = true; 43 | $context[AbstractNormalizer::CALLBACKS] = [ 44 | 'paths' => $pathsCallback, 45 | ]; 46 | 47 | return $this->recursiveClean($this->decorated->normalize($object, $format, $context)); 48 | } 49 | 50 | private function recursiveClean(array $data): array 51 | { 52 | foreach ($data as $key => $value) { 53 | if (self::EXTENSION_PROPERTIES_KEY === $key) { 54 | foreach ($data[self::EXTENSION_PROPERTIES_KEY] as $extensionPropertyKey => $extensionPropertyValue) { 55 | $data[$extensionPropertyKey] = $extensionPropertyValue; 56 | } 57 | continue; 58 | } 59 | 60 | if (\is_array($value)) { 61 | $data[$key] = $this->recursiveClean($value); 62 | } 63 | } 64 | 65 | unset($data[self::EXTENSION_PROPERTIES_KEY]); 66 | 67 | return $data; 68 | } 69 | 70 | /** 71 | * {@inheritdoc} 72 | */ 73 | public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool 74 | { 75 | return self::FORMAT === $format && $data instanceof OpenApi; 76 | } 77 | 78 | public function getSupportedTypes($format): array 79 | { 80 | return self::FORMAT === $format ? [OpenApi::class => true] : []; 81 | } 82 | 83 | public function hasCacheableSupportsMethod(): bool 84 | { 85 | if (method_exists(Serializer::class, 'getSupportedTypes')) { 86 | trigger_deprecation( 87 | 'api-platform/core', 88 | '3.1', 89 | 'The "%s()" method is deprecated, use "getSupportedTypes()" instead.', 90 | __METHOD__ 91 | ); 92 | } 93 | 94 | return true; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Tests/Factory/OpenApiFactoryTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Tests\Factory; 15 | 16 | use ApiPlatform\JsonSchema\Schema; 17 | use ApiPlatform\JsonSchema\SchemaFactory; 18 | use ApiPlatform\JsonSchema\TypeFactory; 19 | use ApiPlatform\Metadata\ApiProperty; 20 | use ApiPlatform\Metadata\ApiResource; 21 | use ApiPlatform\Metadata\Delete; 22 | use ApiPlatform\Metadata\Get; 23 | use ApiPlatform\Metadata\GetCollection; 24 | use ApiPlatform\Metadata\HttpOperation; 25 | use ApiPlatform\Metadata\Link; 26 | use ApiPlatform\Metadata\NotExposed; 27 | use ApiPlatform\Metadata\Operations; 28 | use ApiPlatform\Metadata\Post; 29 | use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; 30 | use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; 31 | use ApiPlatform\Metadata\Property\PropertyNameCollection; 32 | use ApiPlatform\Metadata\Put; 33 | use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; 34 | use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; 35 | use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; 36 | use ApiPlatform\Metadata\Resource\ResourceNameCollection; 37 | use ApiPlatform\OpenApi\Factory\OpenApiFactory; 38 | use ApiPlatform\OpenApi\Model; 39 | use ApiPlatform\OpenApi\Model\Components; 40 | use ApiPlatform\OpenApi\Model\ExternalDocumentation; 41 | use ApiPlatform\OpenApi\Model\Info; 42 | use ApiPlatform\OpenApi\Model\MediaType; 43 | use ApiPlatform\OpenApi\Model\OAuthFlow; 44 | use ApiPlatform\OpenApi\Model\OAuthFlows; 45 | use ApiPlatform\OpenApi\Model\Operation; 46 | use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; 47 | use ApiPlatform\OpenApi\Model\Parameter; 48 | use ApiPlatform\OpenApi\Model\RequestBody; 49 | use ApiPlatform\OpenApi\Model\Response; 50 | use ApiPlatform\OpenApi\Model\Response as OpenApiResponse; 51 | use ApiPlatform\OpenApi\Model\SecurityScheme; 52 | use ApiPlatform\OpenApi\Model\Server; 53 | use ApiPlatform\OpenApi\OpenApi; 54 | use ApiPlatform\OpenApi\Options; 55 | use ApiPlatform\OpenApi\Tests\Fixtures\Dummy; 56 | use ApiPlatform\OpenApi\Tests\Fixtures\DummyFilter; 57 | use ApiPlatform\OpenApi\Tests\Fixtures\OutputDto; 58 | use ApiPlatform\State\Pagination\PaginationOptions; 59 | use PHPUnit\Framework\TestCase; 60 | use Prophecy\Argument; 61 | use Prophecy\PhpUnit\ProphecyTrait; 62 | use Psr\Container\ContainerInterface; 63 | use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; 64 | use Symfony\Component\PropertyInfo\Type; 65 | use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; 66 | 67 | class OpenApiFactoryTest extends TestCase 68 | { 69 | use ExpectDeprecationTrait; 70 | use ProphecyTrait; 71 | 72 | private const OPERATION_FORMATS = [ 73 | 'input_formats' => ['jsonld' => ['application/ld+json']], 74 | 'output_formats' => ['jsonld' => ['application/ld+json']], 75 | ]; 76 | 77 | public function testInvoke(): void 78 | { 79 | $baseOperation = (new HttpOperation())->withTypes(['http://schema.example.com/Dummy'])->withInputFormats(self::OPERATION_FORMATS['input_formats'])->withOutputFormats(self::OPERATION_FORMATS['output_formats'])->withClass(Dummy::class)->withOutput([ 80 | 'class' => OutputDto::class, 81 | ])->withPaginationClientItemsPerPage(true)->withShortName('Dummy')->withDescription('This is a dummy'); 82 | $dummyResource = (new ApiResource())->withOperations(new Operations([ 83 | 'ignored' => new NotExposed(), 84 | 'ignoredWithUriTemplate' => (new NotExposed())->withUriTemplate('/dummies/{id}'), 85 | 'getDummyItem' => (new Get())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), 86 | 'putDummyItem' => (new Put())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), 87 | 'deleteDummyItem' => (new Delete())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), 88 | 'customDummyItem' => (new HttpOperation())->withMethod(HttpOperation::METHOD_HEAD)->withUriTemplate('/foo/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])])->withOpenapi(new OpenApiOperation( 89 | tags: ['Dummy', 'Profile'], 90 | responses: [ 91 | '202' => new OpenApiResponse( 92 | description: 'Success', 93 | content: new \ArrayObject([ 94 | 'application/json' => [ 95 | 'schema' => ['$ref' => '#/components/schemas/Dummy'], 96 | ], 97 | ]), 98 | headers: new \ArrayObject([ 99 | 'Foo' => ['description' => 'A nice header', 'schema' => ['type' => 'integer']], 100 | ]), 101 | links: new \ArrayObject([ 102 | 'Foo' => ['$ref' => '#/components/schemas/Dummy'], 103 | ]), 104 | ), 105 | '205' => new OpenApiResponse(), 106 | ], 107 | description: 'Custom description', 108 | externalDocs: new ExternalDocumentation( 109 | description: 'See also', 110 | url: 'http://schema.example.com/Dummy', 111 | ), 112 | parameters: [ 113 | new Parameter( 114 | name: 'param', 115 | in: 'path', 116 | description: 'Test parameter', 117 | required: true, 118 | ), 119 | new Parameter( 120 | name: 'id', 121 | in: 'path', 122 | description: 'Replace parameter', 123 | required: true, 124 | schema: ['type' => 'string', 'format' => 'uuid'], 125 | ), 126 | ], 127 | requestBody: new RequestBody( 128 | description: 'Custom request body', 129 | content: new \ArrayObject([ 130 | 'multipart/form-data' => [ 131 | 'schema' => [ 132 | 'type' => 'object', 133 | 'properties' => [ 134 | 'file' => [ 135 | 'type' => 'string', 136 | 'format' => 'binary', 137 | ], 138 | ], 139 | ], 140 | ], 141 | ]), 142 | required: true, 143 | ), 144 | extensionProperties: ['x-visibility' => 'hide'], 145 | )), 146 | 'custom-http-verb' => (new HttpOperation())->withMethod('TEST')->withOperation($baseOperation), 147 | 'withRoutePrefix' => (new GetCollection())->withUriTemplate('/dummies')->withRoutePrefix('/prefix')->withOperation($baseOperation), 148 | 'formatsDummyItem' => (new Put())->withOperation($baseOperation)->withUriTemplate('/formatted/{id}')->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])])->withInputFormats(['json' => ['application/json'], 'csv' => ['text/csv']])->withOutputFormats(['json' => ['application/json'], 'csv' => ['text/csv']]), 149 | 'getDummyCollection' => (new GetCollection())->withUriTemplate('/dummies')->withOpenapi(new OpenApiOperation( 150 | parameters: [ 151 | new Parameter( 152 | name: 'page', 153 | in: 'query', 154 | description: 'Test modified collection page number', 155 | required: false, 156 | allowEmptyValue: true, 157 | schema: ['type' => 'integer', 'default' => 1], 158 | ), 159 | ], 160 | ))->withOperation($baseOperation), 161 | 'postDummyCollection' => (new Post())->withUriTemplate('/dummies')->withOperation($baseOperation), 162 | // Filtered 163 | 'filteredDummyCollection' => (new GetCollection())->withUriTemplate('/filtered')->withFilters(['f1', 'f2', 'f3', 'f4', 'f5'])->withOperation($baseOperation), 164 | // Paginated 165 | 'paginatedDummyCollection' => (new GetCollection())->withUriTemplate('/paginated') 166 | ->withPaginationClientEnabled(true) 167 | ->withPaginationClientItemsPerPage(true) 168 | ->withPaginationItemsPerPage(20) 169 | ->withPaginationMaximumItemsPerPage(80) 170 | ->withOperation($baseOperation), 171 | 'postDummyCollectionWithRequestBody' => (new Post())->withUriTemplate('/dummiesRequestBody')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( 172 | requestBody: new RequestBody( 173 | description: 'List of Ids', 174 | content: new \ArrayObject([ 175 | 'application/json' => [ 176 | 'schema' => [ 177 | 'type' => 'object', 178 | 'properties' => [ 179 | 'ids' => [ 180 | 'type' => 'array', 181 | 'items' => ['type' => 'string'], 182 | 'example' => [ 183 | '1e677e04-d461-4389-bedc-6d1b665cc9d6', 184 | '01111b43-f53a-4d50-8639-148850e5da19', 185 | ], 186 | ], 187 | ], 188 | ], 189 | ], 190 | ]), 191 | ), 192 | )), 193 | 'putDummyItemWithResponse' => (new Put())->withUriTemplate('/dummyitems/{id}')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( 194 | responses: [ 195 | '200' => new OpenApiResponse( 196 | description: 'Success', 197 | content: new \ArrayObject([ 198 | 'application/json' => [ 199 | 'schema' => ['$ref' => '#/components/schemas/Dummy'], 200 | ], 201 | ]), 202 | headers: new \ArrayObject([ 203 | 'API_KEY' => ['description' => 'Api Key', 'schema' => ['type' => 'string']], 204 | ]), 205 | links: new \ArrayObject([ 206 | 'link' => ['$ref' => '#/components/schemas/Dummy'], 207 | ]), 208 | ), 209 | '400' => new OpenApiResponse( 210 | description: 'Error', 211 | ), 212 | ], 213 | )), 214 | 'getDummyItemImageCollection' => (new GetCollection())->withUriTemplate('/dummyitems/{id}/images')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( 215 | responses: [ 216 | '200' => new OpenApiResponse( 217 | description: 'Success', 218 | ), 219 | ], 220 | )), 221 | 'postDummyItemWithResponse' => (new Post())->withUriTemplate('/dummyitems')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( 222 | responses: [ 223 | '201' => new OpenApiResponse( 224 | description: 'Created', 225 | content: new \ArrayObject([ 226 | 'application/json' => [ 227 | 'schema' => ['$ref' => '#/components/schemas/Dummy'], 228 | ], 229 | ]), 230 | headers: new \ArrayObject([ 231 | 'API_KEY' => ['description' => 'Api Key', 'schema' => ['type' => 'string']], 232 | ]), 233 | links: new \ArrayObject([ 234 | 'link' => ['$ref' => '#/components/schemas/Dummy'], 235 | ]), 236 | ), 237 | '400' => new OpenApiResponse( 238 | description: 'Error', 239 | ), 240 | ], 241 | )), 242 | ]) 243 | ); 244 | 245 | $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); 246 | $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); 247 | 248 | $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); 249 | $resourceCollectionMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [$dummyResource])); 250 | 251 | $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); 252 | $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); 253 | $propertyNameCollectionFactoryProphecy->create(OutputDto::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); 254 | 255 | $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); 256 | $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn( 257 | (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)])->withDescription('This is an id.')->withReadable(true)->withWritable(false)->withIdentifier(true) 258 | ); 259 | $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn( 260 | (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is a name.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) 261 | ); 262 | $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn( 263 | (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an initializable but not writable property.')->withReadable(true)->withWritable(false)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withInitializable(true) 264 | ); 265 | $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn( 266 | (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)])->withDescription('This is a \DateTimeInterface object.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false) 267 | ); 268 | $propertyMetadataFactoryProphecy->create(Dummy::class, 'enum', Argument::any())->shouldBeCalled()->willReturn( 269 | (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an enum.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) 270 | ); 271 | $propertyMetadataFactoryProphecy->create(OutputDto::class, 'id', Argument::any())->shouldBeCalled()->willReturn( 272 | (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)])->withDescription('This is an id.')->withReadable(true)->withWritable(false)->withIdentifier(true) 273 | ); 274 | $propertyMetadataFactoryProphecy->create(OutputDto::class, 'name', Argument::any())->shouldBeCalled()->willReturn( 275 | (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is a name.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) 276 | ); 277 | $propertyMetadataFactoryProphecy->create(OutputDto::class, 'description', Argument::any())->shouldBeCalled()->willReturn( 278 | (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an initializable but not writable property.')->withReadable(true)->withWritable(false)->withReadableLink(true)->withWritableLink(true)->withInitializable(true) 279 | ); 280 | $propertyMetadataFactoryProphecy->create(OutputDto::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn( 281 | (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)])->withDescription('This is a \DateTimeInterface object.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true) 282 | ); 283 | $propertyMetadataFactoryProphecy->create(OutputDto::class, 'enum', Argument::any())->shouldBeCalled()->willReturn( 284 | (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an enum.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) 285 | ); 286 | 287 | $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); 288 | $filters = [ 289 | 'f1' => new DummyFilter(['name' => [ 290 | 'property' => 'name', 291 | 'type' => 'string', 292 | 'required' => true, 293 | 'strategy' => 'exact', 294 | 'openapi' => ['example' => 'bar', 'deprecated' => true, 'allowEmptyValue' => true, 'allowReserved' => true, 'explode' => true], 295 | ]]), 296 | 'f2' => new DummyFilter(['ha' => [ 297 | 'property' => 'foo', 298 | 'type' => 'int', 299 | 'required' => false, 300 | 'strategy' => 'partial', 301 | ]]), 302 | 'f3' => new DummyFilter(['toto' => [ 303 | 'property' => 'name', 304 | 'type' => 'array', 305 | 'is_collection' => true, 306 | 'required' => true, 307 | 'strategy' => 'exact', 308 | ]]), 309 | 'f4' => new DummyFilter(['order[name]' => [ 310 | 'property' => 'name', 311 | 'type' => 'string', 312 | 'required' => false, 313 | 'schema' => [ 314 | 'type' => 'string', 315 | 'enum' => ['asc', 'desc'], 316 | ], 317 | ]]), 318 | ]; 319 | 320 | foreach ($filters as $filterId => $filter) { 321 | $filterLocatorProphecy->has($filterId)->willReturn(true)->shouldBeCalled(); 322 | $filterLocatorProphecy->get($filterId)->willReturn($filter)->shouldBeCalled(); 323 | } 324 | 325 | $filterLocatorProphecy->has('f5')->willReturn(false)->shouldBeCalled(); 326 | 327 | $resourceCollectionMetadataFactory = $resourceCollectionMetadataFactoryProphecy->reveal(); 328 | $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); 329 | 330 | $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); 331 | 332 | $typeFactory = new TypeFactory(); 333 | $schemaFactory = new SchemaFactory($typeFactory, $resourceCollectionMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); 334 | $typeFactory->setSchemaFactory($schemaFactory); 335 | 336 | $factory = new OpenApiFactory( 337 | $resourceNameCollectionFactoryProphecy->reveal(), 338 | $resourceCollectionMetadataFactory, 339 | $propertyNameCollectionFactory, 340 | $propertyMetadataFactory, 341 | $schemaFactory, 342 | $typeFactory, 343 | $filterLocatorProphecy->reveal(), 344 | [], 345 | new Options('Test API', 'This is a test API.', '1.2.3', true, 'oauth2', 'authorizationCode', '/oauth/v2/token', '/oauth/v2/auth', '/oauth/v2/refresh', ['scope param'], [ 346 | 'header' => [ 347 | 'type' => 'header', 348 | 'name' => 'Authorization', 349 | ], 350 | 'query' => [ 351 | 'type' => 'query', 352 | 'name' => 'key', 353 | ], 354 | ]), 355 | new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') 356 | ); 357 | 358 | $dummySchema = new Schema('openapi'); 359 | $dummySchema->setDefinitions(new \ArrayObject([ 360 | 'type' => 'object', 361 | 'description' => 'This is a dummy', 362 | 'externalDocs' => ['url' => 'http://schema.example.com/Dummy'], 363 | 'deprecated' => false, 364 | 'properties' => [ 365 | 'id' => new \ArrayObject([ 366 | 'type' => 'integer', 367 | 'description' => 'This is an id.', 368 | 'readOnly' => true, 369 | ]), 370 | 'name' => new \ArrayObject([ 371 | 'type' => 'string', 372 | 'description' => 'This is a name.', 373 | 'minLength' => 3, 374 | 'maxLength' => 20, 375 | 'pattern' => '^dummyPattern$', 376 | ]), 377 | 'description' => new \ArrayObject([ 378 | 'type' => 'string', 379 | 'description' => 'This is an initializable but not writable property.', 380 | ]), 381 | 'dummy_date' => new \ArrayObject([ 382 | 'type' => 'string', 383 | 'description' => 'This is a \DateTimeInterface object.', 384 | 'format' => 'date-time', 385 | 'nullable' => true, 386 | ]), 387 | 'enum' => new \ArrayObject([ 388 | 'type' => 'string', 389 | 'enum' => ['one', 'two'], 390 | 'example' => 'one', 391 | 'description' => 'This is an enum.', 392 | ]), 393 | ], 394 | ])); 395 | 396 | $openApi = $factory(['base_url' => '/app_dev.php/']); 397 | 398 | $this->assertInstanceOf(OpenApi::class, $openApi); 399 | $this->assertEquals($openApi->getInfo(), new Info('Test API', '1.2.3', 'This is a test API.')); 400 | $this->assertEquals($openApi->getServers(), [new Server('/app_dev.php/')]); 401 | 402 | $components = $openApi->getComponents(); 403 | $this->assertInstanceOf(Components::class, $components); 404 | 405 | $this->assertEquals($components->getSchemas(), new \ArrayObject(['Dummy' => $dummySchema->getDefinitions(), 'Dummy.OutputDto' => $dummySchema->getDefinitions()])); 406 | 407 | $this->assertEquals($components->getSecuritySchemes(), new \ArrayObject([ 408 | 'oauth' => new SecurityScheme('oauth2', 'OAuth 2.0 authorization code Grant', null, null, null, null, new OAuthFlows(null, null, null, new OAuthFlow('/oauth/v2/auth', '/oauth/v2/token', '/oauth/v2/refresh', new \ArrayObject(['scope param'])))), 409 | 'header' => new SecurityScheme('apiKey', 'Value for the Authorization header parameter.', 'Authorization', 'header'), 410 | 'query' => new SecurityScheme('apiKey', 'Value for the key query parameter.', 'key', 'query'), 411 | ])); 412 | 413 | $this->assertEquals([ 414 | ['oauth' => []], 415 | ['header' => []], 416 | ['query' => []], 417 | ], $openApi->getSecurity()); 418 | 419 | $paths = $openApi->getPaths(); 420 | $dummiesPath = $paths->getPath('/dummies'); 421 | $this->assertNotNull($dummiesPath); 422 | foreach (['Put', 'Head', 'Trace', 'Delete', 'Options', 'Patch'] as $method) { 423 | $this->assertNull($dummiesPath->{'get'.$method}()); 424 | } 425 | 426 | $this->assertEquals(new Operation( 427 | 'getDummyCollection', 428 | ['Dummy'], 429 | [ 430 | '200' => new Response('Dummy collection', new \ArrayObject([ 431 | 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject([ 432 | 'type' => 'array', 433 | 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto'], 434 | ]))), 435 | ])), 436 | ], 437 | 'Retrieves the collection of Dummy resources.', 438 | 'Retrieves the collection of Dummy resources.', 439 | null, 440 | [ 441 | new Parameter('page', 'query', 'Test modified collection page number', false, false, true, [ 442 | 'type' => 'integer', 443 | 'default' => 1, 444 | ]), 445 | new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ 446 | 'type' => 'integer', 447 | 'default' => 30, 448 | 'minimum' => 0, 449 | ]), 450 | new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ 451 | 'type' => 'boolean', 452 | ]), 453 | ] 454 | ), $dummiesPath->getGet()); 455 | 456 | $this->assertEquals(new Operation( 457 | 'postDummyCollection', 458 | ['Dummy'], 459 | [ 460 | '201' => new Response( 461 | 'Dummy resource created', 462 | new \ArrayObject([ 463 | 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), 464 | ]), 465 | null, 466 | new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) 467 | ), 468 | '400' => new Response('Invalid input'), 469 | '422' => new Response('Unprocessable entity'), 470 | ], 471 | 'Creates a Dummy resource.', 472 | 'Creates a Dummy resource.', 473 | null, 474 | [], 475 | new RequestBody( 476 | 'The new Dummy resource', 477 | new \ArrayObject([ 478 | 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), 479 | ]), 480 | true 481 | ) 482 | ), $dummiesPath->getPost()); 483 | 484 | $dummyPath = $paths->getPath('/dummies/{id}'); 485 | $this->assertNotNull($dummyPath); 486 | foreach (['Post', 'Head', 'Trace', 'Options', 'Patch'] as $method) { 487 | $this->assertNull($dummyPath->{'get'.$method}()); 488 | } 489 | 490 | $this->assertEquals(new Operation( 491 | 'getDummyItem', 492 | ['Dummy'], 493 | [ 494 | '200' => new Response( 495 | 'Dummy resource', 496 | new \ArrayObject([ 497 | 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), 498 | ]) 499 | ), 500 | '404' => new Response('Resource not found'), 501 | ], 502 | 'Retrieves a Dummy resource.', 503 | 'Retrieves a Dummy resource.', 504 | null, 505 | [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'string'])] 506 | ), $dummyPath->getGet()); 507 | 508 | $this->assertEquals(new Operation( 509 | 'putDummyItem', 510 | ['Dummy'], 511 | [ 512 | '200' => new Response( 513 | 'Dummy resource updated', 514 | new \ArrayObject([ 515 | 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto'])), 516 | ]), 517 | null, 518 | new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) 519 | ), 520 | '400' => new Response('Invalid input'), 521 | '422' => new Response('Unprocessable entity'), 522 | '404' => new Response('Resource not found'), 523 | ], 524 | 'Replaces the Dummy resource.', 525 | 'Replaces the Dummy resource.', 526 | null, 527 | [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'string'])], 528 | new RequestBody( 529 | 'The updated Dummy resource', 530 | new \ArrayObject([ 531 | 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), 532 | ]), 533 | true 534 | ) 535 | ), $dummyPath->getPut()); 536 | 537 | $this->assertEquals(new Operation( 538 | 'deleteDummyItem', 539 | ['Dummy'], 540 | [ 541 | '204' => new Response('Dummy resource deleted'), 542 | '404' => new Response('Resource not found'), 543 | ], 544 | 'Removes the Dummy resource.', 545 | 'Removes the Dummy resource.', 546 | null, 547 | [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'string'])] 548 | ), $dummyPath->getDelete()); 549 | 550 | $customPath = $paths->getPath('/foo/{id}'); 551 | $this->assertEquals(new Operation( 552 | 'customDummyItem', 553 | ['Dummy', 'Profile'], 554 | [ 555 | '202' => new Response('Success', new \ArrayObject([ 556 | 'application/json' => [ 557 | 'schema' => ['$ref' => '#/components/schemas/Dummy'], 558 | ], 559 | ]), new \ArrayObject([ 560 | 'Foo' => ['description' => 'A nice header', 'schema' => ['type' => 'integer']], 561 | ]), new \ArrayObject([ 562 | 'Foo' => ['$ref' => '#/components/schemas/Dummy'], 563 | ])), 564 | '205' => new Response(), 565 | '404' => new Response('Resource not found'), 566 | ], 567 | 'Dummy', 568 | 'Custom description', 569 | new ExternalDocumentation('See also', 'http://schema.example.com/Dummy'), 570 | [new Parameter('param', 'path', 'Test parameter', true), new Parameter('id', 'path', 'Replace parameter', true, false, false, ['type' => 'string', 'format' => 'uuid'])], 571 | new RequestBody('Custom request body', new \ArrayObject([ 572 | 'multipart/form-data' => [ 573 | 'schema' => [ 574 | 'type' => 'object', 575 | 'properties' => [ 576 | 'file' => [ 577 | 'type' => 'string', 578 | 'format' => 'binary', 579 | ], 580 | ], 581 | ], 582 | ], 583 | ]), true), 584 | null, 585 | false, 586 | null, 587 | null, 588 | ['x-visibility' => 'hide'] 589 | ), $customPath->getHead()); 590 | 591 | $prefixPath = $paths->getPath('/prefix/dummies'); 592 | $this->assertNotNull($prefixPath); 593 | 594 | $formattedPath = $paths->getPath('/formatted/{id}'); 595 | $this->assertEquals(new Operation( 596 | 'formatsDummyItem', 597 | ['Dummy'], 598 | [ 599 | '200' => new Response( 600 | 'Dummy resource updated', 601 | new \ArrayObject([ 602 | 'application/json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto'])), 603 | 'text/csv' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto'])), 604 | ]), 605 | null, 606 | new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) 607 | ), 608 | '400' => new Response('Invalid input'), 609 | '422' => new Response('Unprocessable entity'), 610 | '404' => new Response('Resource not found'), 611 | ], 612 | 'Replaces the Dummy resource.', 613 | 'Replaces the Dummy resource.', 614 | null, 615 | [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'string'])], 616 | new RequestBody( 617 | 'The updated Dummy resource', 618 | new \ArrayObject([ 619 | 'application/json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), 620 | 'text/csv' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), 621 | ]), 622 | true 623 | ) 624 | ), $formattedPath->getPut()); 625 | 626 | $filteredPath = $paths->getPath('/filtered'); 627 | $this->assertEquals(new Operation( 628 | 'filteredDummyCollection', 629 | ['Dummy'], 630 | [ 631 | '200' => new Response('Dummy collection', new \ArrayObject([ 632 | 'application/ld+json' => new MediaType(new \ArrayObject([ 633 | 'type' => 'array', 634 | 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto'], 635 | ])), 636 | ])), 637 | ], 638 | 'Retrieves the collection of Dummy resources.', 639 | 'Retrieves the collection of Dummy resources.', 640 | null, 641 | [ 642 | new Parameter('page', 'query', 'The collection page number', false, false, true, [ 643 | 'type' => 'integer', 644 | 'default' => 1, 645 | ]), 646 | new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ 647 | 'type' => 'integer', 648 | 'default' => 30, 649 | 'minimum' => 0, 650 | ]), 651 | new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ 652 | 'type' => 'boolean', 653 | ]), 654 | new Parameter('name', 'query', '', true, true, true, [ 655 | 'type' => 'string', 656 | ], 'form', true, true, 'bar'), 657 | new Parameter('ha', 'query', '', false, false, true, [ 658 | 'type' => 'integer', 659 | ]), 660 | new Parameter('toto', 'query', '', true, false, true, [ 661 | 'type' => 'array', 662 | 'items' => ['type' => 'string'], 663 | ], 'deepObject', true), 664 | new Parameter('order[name]', 'query', '', false, false, true, [ 665 | 'type' => 'string', 666 | 'enum' => ['asc', 'desc'], 667 | ]), 668 | ] 669 | ), $filteredPath->getGet()); 670 | 671 | $paginatedPath = $paths->getPath('/paginated'); 672 | $this->assertEquals(new Operation( 673 | 'paginatedDummyCollection', 674 | ['Dummy'], 675 | [ 676 | '200' => new Response('Dummy collection', new \ArrayObject([ 677 | 'application/ld+json' => new MediaType(new \ArrayObject([ 678 | 'type' => 'array', 679 | 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto'], 680 | ])), 681 | ])), 682 | ], 683 | 'Retrieves the collection of Dummy resources.', 684 | 'Retrieves the collection of Dummy resources.', 685 | null, 686 | [ 687 | new Parameter('page', 'query', 'The collection page number', false, false, true, [ 688 | 'type' => 'integer', 689 | 'default' => 1, 690 | ]), 691 | new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ 692 | 'type' => 'integer', 693 | 'default' => 20, 694 | 'minimum' => 0, 695 | 'maximum' => 80, 696 | ]), 697 | new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ 698 | 'type' => 'boolean', 699 | ]), 700 | ] 701 | ), $paginatedPath->getGet()); 702 | 703 | $requestBodyPath = $paths->getPath('/dummiesRequestBody'); 704 | $this->assertEquals(new Operation( 705 | 'postDummyCollectionWithRequestBody', 706 | ['Dummy'], 707 | [ 708 | '201' => new Response( 709 | 'Dummy resource created', 710 | new \ArrayObject([ 711 | 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), 712 | ]), 713 | null, 714 | new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) 715 | ), 716 | '400' => new Response('Invalid input'), 717 | '422' => new Response('Unprocessable entity'), 718 | ], 719 | 'Creates a Dummy resource.', 720 | 'Creates a Dummy resource.', 721 | null, 722 | [], 723 | new RequestBody( 724 | 'List of Ids', 725 | new \ArrayObject([ 726 | 'application/json' => [ 727 | 'schema' => [ 728 | 'type' => 'object', 729 | 'properties' => [ 730 | 'ids' => [ 731 | 'type' => 'array', 732 | 'items' => ['type' => 'string'], 733 | 'example' => [ 734 | '1e677e04-d461-4389-bedc-6d1b665cc9d6', 735 | '01111b43-f53a-4d50-8639-148850e5da19', 736 | ], 737 | ], 738 | ], 739 | ], 740 | ], 741 | ]), 742 | false 743 | ), 744 | deprecated: false, 745 | ), $requestBodyPath->getPost()); 746 | 747 | $dummyItemPath = $paths->getPath('/dummyitems/{id}'); 748 | $this->assertEquals(new Operation( 749 | 'putDummyItemWithResponse', 750 | ['Dummy'], 751 | [ 752 | '200' => new Response( 753 | 'Success', 754 | new \ArrayObject([ 755 | 'application/json' => [ 756 | 'schema' => ['$ref' => '#/components/schemas/Dummy'], 757 | ], 758 | ]), 759 | new \ArrayObject([ 760 | 'API_KEY' => ['description' => 'Api Key', 'schema' => ['type' => 'string']], 761 | ]), 762 | new \ArrayObject([ 763 | 'link' => ['$ref' => '#/components/schemas/Dummy'], 764 | ]) 765 | ), 766 | '400' => new Response('Error'), 767 | '422' => new Response('Unprocessable entity'), 768 | '404' => new Response('Resource not found'), 769 | ], 770 | 'Replaces the Dummy resource.', 771 | 'Replaces the Dummy resource.', 772 | null, 773 | [], 774 | new RequestBody( 775 | 'The updated Dummy resource', 776 | new \ArrayObject([ 777 | 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), 778 | ]), 779 | true 780 | ), 781 | deprecated: false 782 | ), $dummyItemPath->getPut()); 783 | 784 | $dummyItemPath = $paths->getPath('/dummyitems'); 785 | $this->assertEquals(new Operation( 786 | 'postDummyItemWithResponse', 787 | ['Dummy'], 788 | [ 789 | '201' => new Response( 790 | 'Created', 791 | new \ArrayObject([ 792 | 'application/json' => [ 793 | 'schema' => ['$ref' => '#/components/schemas/Dummy'], 794 | ], 795 | ]), 796 | new \ArrayObject([ 797 | 'API_KEY' => ['description' => 'Api Key', 'schema' => ['type' => 'string']], 798 | ]), 799 | new \ArrayObject([ 800 | 'link' => ['$ref' => '#/components/schemas/Dummy'], 801 | ]) 802 | ), 803 | '400' => new Response('Error'), 804 | '422' => new Response('Unprocessable entity'), 805 | ], 806 | 'Creates a Dummy resource.', 807 | 'Creates a Dummy resource.', 808 | null, 809 | [], 810 | new RequestBody( 811 | 'The new Dummy resource', 812 | new \ArrayObject([ 813 | 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), 814 | ]), 815 | true 816 | ), 817 | deprecated: false 818 | ), $dummyItemPath->getPost()); 819 | 820 | $dummyItemPath = $paths->getPath('/dummyitems/{id}/images'); 821 | 822 | $this->assertEquals(new Operation( 823 | 'getDummyItemImageCollection', 824 | ['Dummy'], 825 | [ 826 | '200' => new Response( 827 | 'Success' 828 | ), 829 | ], 830 | 'Retrieves the collection of Dummy resources.', 831 | 'Retrieves the collection of Dummy resources.', 832 | null, 833 | [ 834 | new Parameter('page', 'query', 'The collection page number', false, false, true, [ 835 | 'type' => 'integer', 836 | 'default' => 1, 837 | ]), 838 | new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ 839 | 'type' => 'integer', 840 | 'default' => 30, 841 | 'minimum' => 0, 842 | ]), 843 | new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ 844 | 'type' => 'boolean', 845 | ]), 846 | ] 847 | ), $dummyItemPath->getGet()); 848 | } 849 | } 850 | -------------------------------------------------------------------------------- /Tests/Fixtures/Dummy.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Tests\Fixtures; 15 | 16 | use ApiPlatform\Metadata\ApiProperty; 17 | use ApiPlatform\Metadata\ApiResource; 18 | 19 | /** 20 | * Dummy. 21 | * 22 | * @author Kévin Dunglas 23 | */ 24 | #[ApiResource(filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], extraProperties: ['standard_put' => false])] 25 | class Dummy 26 | { 27 | /** 28 | * @var int|null The id 29 | */ 30 | private $id; 31 | 32 | /** 33 | * @var string The dummy name 34 | */ 35 | #[ApiProperty(iris: ['https://schema.org/name'])] 36 | private string $name; 37 | 38 | /** 39 | * @var string|null The dummy name alias 40 | */ 41 | #[ApiProperty(iris: ['https://schema.org/alternateName'])] 42 | private $alias; 43 | 44 | /** 45 | * @var array foo 46 | */ 47 | private ?array $foo = null; 48 | 49 | /** 50 | * @var string|null A short description of the item 51 | */ 52 | #[ApiProperty(iris: ['https://schema.org/description'])] 53 | public $description; 54 | 55 | /** 56 | * @var string|null A dummy 57 | */ 58 | public $dummy; 59 | 60 | /** 61 | * @var bool|null A dummy boolean 62 | */ 63 | public ?bool $dummyBoolean = null; 64 | 65 | /** 66 | * @var \DateTime|null A dummy date 67 | */ 68 | #[ApiProperty(iris: ['https://schema.org/DateTime'])] 69 | public $dummyDate; 70 | 71 | /** 72 | * @var float|null A dummy float 73 | */ 74 | public $dummyFloat; 75 | 76 | /** 77 | * @var string|null A dummy price 78 | */ 79 | public $dummyPrice; 80 | 81 | #[ApiProperty(push: true)] 82 | public ?RelatedDummy $relatedDummy = null; 83 | 84 | public iterable $relatedDummies; 85 | 86 | /** 87 | * @var array|null serialize data 88 | */ 89 | public $jsonData = []; 90 | 91 | /** 92 | * @var array|null 93 | */ 94 | public $arrayData = []; 95 | 96 | /** 97 | * @var string|null 98 | */ 99 | public $nameConverted; 100 | 101 | public static function staticMethod(): void 102 | { 103 | } 104 | 105 | public function __construct() 106 | { 107 | $this->relatedDummies = []; 108 | } 109 | 110 | public function getId() 111 | { 112 | return $this->id; 113 | } 114 | 115 | public function setId($id): void 116 | { 117 | $this->id = $id; 118 | } 119 | 120 | public function setName(string $name): void 121 | { 122 | $this->name = $name; 123 | } 124 | 125 | public function getName(): string 126 | { 127 | return $this->name; 128 | } 129 | 130 | public function setAlias($alias): void 131 | { 132 | $this->alias = $alias; 133 | } 134 | 135 | public function getAlias() 136 | { 137 | return $this->alias; 138 | } 139 | 140 | public function setDescription($description): void 141 | { 142 | $this->description = $description; 143 | } 144 | 145 | public function getDescription() 146 | { 147 | return $this->description; 148 | } 149 | 150 | public function fooBar($baz): void 151 | { 152 | } 153 | 154 | public function getFoo(): ?array 155 | { 156 | return $this->foo; 157 | } 158 | 159 | public function setFoo(?array $foo = null): void 160 | { 161 | $this->foo = $foo; 162 | } 163 | 164 | public function setDummyDate(?\DateTime $dummyDate = null): void 165 | { 166 | $this->dummyDate = $dummyDate; 167 | } 168 | 169 | public function getDummyDate() 170 | { 171 | return $this->dummyDate; 172 | } 173 | 174 | public function setDummyPrice($dummyPrice) 175 | { 176 | $this->dummyPrice = $dummyPrice; 177 | 178 | return $this; 179 | } 180 | 181 | public function getDummyPrice() 182 | { 183 | return $this->dummyPrice; 184 | } 185 | 186 | public function setJsonData($jsonData): void 187 | { 188 | $this->jsonData = $jsonData; 189 | } 190 | 191 | public function getJsonData() 192 | { 193 | return $this->jsonData; 194 | } 195 | 196 | public function setArrayData($arrayData): void 197 | { 198 | $this->arrayData = $arrayData; 199 | } 200 | 201 | public function getArrayData() 202 | { 203 | return $this->arrayData; 204 | } 205 | 206 | public function getRelatedDummy(): ?RelatedDummy 207 | { 208 | return $this->relatedDummy; 209 | } 210 | 211 | public function setRelatedDummy(RelatedDummy $relatedDummy): void 212 | { 213 | $this->relatedDummy = $relatedDummy; 214 | } 215 | 216 | public function addRelatedDummy(RelatedDummy $relatedDummy): void 217 | { 218 | $this->relatedDummies->add($relatedDummy); 219 | } 220 | 221 | public function isDummyBoolean(): ?bool 222 | { 223 | return $this->dummyBoolean; 224 | } 225 | 226 | /** 227 | * @param bool $dummyBoolean 228 | */ 229 | public function setDummyBoolean($dummyBoolean): void 230 | { 231 | $this->dummyBoolean = $dummyBoolean; 232 | } 233 | 234 | public function setDummy($dummy = null): void 235 | { 236 | $this->dummy = $dummy; 237 | } 238 | 239 | public function getDummy() 240 | { 241 | return $this->dummy; 242 | } 243 | 244 | public function getRelatedDummies(): iterable 245 | { 246 | return $this->relatedDummies; 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /Tests/Fixtures/DummyFilter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Tests\Fixtures; 15 | 16 | use ApiPlatform\Metadata\FilterInterface; 17 | 18 | /** 19 | * @author Kévin Dunglas 20 | */ 21 | class DummyFilter implements FilterInterface 22 | { 23 | public function __construct(private readonly array $description) 24 | { 25 | } 26 | 27 | /** 28 | * Gets the description of this filter for the given resource. 29 | * 30 | * Returns an array with the filter parameter names as keys and array with the following data as values: 31 | * - property: the property where the filter is applied 32 | * - type: the type of the filter 33 | * - required: if this filter is required 34 | * - strategy: the used strategy 35 | * The description can contain additional data specific to a filter. 36 | */ 37 | public function getDescription(string $resourceClass): array 38 | { 39 | return $this->description; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/Fixtures/OutputDto.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Tests\Fixtures; 15 | 16 | use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; 17 | 18 | /** 19 | * @author Kévin Dunglas 20 | */ 21 | class OutputDto 22 | { 23 | /** 24 | * @var int 25 | */ 26 | public $id; 27 | 28 | /** 29 | * @var float 30 | */ 31 | public $baz; 32 | 33 | /** 34 | * @var string 35 | */ 36 | public $bat; 37 | 38 | /** 39 | * @var RelatedDummy[] 40 | */ 41 | public $relatedDummies = []; 42 | } 43 | -------------------------------------------------------------------------------- /Tests/Fixtures/RelatedDummy.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Tests\Fixtures; 15 | 16 | class RelatedDummy 17 | { 18 | } 19 | -------------------------------------------------------------------------------- /Tests/Serializer/ApiGatewayNormalizerTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Tests\Serializer; 15 | 16 | use ApiPlatform\OpenApi\Model\Info; 17 | use ApiPlatform\OpenApi\Model\Paths; 18 | use ApiPlatform\OpenApi\OpenApi; 19 | use ApiPlatform\OpenApi\Serializer\ApiGatewayNormalizer; 20 | use ApiPlatform\OpenApi\Serializer\OpenApiNormalizer; 21 | use PHPUnit\Framework\TestCase; 22 | use Prophecy\Argument; 23 | use Prophecy\PhpUnit\ProphecyTrait; 24 | use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; 25 | use Symfony\Component\Serializer\Normalizer\NormalizerInterface; 26 | use Symfony\Component\Serializer\Serializer; 27 | 28 | final class ApiGatewayNormalizerTest extends TestCase 29 | { 30 | use ProphecyTrait; 31 | 32 | /** 33 | * @group legacy 34 | */ 35 | public function testSupportsNormalization(): void 36 | { 37 | $normalizerProphecy = $this->prophesize(NormalizerInterface::class); 38 | $normalizerProphecy->supportsNormalization(OpenApiNormalizer::FORMAT, OpenApi::class)->willReturn(true); 39 | if (!method_exists(Serializer::class, 'getSupportedTypes')) { 40 | $normalizerProphecy->willImplement(CacheableSupportsMethodInterface::class); 41 | $normalizerProphecy->hasCacheableSupportsMethod()->willReturn(true); 42 | } 43 | 44 | $normalizer = new ApiGatewayNormalizer($normalizerProphecy->reveal()); 45 | 46 | $this->assertTrue($normalizer->supportsNormalization(OpenApiNormalizer::FORMAT, OpenApi::class)); 47 | 48 | if (!method_exists(Serializer::class, 'getSupportedTypes')) { 49 | $this->assertTrue($normalizer->hasCacheableSupportsMethod()); 50 | } 51 | } 52 | 53 | public function testNormalize(): void 54 | { 55 | $swaggerDocument = [ 56 | 'paths' => new \ArrayObject([ 57 | '/dummies' => [ 58 | 'post' => new \ArrayObject([ 59 | 'parameters' => [ 60 | [ 61 | 'name' => 'dummy', 62 | 'in' => 'body', 63 | 'schema' => [ 64 | '$ref' => '#/definitions/Dummy', 65 | ], 66 | ], 67 | ], 68 | 'responses' => [ 69 | 201 => [ 70 | 'schema' => [ 71 | '$ref' => '#/definitions/Dummy-list_details', 72 | ], 73 | ], 74 | ], 75 | ]), 76 | 'get' => new \ArrayObject([ 77 | 'parameters' => [ 78 | [ 79 | 'name' => 'relatedDummy', 80 | 'in' => 'query', 81 | 'required' => false, 82 | 'type' => 'string', 83 | ], 84 | [ 85 | 'name' => 'relatedDummy[]', 86 | 'in' => 'query', 87 | 'required' => false, 88 | 'type' => 'string', 89 | ], 90 | ], 91 | 'responses' => [ 92 | 200 => [ 93 | 'schema' => [ 94 | 'type' => 'array', 95 | 'items' => [ 96 | '$ref' => '#/definitions/Dummy-list', 97 | ], 98 | ], 99 | ], 100 | ], 101 | ]), 102 | ], 103 | '/dummies/{id}' => [ 104 | 'get' => new \ArrayObject([ 105 | 'parameters' => [ 106 | [ 107 | 'name' => 'id', 108 | 'in' => 'path', 109 | 'required' => true, 110 | 'type' => 'string', 111 | ], 112 | ], 113 | 'responses' => [ 114 | 200 => [ 115 | 'schema' => [ 116 | '$ref' => '#/definitions/Dummy-list_details', 117 | ], 118 | ], 119 | ], 120 | ]), 121 | ], 122 | '/dummies/{id}/what' => [ 123 | 'post' => new \ArrayObject([ 124 | 'parameters' => [ 125 | [ 126 | 'name' => 'dummy', 127 | 'in' => 'body', 128 | 'schema' => [ 129 | '$ref' => '#/definitions/Dummy:InputDto', 130 | ], 131 | ], 132 | ], 133 | 'responses' => [ 134 | 200 => [ 135 | 'schema' => [ 136 | '$ref' => '#/definitions/Dummy:OutputDto', 137 | ], 138 | ], 139 | ], 140 | ]), 141 | ], 142 | ]), 143 | 'components' => ['schemas' => [ 144 | 'Dummy' => new \ArrayObject([ 145 | 'properties' => [ 146 | 'id' => [ 147 | 'readOnly' => true, 148 | 'type' => 'integer', 149 | ], 150 | 'description' => [ 151 | 'type' => 'string', 152 | ], 153 | ], 154 | ]), 155 | 'Dummy-list' => new \ArrayObject([ 156 | 'properties' => [ 157 | 'id' => [ 158 | 'readOnly' => true, 159 | 'type' => 'integer', 160 | ], 161 | 'description' => [ 162 | 'type' => 'string', 163 | ], 164 | ], 165 | ]), 166 | 'Dummy-list_details' => new \ArrayObject([ 167 | 'properties' => [ 168 | 'id' => [ 169 | 'readOnly' => true, 170 | 'type' => 'integer', 171 | ], 172 | 'description' => [ 173 | 'type' => 'string', 174 | ], 175 | 'relatedDummy' => new \ArrayObject([ 176 | '$ref' => '#/definitions/RelatedDummy-list_details', 177 | ]), 178 | ], 179 | ]), 180 | 'Dummy:OutputDto' => new \ArrayObject([ 181 | 'type' => 'object', 182 | 'properties' => [ 183 | 'baz' => new \ArrayObject([ 184 | 'readOnly' => true, 185 | 'type' => 'string', 186 | ]), 187 | 'bat' => new \ArrayObject([ 188 | 'type' => 'integer', 189 | ]), 190 | ], 191 | ]), 192 | 'Dummy:InputDto' => new \ArrayObject([ 193 | 'type' => 'object', 194 | 'properties' => [ 195 | 'foo' => new \ArrayObject([ 196 | 'type' => 'string', 197 | ]), 198 | 'bar' => new \ArrayObject([ 199 | 'type' => 'integer', 200 | ]), 201 | ], 202 | ]), 203 | 'RelatedDummy-list_details' => new \ArrayObject([ 204 | 'type' => 'object', 205 | 'properties' => [ 206 | 'name' => new \ArrayObject([ 207 | 'type' => 'string', 208 | ]), 209 | ], 210 | ]), 211 | ]], 212 | ]; 213 | 214 | $modifiedSwaggerDocument = [ 215 | 'paths' => new \ArrayObject([ 216 | '/dummies' => [ 217 | 'post' => new \ArrayObject([ 218 | 'parameters' => [ 219 | [ 220 | 'name' => 'dummy', 221 | 'in' => 'body', 222 | 'schema' => [ 223 | '$ref' => '#/definitions/Dummy', 224 | ], 225 | ], 226 | ], 227 | 'responses' => [ 228 | 201 => [ 229 | 'schema' => [ 230 | '$ref' => '#/definitions/Dummylistdetails', 231 | ], 232 | ], 233 | ], 234 | ]), 235 | 'get' => new \ArrayObject([ 236 | 'parameters' => [ 237 | [ 238 | 'name' => 'relatedDummy', 239 | 'in' => 'query', 240 | 'required' => false, 241 | 'type' => 'string', 242 | ], 243 | ], 244 | 'responses' => [ 245 | 200 => [ 246 | 'schema' => [ 247 | 'type' => 'array', 248 | 'items' => [ 249 | '$ref' => '#/definitions/Dummylist', 250 | ], 251 | ], 252 | ], 253 | ], 254 | ]), 255 | ], 256 | '/dummies/{id}' => [ 257 | 'get' => new \ArrayObject([ 258 | 'parameters' => [ 259 | [ 260 | 'name' => 'id', 261 | 'in' => 'path', 262 | 'required' => true, 263 | 'type' => 'string', 264 | ], 265 | ], 266 | 'responses' => [ 267 | 200 => [ 268 | 'schema' => [ 269 | '$ref' => '#/definitions/Dummylistdetails', 270 | ], 271 | ], 272 | ], 273 | ]), 274 | ], 275 | '/dummies/{id}/what' => [ 276 | 'post' => new \ArrayObject([ 277 | 'parameters' => [ 278 | [ 279 | 'name' => 'dummy', 280 | 'in' => 'body', 281 | 'schema' => [ 282 | '$ref' => '#/definitions/DummyInputDto', 283 | ], 284 | ], 285 | ], 286 | 'responses' => [ 287 | 200 => [ 288 | 'schema' => [ 289 | '$ref' => '#/definitions/DummyOutputDto', 290 | ], 291 | ], 292 | ], 293 | ]), 294 | ], 295 | ]), 296 | 'components' => ['schemas' => [ 297 | 'Dummy' => new \ArrayObject([ 298 | 'properties' => [ 299 | 'id' => [ 300 | 'type' => 'integer', 301 | ], 302 | 'description' => [ 303 | 'type' => 'string', 304 | ], 305 | ], 306 | ]), 307 | 'Dummylist' => new \ArrayObject([ 308 | 'properties' => [ 309 | 'id' => [ 310 | 'type' => 'integer', 311 | ], 312 | 'description' => [ 313 | 'type' => 'string', 314 | ], 315 | ], 316 | ]), 317 | 'Dummylistdetails' => new \ArrayObject([ 318 | 'properties' => [ 319 | 'id' => [ 320 | 'type' => 'integer', 321 | ], 322 | 'description' => [ 323 | 'type' => 'string', 324 | ], 325 | 'relatedDummy' => new \ArrayObject([ 326 | '$ref' => '#/definitions/RelatedDummylistdetails', 327 | ]), 328 | ], 329 | ]), 330 | 'DummyOutputDto' => new \ArrayObject([ 331 | 'type' => 'object', 332 | 'properties' => [ 333 | 'baz' => new \ArrayObject([ 334 | 'type' => 'string', 335 | ]), 336 | 'bat' => new \ArrayObject([ 337 | 'type' => 'integer', 338 | ]), 339 | ], 340 | ]), 341 | 'DummyInputDto' => new \ArrayObject([ 342 | 'type' => 'object', 343 | 'properties' => [ 344 | 'foo' => new \ArrayObject([ 345 | 'type' => 'string', 346 | ]), 347 | 'bar' => new \ArrayObject([ 348 | 'type' => 'integer', 349 | ]), 350 | ], 351 | ]), 352 | 'RelatedDummylistdetails' => new \ArrayObject([ 353 | 'type' => 'object', 354 | 'properties' => [ 355 | 'name' => new \ArrayObject([ 356 | 'type' => 'string', 357 | ]), 358 | ], 359 | ]), 360 | ]], 361 | 'basePath' => '/', 362 | ]; 363 | 364 | $documentation = $this->getOpenApi(); 365 | $normalizerProphecy = $this->prophesize(NormalizerInterface::class); 366 | $normalizerProphecy->normalize($documentation, OpenApiNormalizer::FORMAT, [ 367 | ApiGatewayNormalizer::API_GATEWAY => true, 368 | ])->willReturn($swaggerDocument); 369 | 370 | $normalizer = new ApiGatewayNormalizer($normalizerProphecy->reveal()); 371 | 372 | $this->assertEquals($modifiedSwaggerDocument, $normalizer->normalize($documentation, OpenApiNormalizer::FORMAT, [ 373 | ApiGatewayNormalizer::API_GATEWAY => true, 374 | ])); 375 | } 376 | 377 | public function testNormalizeNotInApiGatewayContext(): void 378 | { 379 | $documentation = $this->getOpenApi(); 380 | 381 | $swaggerDocument = [ 382 | 'paths' => new \ArrayObject([ 383 | '/dummies' => [ 384 | 'post' => new \ArrayObject([ 385 | 'parameters' => [ 386 | [ 387 | 'name' => 'dummy', 388 | 'in' => 'body', 389 | 'schema' => [ 390 | '$ref' => '#/definitions/Dummy', 391 | ], 392 | ], 393 | ], 394 | 'responses' => [ 395 | 201 => [ 396 | 'schema' => [ 397 | '$ref' => '#/definitions/Dummy-list_details', 398 | ], 399 | ], 400 | ], 401 | ]), 402 | 'get' => new \ArrayObject([ 403 | 'parameters' => [ 404 | [ 405 | 'name' => 'relatedDummy', 406 | 'in' => 'query', 407 | 'required' => false, 408 | 'type' => 'string', 409 | ], 410 | [ 411 | 'name' => 'relatedDummy[]', 412 | 'in' => 'query', 413 | 'required' => false, 414 | 'type' => 'string', 415 | ], 416 | ], 417 | 'responses' => [ 418 | 200 => [ 419 | 'schema' => [ 420 | 'type' => 'array', 421 | 'items' => [ 422 | '$ref' => '#/definitions/Dummy-list', 423 | ], 424 | ], 425 | ], 426 | ], 427 | ]), 428 | ], 429 | '/dummies/{id}' => [ 430 | 'get' => new \ArrayObject([ 431 | 'parameters' => [ 432 | [ 433 | 'name' => 'id', 434 | 'in' => 'path', 435 | 'required' => true, 436 | 'type' => 'string', 437 | ], 438 | ], 439 | 'responses' => [ 440 | 200 => [ 441 | 'schema' => [ 442 | '$ref' => '#/definitions/Dummy-list_details', 443 | ], 444 | ], 445 | ], 446 | ]), 447 | ], 448 | '/dummies/{id}/what' => [ 449 | 'post' => new \ArrayObject([ 450 | 'parameters' => [ 451 | [ 452 | 'name' => 'dummy', 453 | 'in' => 'body', 454 | 'schema' => [ 455 | '$ref' => '#/definitions/Dummy:InputDto', 456 | ], 457 | ], 458 | ], 459 | 'responses' => [ 460 | 200 => [ 461 | 'schema' => [ 462 | '$ref' => '#/definitions/Dummy:OutputDto', 463 | ], 464 | ], 465 | ], 466 | ]), 467 | ], 468 | ]), 469 | 'components' => ['schemas' => new \ArrayObject([ 470 | 'Dummy' => new \ArrayObject([ 471 | 'properties' => [ 472 | 'id' => [ 473 | 'readOnly' => true, 474 | 'type' => 'integer', 475 | ], 476 | 'description' => [ 477 | 'type' => 'string', 478 | ], 479 | ], 480 | ]), 481 | 'Dummy-list' => new \ArrayObject([ 482 | 'properties' => [ 483 | 'id' => [ 484 | 'readOnly' => true, 485 | 'type' => 'integer', 486 | ], 487 | 'description' => [ 488 | 'type' => 'string', 489 | ], 490 | ], 491 | ]), 492 | 'Dummy-list_details' => new \ArrayObject([ 493 | 'properties' => [ 494 | 'id' => [ 495 | 'readOnly' => true, 496 | 'type' => 'integer', 497 | ], 498 | 'description' => [ 499 | 'type' => 'string', 500 | ], 501 | 'relatedDummy' => new \ArrayObject([ 502 | '$ref' => '#/definitions/RelatedDummy-list_details', 503 | ]), 504 | ], 505 | ]), 506 | 'Dummy:OutputDto' => new \ArrayObject([ 507 | 'type' => 'object', 508 | 'properties' => [ 509 | 'baz' => new \ArrayObject([ 510 | 'readOnly' => true, 511 | 'type' => 'string', 512 | ]), 513 | 'bat' => new \ArrayObject([ 514 | 'type' => 'integer', 515 | ]), 516 | ], 517 | ]), 518 | 'Dummy:InputDto' => new \ArrayObject([ 519 | 'type' => 'object', 520 | 'properties' => [ 521 | 'foo' => new \ArrayObject([ 522 | 'type' => 'string', 523 | ]), 524 | 'bar' => new \ArrayObject([ 525 | 'type' => 'integer', 526 | ]), 527 | ], 528 | ]), 529 | 'RelatedDummy-list_details' => new \ArrayObject([ 530 | 'type' => 'object', 531 | 'properties' => [ 532 | 'name' => new \ArrayObject([ 533 | 'type' => 'string', 534 | ]), 535 | ], 536 | ]), 537 | ])], 538 | ]; 539 | 540 | $normalizerProphecy = $this->prophesize(NormalizerInterface::class); 541 | $normalizerProphecy->normalize($documentation, OpenApiNormalizer::FORMAT, Argument::type('array'))->willReturn($swaggerDocument); 542 | 543 | $normalizer = new ApiGatewayNormalizer($normalizerProphecy->reveal()); 544 | 545 | $this->assertEquals($swaggerDocument, $normalizer->normalize($documentation, OpenApiNormalizer::FORMAT)); 546 | } 547 | 548 | private function getOpenApi(): OpenApi 549 | { 550 | return new OpenApi(new Info('test', '0'), [], new Paths()); 551 | } 552 | } 553 | -------------------------------------------------------------------------------- /Tests/Serializer/OpenApiNormalizerTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace ApiPlatform\OpenApi\Tests\Serializer; 15 | 16 | use ApiPlatform\JsonSchema\SchemaFactory; 17 | use ApiPlatform\JsonSchema\TypeFactory; 18 | use ApiPlatform\Metadata\ApiProperty; 19 | use ApiPlatform\Metadata\ApiResource; 20 | use ApiPlatform\Metadata\Delete; 21 | use ApiPlatform\Metadata\Get; 22 | use ApiPlatform\Metadata\GetCollection; 23 | use ApiPlatform\Metadata\HttpOperation; 24 | use ApiPlatform\Metadata\Operations; 25 | use ApiPlatform\Metadata\Post; 26 | use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; 27 | use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; 28 | use ApiPlatform\Metadata\Property\PropertyNameCollection; 29 | use ApiPlatform\Metadata\Put; 30 | use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; 31 | use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; 32 | use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; 33 | use ApiPlatform\Metadata\Resource\ResourceNameCollection; 34 | use ApiPlatform\OpenApi\Factory\OpenApiFactory; 35 | use ApiPlatform\OpenApi\Model\Components; 36 | use ApiPlatform\OpenApi\Model\Info; 37 | use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; 38 | use ApiPlatform\OpenApi\Model\Parameter; 39 | use ApiPlatform\OpenApi\Model\Paths; 40 | use ApiPlatform\OpenApi\Model\Schema; 41 | use ApiPlatform\OpenApi\Model\Server; 42 | use ApiPlatform\OpenApi\OpenApi; 43 | use ApiPlatform\OpenApi\Options; 44 | use ApiPlatform\OpenApi\Serializer\OpenApiNormalizer; 45 | use ApiPlatform\State\Pagination\PaginationOptions; 46 | use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; 47 | use PHPUnit\Framework\TestCase; 48 | use Prophecy\Argument; 49 | use Prophecy\PhpUnit\ProphecyTrait; 50 | use Psr\Container\ContainerInterface; 51 | use Symfony\Component\PropertyInfo\Type; 52 | use Symfony\Component\Serializer\Encoder\JsonEncoder; 53 | use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; 54 | use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; 55 | use Symfony\Component\Serializer\Serializer; 56 | 57 | class OpenApiNormalizerTest extends TestCase 58 | { 59 | use ProphecyTrait; 60 | 61 | private const OPERATION_FORMATS = [ 62 | 'input_formats' => ['jsonld' => ['application/ld+json']], 63 | 'output_formats' => ['jsonld' => ['application/ld+json']], 64 | ]; 65 | 66 | public function testNormalizeWithSchemas(): void 67 | { 68 | $openApi = new OpenApi(new Info('My API', '1.0.0', 'An amazing API'), [new Server('https://example.com')], new Paths(), new Components(new \ArrayObject(['z' => new Schema(), 'b' => new Schema()]))); 69 | $encoders = [new JsonEncoder()]; 70 | $normalizers = [new ObjectNormalizer()]; 71 | 72 | $serializer = new Serializer($normalizers, $encoders); 73 | $normalizers[0]->setSerializer($serializer); 74 | 75 | $normalizer = new OpenApiNormalizer($normalizers[0]); 76 | 77 | $array = $normalizer->normalize($openApi); 78 | 79 | $this->assertSame(array_keys($array['components']['schemas']), ['b', 'z']); 80 | } 81 | 82 | public function testNormalizeWithEmptySchemas(): void 83 | { 84 | $openApi = new OpenApi(new Info('My API', '1.0.0', 'An amazing API'), [new Server('https://example.com')], new Paths(), new Components(new \ArrayObject())); 85 | $encoders = [new JsonEncoder()]; 86 | $normalizers = [new ObjectNormalizer()]; 87 | 88 | $serializer = new Serializer($normalizers, $encoders); 89 | $normalizers[0]->setSerializer($serializer); 90 | 91 | $normalizer = new OpenApiNormalizer($normalizers[0]); 92 | 93 | $array = $normalizer->normalize($openApi); 94 | $this->assertCount(0, $array['components']['schemas']); 95 | } 96 | 97 | public function testNormalize(): void 98 | { 99 | $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); 100 | $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class, 'Zorro'])); 101 | $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); 102 | $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate'])); 103 | $propertyNameCollectionFactoryProphecy->create('Zorro', Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id'])); 104 | 105 | $baseOperation = (new HttpOperation())->withTypes(['http://schema.example.com/Dummy']) 106 | ->withInputFormats(self::OPERATION_FORMATS['input_formats'])->withOutputFormats(self::OPERATION_FORMATS['output_formats']) 107 | ->withClass(Dummy::class) 108 | ->withShortName('Dummy') 109 | ->withDescription('This is a dummy.'); 110 | 111 | $dummyMetadata = new ResourceMetadataCollection(Dummy::class, [ 112 | (new ApiResource())->withOperations(new Operations( 113 | [ 114 | 'get' => (new Get())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation), 115 | 'put' => (new Put())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation), 116 | 'delete' => (new Delete())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation), 117 | 'get_collection' => (new GetCollection())->withUriTemplate('/dummies')->withOperation($baseOperation), 118 | 'post' => (new Post())->withUriTemplate('/dummies')->withOpenapi(new OpenApiOperation( 119 | security: [], 120 | servers: ['url' => '/test'], 121 | ))->withOperation($baseOperation), 122 | ] 123 | )), 124 | ]); 125 | 126 | $zorroBaseOperation = (new HttpOperation()) 127 | ->withTypes(['http://schema.example.com/Zorro']) 128 | ->withInputFormats(self::OPERATION_FORMATS['input_formats'])->withOutputFormats(self::OPERATION_FORMATS['output_formats']) 129 | ->withClass('Zorro') 130 | ->withShortName('Zorro') 131 | ->withDescription('This is zorro.'); 132 | 133 | $zorroMetadata = new ResourceMetadataCollection(Dummy::class, [ 134 | (new ApiResource())->withOperations(new Operations( 135 | [ 136 | 'get' => (new Get())->withUriTemplate('/zorros/{id}')->withOperation($zorroBaseOperation), 137 | 'get_collection' => (new GetCollection())->withUriTemplate('/zorros')->withOperation($zorroBaseOperation), 138 | ] 139 | )), 140 | ]); 141 | 142 | $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); 143 | $resourceCollectionMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); 144 | $resourceCollectionMetadataFactoryProphecy->create('Zorro')->shouldBeCalled()->willReturn($zorroMetadata); 145 | 146 | $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); 147 | $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn( 148 | (new ApiProperty()) 149 | ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) 150 | ->withDescription('This is an id.') 151 | ->withReadable(true) 152 | ->withWritable(false) 153 | ->withIdentifier(true) 154 | ); 155 | $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn( 156 | (new ApiProperty()) 157 | ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) 158 | ->withDescription('This is a name.') 159 | ->withReadable(true) 160 | ->withWritable(true) 161 | ->withReadableLink(true) 162 | ->withWritableLink(true) 163 | ->withRequired(false) 164 | ->withIdentifier(false) 165 | ->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) 166 | ); 167 | $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn( 168 | (new ApiProperty()) 169 | ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) 170 | ->withDescription('This is an initializable but not writable property.') 171 | ->withReadable(true) 172 | ->withWritable(false) 173 | ->withReadableLink(true) 174 | ->withWritableLink(true) 175 | ->withRequired(false) 176 | ->withIdentifier(false) 177 | ); 178 | $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn( 179 | (new ApiProperty()) 180 | ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)]) 181 | ->withDescription('This is a \DateTimeInterface object.') 182 | ->withReadable(true) 183 | ->withWritable(true) 184 | ->withReadableLink(true) 185 | ->withWritableLink(true) 186 | ->withRequired(false) 187 | ->withIdentifier(false) 188 | ); 189 | 190 | $propertyMetadataFactoryProphecy->create('Zorro', 'id', Argument::any())->shouldBeCalled()->willReturn( 191 | (new ApiProperty()) 192 | ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) 193 | ->withDescription('This is an id.') 194 | ->withReadable(true) 195 | ->withWritable(false) 196 | ->withIdentifier(true) 197 | ); 198 | 199 | $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); 200 | $resourceMetadataFactory = $resourceCollectionMetadataFactoryProphecy->reveal(); 201 | $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); 202 | $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); 203 | 204 | $typeFactory = new TypeFactory(); 205 | $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); 206 | $typeFactory->setSchemaFactory($schemaFactory); 207 | 208 | $factory = new OpenApiFactory( 209 | $resourceNameCollectionFactoryProphecy->reveal(), 210 | $resourceMetadataFactory, 211 | $propertyNameCollectionFactory, 212 | $propertyMetadataFactory, 213 | $schemaFactory, 214 | $typeFactory, 215 | $filterLocatorProphecy->reveal(), 216 | [], 217 | new Options('Test API', 'This is a test API.', '1.2.3', true, 'oauth2', 'authorizationCode', '/oauth/v2/token', '/oauth/v2/auth', '/oauth/v2/refresh', ['scope param'], [ 218 | 'header' => [ 219 | 'type' => 'header', 220 | 'name' => 'Authorization', 221 | ], 222 | 'query' => [ 223 | 'type' => 'query', 224 | 'name' => 'key', 225 | ], 226 | ]), 227 | new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') 228 | ); 229 | 230 | $openApi = $factory(['base_url' => '/app_dev.php/']); 231 | 232 | $pathItem = $openApi->getPaths()->getPath('/dummies/{id}'); 233 | $operation = $pathItem->getGet(); 234 | 235 | $openApi->getPaths()->addPath('/dummies/{id}', $pathItem->withGet( 236 | $operation->withParameters(array_merge( 237 | $operation->getParameters(), 238 | [new Parameter('fields', 'query', 'Fields to remove of the output')] 239 | )) 240 | )); 241 | 242 | $openApi = $openApi->withInfo((new Info('New Title', 'v2', 'Description of my custom API'))->withExtensionProperty('info-key', 'Info value')); 243 | $openApi = $openApi->withExtensionProperty('key', 'Custom x-key value'); 244 | $openApi = $openApi->withExtensionProperty('x-value', 'Custom x-value value'); 245 | 246 | $encoders = [new JsonEncoder()]; 247 | $normalizers = [new ObjectNormalizer()]; 248 | 249 | $serializer = new Serializer($normalizers, $encoders); 250 | $normalizers[0]->setSerializer($serializer); 251 | 252 | $normalizer = new OpenApiNormalizer($normalizers[0]); 253 | 254 | $openApiAsArray = $normalizer->normalize($openApi); 255 | 256 | // Just testing normalization specifics 257 | $this->assertSame($openApiAsArray['x-key'], 'Custom x-key value'); 258 | $this->assertSame($openApiAsArray['x-value'], 'Custom x-value value'); 259 | $this->assertSame($openApiAsArray['info']['x-info-key'], 'Info value'); 260 | $this->assertArrayNotHasKey('extensionProperties', $openApiAsArray); 261 | // this key is null, should not be in the output 262 | $this->assertArrayNotHasKey('termsOfService', $openApiAsArray['info']); 263 | $this->assertArrayNotHasKey('paths', $openApiAsArray['paths']); 264 | $this->assertArrayHasKey('/dummies/{id}', $openApiAsArray['paths']); 265 | $this->assertArrayNotHasKey('servers', $openApiAsArray['paths']['/dummies/{id}']['get']); 266 | $this->assertArrayNotHasKey('security', $openApiAsArray['paths']['/dummies/{id}']['get']); 267 | 268 | // Security can be disabled per-operation using an empty array 269 | $this->assertEquals([], $openApiAsArray['paths']['/dummies']['post']['security']); 270 | $this->assertEquals(['url' => '/test'], $openApiAsArray['paths']['/dummies']['post']['servers']); 271 | 272 | // Make sure things are sorted 273 | $this->assertSame(array_keys($openApiAsArray['paths']), ['/dummies', '/dummies/{id}', '/zorros', '/zorros/{id}']); 274 | // Test name converter doesn't rename this property 275 | $this->assertArrayHasKey('requestBody', $openApiAsArray['paths']['/dummies']['post']); 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-platform/openapi", 3 | "description": "Models to build and serialize an OpenAPI specification.", 4 | "type": "library", 5 | "keywords": [ 6 | "REST", 7 | "GraphQL", 8 | "API", 9 | "JSON-LD", 10 | "Hydra", 11 | "JSONAPI", 12 | "OpenAPI", 13 | "HAL", 14 | "Swagger" 15 | ], 16 | "homepage": "https://api-platform.com", 17 | "license": "MIT", 18 | "authors": [ 19 | { 20 | "name": "Kévin Dunglas", 21 | "email": "kevin@dunglas.fr", 22 | "homepage": "https://dunglas.fr" 23 | }, 24 | { 25 | "name": "API Platform Community", 26 | "homepage": "https://api-platform.com/community/contributors" 27 | } 28 | ], 29 | "require": { 30 | "php": ">=8.1", 31 | "api-platform/json-schema": "*@dev || ^3.1", 32 | "api-platform/metadata": "*@dev || ^3.1", 33 | "api-platform/state": "*@dev || ^3.1", 34 | "symfony/console": "^6.1", 35 | "symfony/property-access": "^6.1", 36 | "symfony/serializer": "^6.1", 37 | "sebastian/comparator": "<5.0" 38 | }, 39 | "require-dev": { 40 | "phpspec/prophecy-phpunit": "^2.0", 41 | "symfony/phpunit-bridge": "^6.1" 42 | }, 43 | "autoload": { 44 | "psr-4": { 45 | "ApiPlatform\\OpenApi\\": "" 46 | }, 47 | "exclude-from-classmap": [ 48 | "/Tests/" 49 | ] 50 | }, 51 | "config": { 52 | "preferred-install": { 53 | "*": "dist" 54 | }, 55 | "sort-packages": true, 56 | "allow-plugins": { 57 | "composer/package-versions-deprecated": true, 58 | "phpstan/extension-installer": true 59 | } 60 | }, 61 | "extra": { 62 | "branch-alias": { 63 | "dev-main": "3.2.x-dev" 64 | }, 65 | "symfony": { 66 | "require": "^6.1" 67 | } 68 | }, 69 | "repositories": [ 70 | { 71 | "type": "path", 72 | "url": "../Metadata" 73 | }, 74 | { 75 | "type": "path", 76 | "url": "../JsonSchema" 77 | }, 78 | { 79 | "type": "path", 80 | "url": "../State" 81 | } 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ./Tests/ 18 | 19 | 20 | 21 | 22 | 23 | ./ 24 | 25 | 26 | ./Tests 27 | ./vendor 28 | 29 | 30 | 31 | 32 | --------------------------------------------------------------------------------