Skip to content

Commit

Permalink
Symfony 6 support (#10)
Browse files Browse the repository at this point in the history
* Symfony 6 support

* Symfony 6 support

* Use double instead of single colon separator for routes

* Update tests

* Disable Route Describer for nelmio/api-doc-bundle v4+

* OpenApi Endpoints Describer

* * Update README
* Add symfony/routing (Symfony 4.1: Deprecated the bundle notation)

* Add GitHub Actions

* Update dependencies
Update workflow

* Keep only OpenApi\Annotations\OpenApi describer even if EXSyst\Component\Swagger\Swagger installed

* Fix Warning: Attempt to read property "schemas" on string
  • Loading branch information
pidhorodetskyi authored Mar 20, 2024
1 parent 6769e57 commit f9ae7c2
Show file tree
Hide file tree
Showing 12 changed files with 335 additions and 57 deletions.
44 changes: 44 additions & 0 deletions .github/workflows/symfony.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.

name: Symfony

on:
push:
branches: ['master']
tags: ['*']
pull_request:
branches: ['*']

permissions:
contents: read

jobs:
symfony-tests:
runs-on: ${{ matrix.operating-system }}
strategy:
fail-fast: false
matrix:
operating-system: [ 'ubuntu-latest' ]
php-versions: [ '7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3' ]
phpunit-versions: [ 'latest' ]
steps:
- name: Setup PHP
uses: auto1-oss/setup-php@v2
with:
php-version: ${{ matrix.php-versions }}
tools: composer:2.2.23
extensions: mbstring, intl
ini-values: post_max_size=256M, max_execution_time=180
coverage: xdebug
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout repository
uses: actions/checkout@v3
- name: Composer update on php ${{ matrix.php }} and symfony
run: composer update --prefer-dist --no-progress
- name: Execute tests (Unit and Feature tests) via PHPUnit
run: vendor/bin/phpunit

18 changes: 18 additions & 0 deletions ApiDoc/Components.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Auto1\ServiceAPIHandlerBundle\ApiDoc;

class Components extends \OpenApi\Annotations\Components
{
public function validate(array $stack = [], array $skip = [], string $ref = '', $context = null): bool
{
/**
* It's the only way to avoid the error that different responses for different Paths have the same response code
* <User Warning: Multiple @OA\Response() with the same response="200">
* https://github.com/zircote/swagger-php/blob/master/src/Annotations/AbstractAnnotation.php#L489
*/
return true;
}
}
1 change: 1 addition & 0 deletions ApiDoc/EndpointRouteDescriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
* Class EndpointRouteDescriber.
*
* @package Auto1\ServiceAPIHandlerBundle\ApiDoc
* @deprecated for nelmio/api-doc-bundle v4. nelmio/api-doc-bundle v4 uses OpenApi instead of Swagger.
*/
class EndpointRouteDescriber implements RouteDescriberInterface, ModelRegistryAwareInterface
{
Expand Down
202 changes: 202 additions & 0 deletions ApiDoc/OpenApiEndpointRouteDescriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
<?php

namespace Auto1\ServiceAPIHandlerBundle\ApiDoc;

use Auto1\ServiceAPIComponentsBundle\Service\Endpoint\EndpointInterface;
use Auto1\ServiceAPIComponentsBundle\Service\Endpoint\EndpointRegistryInterface;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait;
use Nelmio\ApiDocBundle\Model\Model;
use Nelmio\ApiDocBundle\RouteDescriber\RouteDescriberInterface;
use Nelmio\ApiDocBundle\RouteDescriber\RouteDescriberTrait;
use OpenApi\Annotations\Get;
use OpenApi\Annotations\Items;
use OpenApi\Annotations\JsonContent;
use OpenApi\Annotations\OpenApi;
use OpenApi\Annotations\Operation;
use OpenApi\Annotations\Parameter;
use OpenApi\Annotations\PathItem;
use OpenApi\Annotations\RequestBody;
use OpenApi\Annotations\MediaType;
use OpenApi\Annotations\Response;
use OpenApi\Annotations\Schema;
use OpenApi\Context;
use OpenApi\Generator;
use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface;
use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Routing\Route;

/**
* Class EndpointRouteDescriber.
*
* @package Auto1\ServiceAPIHandlerBundle\ApiDoc
*/
class OpenApiEndpointRouteDescriber implements RouteDescriberInterface, ModelRegistryAwareInterface
{
use RouteDescriberTrait;
use ModelRegistryAwareTrait;

private const MEDIA_TYPE = 'application/json';

/**
* @var EndpointRegistryInterface
*/
private $endpointRegistry;

/**
* @var string[]
*/
private $controllerToRequestMapping;

/**
* @var PropertyInfoExtractorInterface
*/
private $propertyExtractor;

public function __construct(
EndpointRegistryInterface $endpointRegistry,
array $controllerToRequestMapping,
PropertyInfoExtractorInterface $propertyExtractor
) {
$this->endpointRegistry = $endpointRegistry;
$this->controllerToRequestMapping = $controllerToRequestMapping;
$this->propertyExtractor = $propertyExtractor;
}

public function describe(OpenApi $api, Route $route, \ReflectionMethod $reflectionMethod)
{
$endpoint = $this->getEndpoint($route);

if (!$endpoint instanceof EndpointInterface) {
return;
}

$operation = $this->getOperations($api, $route)[0] ?? null;
if (!$operation instanceof Operation) {
return;
}

$this->fillEndpointTags($operation, $reflectionMethod->getDeclaringClass());
$this->fillEndpointResponse($operation, $endpoint);
$this->fillEndpointParameters($operation, $route, $endpoint);

$components = $api->components;

$properties = [];
if ($components !== Generator::UNDEFINED) {
$properties = [
'schemas' => $components->schemas ,
'responses' => $components->responses,
'parameters' => $components->parameters,
'examples' => $components->examples,
'requestBodies' => $components->requestBodies,
'headers' => $components->headers,
'securitySchemes' => $components->securitySchemes,
'links' => $components->links,
'callbacks' => $components->callbacks,
];
}

$api->components = new Components($properties);
}

private function getEndpoint(Route $route)
{
$controller = $route->getDefault('_controller');

if (!\array_key_exists($controller, $this->controllerToRequestMapping)) {
return null;
}

$request = $this->controllerToRequestMapping[$controller];
$endpoint = $this->endpointRegistry->getEndpoint(new $request);

return $endpoint;
}

private function fillEndpointResponse(Operation $operation, EndpointInterface $endpoint)
{
$isArrayResponse = false;
$responseClass = $endpoint->getResponseClass();
if (!$responseClass) {
return;
}

if (strpos($responseClass, '[]') !== false) {
$responseClass = str_replace('[]', '', $responseClass);
$isArrayResponse = true;
}
if (!class_exists($responseClass)) {
return;
}

$model = new Model(new Type(Type::BUILTIN_TYPE_OBJECT, false, $responseClass));
$ref = $this->modelRegistry->register($model);

if ($isArrayResponse) {
$schema = new Items(['type' => 'array', 'items' => new Items(['ref' => $ref])]);
} else {
$schema = new Schema(['schema' => $ref, 'ref' => $ref]);
}
$content = [
new MediaType([
'mediaType' => self::MEDIA_TYPE,
'schema' => $schema,
]),
];

$response = new Response([
'description' => 'OK',
'response' => 200,
'content' => $content,
]);
$operation->responses = [$response];
}

private function fillEndpointTags(Operation $operation, \ReflectionClass $reflectionClass)
{
$className = $reflectionClass->getShortName();
$classTag = strtolower(preg_replace('/([a-zA-Z0-9])(?=[A-Z])/', '$1-', $className));

$operation->tags = [$classTag];
}

private function fillEndpointParameters(
Operation $operation,
Route $route,
EndpointInterface $endpoint
) {
if (!class_exists($endpoint->getRequestClass())) {
return;
}

$routeParameters = $route->compile()->getPathVariables();
$dtoParameters = $this->propertyExtractor->getProperties($endpoint->getRequestClass());

//When all parameters are available in the route parameters
if (null === $dtoParameters || count(array_diff($dtoParameters, $routeParameters)) == 0) {
return;
}

/**
* To avoid this error but also provide request body fields this hack is required
* <The PropertyInfo component was not able to guess the type of ArrayObject::$arrayCopy>
*/
if (is_subclass_of($endpoint->getRequestClass(), \ArrayObject::class)) {
$type = new Type(Type::BUILTIN_TYPE_OBJECT, false, \stdClass::class);
} else {
$type = new Type(Type::BUILTIN_TYPE_OBJECT, false, $endpoint->getRequestClass());
}
$ref = $this->modelRegistry->register(new Model($type));

$operation->requestBody = new RequestBody([
'request' => $endpoint->getRequestClass(),
'content' => [
new MediaType([
'mediaType' => self::MEDIA_TYPE,
'schema' => new Schema(['ref' => $ref, 'schema' => $ref]),
]),
],
]);
}
}
4 changes: 2 additions & 2 deletions ArgumentResolver/ServiceRequestResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,15 @@ public function __construct(
/**
* {@inheritdoc}
*/
public function supports(Request $request, ArgumentMetadata $argument)
public function supports(Request $request, ArgumentMetadata $argument): bool
{
return is_subclass_of($argument->getType(), ServiceRequestInterface::class, true);
}

/**
* {@inheritdoc}
*/
public function resolve(Request $request, ArgumentMetadata $argument)
public function resolve(Request $request, ArgumentMetadata $argument): iterable
{
$endpoint = $this->endpointRegistry->getEndpoint(
(new \ReflectionClass($argument->getType()))->newInstanceWithoutConstructor()
Expand Down
7 changes: 5 additions & 2 deletions DependencyInjection/Auto1ServiceAPIHandlerExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;

/**
* Class PricingExtension
* Class Auto1ServiceAPIHandlerExtension
*/
class Auto1ServiceAPIHandlerExtension extends Extension
{
Expand All @@ -26,8 +26,11 @@ public function load(array $configs, ContainerBuilder $container)
//Load config files
$loader->load('services.yml');

if (!class_exists(NelmioApiDocBundle::class)) {
if (!class_exists('EXSyst\Component\Swagger\Swagger') || class_exists('OpenApi\Annotations\OpenApi')) {
$container->removeDefinition('auto1.route_describers.route_metadata');
}
if (!class_exists('OpenApi\Annotations\OpenApi')) {
$container->removeDefinition('auto1.route_describers.open_api_route_describer');
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,11 @@ private function buildMappingFromRouteDetails($routeDetails, $classToServiceMapp

foreach ($routeDetails as $routeDetail) {
$controllerKey = $classToServiceMapping[$routeDetail['controller']] ?? $routeDetail['controller'];
$separator = isset($classToServiceMapping[$routeDetail['controller']]) ? ':' : '::';

$mapping[sprintf('%s%s%s', $controllerKey, $separator, $routeDetail['action'])] = $routeDetail['request'];
/**
* https://symfony.com/blog/new-in-symfony-4-1-deprecated-the-bundle-notation
*/
$mapping[sprintf('%s::%s', $controllerKey, $routeDetail['action'])] = $routeDetail['request'];
}

return $mapping;
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ class MyController {
}
```

## Swagger generation
For `symfony:>=6.0` and `nelmio/api-doc-bundle:>=4.0` swagger json file is generated in OpenApi v3 format `"openapi": "3.0.0"`.
For previous versions of `symfony` and `nelmio/api-doc-bundle` swagger json file is generated in Swagger V2 format `"swagger": "2.0"`.

## Debug
```bash
bin/console c:c && bin/console debug:router --show-controllers
Expand Down
9 changes: 9 additions & 0 deletions Resources/config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ services:
tags:
- {name: 'nelmio_api_doc.route_describer', priority: -400}

auto1.route_describers.open_api_route_describer:
class: Auto1\ServiceAPIHandlerBundle\ApiDoc\OpenApiEndpointRouteDescriber
arguments:
- '@auto1.api.endpoint.registry'
- '%auto1.api_handler.controller_request_mapping%'
- '@property_info'
tags:
- {name: 'nelmio_api_doc.route_describer', priority: -400}

# Argument resolver
auto1.api_handler.argument_resolver.service_request:
class: Auto1\ServiceAPIHandlerBundle\ArgumentResolver\ServiceRequestResolver
Expand Down
Loading

0 comments on commit f9ae7c2

Please sign in to comment.