Hello everyone, today we will talk about how to make friends with Symfony 4, JSON RPC and OpenAPI 3.
This article is not intended for beginners, you should already understand how to work with Symfony, Depedency Injection and other “scary” things.
Today we consider one specific implementation of JSON RPC.
There are many implementations of JSON RPC for symfony, in particular:
About the last just talk in this article. This library has several advantages that determined my choice.
It is developed without reference to any framework ( yoanm / php-jsonrpc-server-sdk ), there is a symfony bundle, it has several additional packages that allow you to add incoming data checking, automatic documentation, events and interfaces for the ability to supplement the work without overriding.
First, install symfony / skeleton.
$ composer create-project symfony/skeleton jsonrpc
Go to the project folder.
$ cd jsonrpc
And install the necessary library.
$ composer require yoanm/symfony-jsonrpc-http-server
Customizable.
// config/bundles.php return [ ... Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], Yoanm\SymfonyJsonRpcHttpServer\JsonRpcHttpServerBundle::class => ['all' => true], ... ];
# config/routes.yaml json-rpc-endpoint: resource: '@JsonRpcHttpServerBundle/Resources/config/routing/endpoint.xml'
# config/packages/json_rpc.yaml json_rpc_http_server: ~
Add a service that will store all our methods.
// src/MappingCollector.php <?php namespace App; use Yoanm\JsonRpcServer\Domain\JsonRpcMethodAwareInterface; use Yoanm\JsonRpcServer\Domain\JsonRpcMethodInterface; class MappingCollector implements JsonRpcMethodAwareInterface { /** @var JsonRpcMethodInterface[] */ private $mappingList = []; public function addJsonRpcMethod(string $methodName, JsonRpcMethodInterface $method): void { $this->mappingList[$methodName] = $method; } /** * @return JsonRpcMethodInterface[] */ public function getMappingList() : array { return $this->mappingList; } }
And we add service to services.yaml.
# config/services.yaml services: ... mapping_aware_service: class: App\MappingCollector tags: ['json_rpc_http_server.method_aware'] ...
JSON RPC methods are added as normal services in the services.yaml file. We first implement the ping method itself.
// src/Method/PingMethod.php <?php namespace App\Method; use Yoanm\JsonRpcServer\Domain\JsonRpcMethodInterface; class PingMethod implements JsonRpcMethodInterface { public function apply(array $paramList = null) { return 'pong'; } }
And add as a service.
# config/services.yaml services: ... App\Method\PingMethod: public: false tags: [{ method: 'ping', name: 'json_rpc_http_server.jsonrpc_method' }] ...
We launch the built-in web server symfony.
$ symfony serve
We try to make a call.
$ curl 'http://127.0.0.1:8000/json-rpc' --data-binary '[{ "jsonrpc":"2.0","method":"ping","params":[],"id" : 1 }]'
[ { "jsonrpc": "2.0", "id": 1, "result": "pong" } ]
Now we implement the method that gets the parameters. As a response, we return the input data.
// src/Method/ParamsMethod.php <?php namespace App\Method; use Yoanm\JsonRpcServer\Domain\JsonRpcMethodInterface; class ParamsMethod implements JsonRpcMethodInterface { public function apply(array $paramList = null) { return $paramList; } }
# config/services.yaml services: ... App\Method\ParamsMethod: public: false tags: [{ method: 'params', name: 'json_rpc_http_server.jsonrpc_method' }] ...
We try to call.
$ curl 'http://127.0.0.1:8000/json-rpc' --data-binary '[{ "jsonrpc":"2.0","method":"params","params":{"name":"John","age":21},"id" : 1 }]'
[ { "jsonrpc": "2.0", "id": 1, "result": { "name": "John", "age": 21 } } ]
If automatic data verification is required at the input of the method, then in this case there is a package yoanm / symfony-jsonrpc-params-validator .
$ composer require yoanm/symfony-jsonrpc-params-validator
We connect the bundle.
// config/bundles.php return [ ... Yoanm\JsonRpcParamsValidatorBundle\JsonRpcParamsValidatorBundle::class => ['all' => true], ... ];
Methods that need input validation must implement the Yoanm \ JsonRpcParamsSymfonyValidator \ Domain \ MethodWithValidatedParamsInterface interface . Let's change the ParamsMethod class a bit .
// src/Method/ParamsMethod.php <?php namespace App\Method; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\Choice; use Symfony\Component\Validator\Constraints\Collection; use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\Optional; use Symfony\Component\Validator\Constraints\Positive; use Symfony\Component\Validator\Constraints\Required; use Yoanm\JsonRpcParamsSymfonyValidator\Domain\MethodWithValidatedParamsInterface; use Yoanm\JsonRpcServer\Domain\JsonRpcMethodInterface; class ParamsMethod implements JsonRpcMethodInterface, MethodWithValidatedParamsInterface { public function apply(array $paramList = null) return $paramList; } public function getParamsConstraint() : Constraint return new Collection(['fields' => [ 'name' => new Required([ new Length(['min' => 1, 'max' => 32]) ]), 'age' => new Required([ new Positive() ]), 'sex' => new Optional([ new Choice(['f', 'm']) ]), ]]); } }
Now, if we execute the query with empty parameters or with errors, we get the corresponding errors in response.
$ curl 'http://127.0.0.1:8000/json-rpc' --data-binary '[{"jsonrpc":"2.0","method":"params","params":[],"id" : 1 }]'
[ { "jsonrpc": "2.0", "id": 1, "error": { "code": -32602, "message": "Invalid params", "data": { "violations": [ { "path": "[name]", "message": "This field is missing.", "code": "2fa2158c-2a7f-484b-98aa-975522539ff8" }, { "path": "[age]", "message": "This field is missing.", "code": "2fa2158c-2a7f-484b-98aa-975522539ff8" } ] } } } ]
$ curl 'http://127.0.0.1:8000/json-rpc' --data-binary '[{"jsonrpc":"2.0","method":"params","params":{"name":"John","age":-1},"id" : 1 }]'
[ { "jsonrpc": "2.0", "id": 1, "error": { "code": -32602, "message": "Invalid params", "data": { "violations": [ { "path": "[age]", "message": "This value should be positive.", "code": "778b7ae0-84d3-481a-9dec-35fdb64b1d78" } ] } } }
$ curl 'http://127.0.0.1:8000/json-rpc' --data-binary '[{ "jsonrpc":"2.0","method":"params","params":{"name":"John","age":21,"sex":"u"},"id" : 1 }]'
[ { "jsonrpc": "2.0", "id": 1, "error": { "code": -32602, "message": "Invalid params", "data": { "violations": [ { "path": "[sex]", "message": "The value you selected is not a valid choice.", "code": "8e179f1b-97aa-4560-a02f-2a8b42e49df7" } ] } } } ]
Install an additional package.
composer require yoanm/symfony-jsonrpc-http-server-doc
Configure the bundle.
// config/bundles.php return [ ... Yoanm\SymfonyJsonRpcHttpServerDoc\JsonRpcHttpServerDocBundle::class => ['all' => true], ... ];
# config/routes.yaml ... json-rpc-endpoint-doc: resource: '@JsonRpcHttpServerDocBundle/Resources/config/routing/endpoint.xml'
# config/packages/json_rpc.yaml ... json_rpc_http_server_doc: ~
Now you can get the documentation in JSON format.
$ curl 'http://127.0.0.1:8000/doc'
{ "methods": [ { "identifier": "Params", "name": "params" }, { "identifier": "Ping", "name": "ping" } ], "errors": [ { "id": "ParseError-32700", "title": "Parse error", "type": "object", "properties": { "code": -32700 } }, { "id": "InvalidRequest-32600", "title": "Invalid request", "type": "object", "properties": { "code": -32600 } }, { "id": "MethodNotFound-32601", "title": "Method not found", "type": "object", "properties": { "code": -32601 } }, { "id": "ParamsValidationsError-32602", "title": "Params validations error", "type": "object", "properties": { "code": -32602, "data": { "type": "object", "nullable": true, "required": true, "siblings": { "violations": { "type": "array", "nullable": true, "required": false } } } } }, { "id": "InternalError-32603", "title": "Internal error", "type": "object", "properties": { "code": -32603, "data": { "type": "object", "nullable": true, "required": false, "siblings": { "previous": { "description": "Previous error message", "type": "string", "nullable": true, "required": false } } } } } ], "http": { "host": "127.0.0.1:8000" } }
But how so? And where is the description of the input parameters? To do this, put another bundle yoanm / symfony-jsonrpc-params-sf-constraints-doc .
$ composer require yoanm/symfony-jsonrpc-params-sf-constraints-doc
// config/bundles.php return [ ... Yoanm\SymfonyJsonRpcParamsSfConstraintsDoc\JsonRpcParamsSfConstraintsDocBundle::class => ['all' => true], ... ];
Now if we make a request, we will get JSON already methods with parameters.
$ curl 'http://127.0.0.1:8000/doc'
{ "methods": [ { "identifier": "Params", "name": "params", "params": { "type": "object", "nullable": false, "required": true, "siblings": { "name": { "type": "string", "nullable": true, "required": true, "minLength": 1, "maxLength": 32 }, "age": { "type": "string", "nullable": true, "required": true }, "sex": { "type": "string", "nullable": true, "required": false, "allowedValues": [ "f", "m" ] } } } }, { "identifier": "Ping", "name": "ping" } ], "errors": [ { "id": "ParseError-32700", "title": "Parse error", "type": "object", "properties": { "code": -32700 } }, { "id": "InvalidRequest-32600", "title": "Invalid request", "type": "object", "properties": { "code": -32600 } }, { "id": "MethodNotFound-32601", "title": "Method not found", "type": "object", "properties": { "code": -32601 } }, { "id": "ParamsValidationsError-32602", "title": "Params validations error", "type": "object", "properties": { "code": -32602, "data": { "type": "object", "nullable": true, "required": true, "siblings": { "violations": { "type": "array", "nullable": true, "required": false, "item_validation": { "type": "object", "nullable": true, "required": true, "siblings": { "path": { "type": "string", "nullable": true, "required": true, "example": "[key]" }, "message": { "type": "string", "nullable": true, "required": true }, "code": { "type": "string", "nullable": true, "required": false } } } } } } } }, { "id": "InternalError-32603", "title": "Internal error", "type": "object", "properties": { "code": -32603, "data": { "type": "object", "nullable": true, "required": false, "siblings": { "previous": { "description": "Previous error message", "type": "string", "nullable": true, "required": false } } } } } ], "http": { "host": "127.0.0.1:8000" } }
In order for JSON documentation to be compatible with the OpenAPI 3 standard, you need to install yoanm / symfony-jsonrpc-http-server-openapi-doc .
$ composer require yoanm/symfony-jsonrpc-http-server-openapi-doc
Customizable.
// config/bundles.php return [ ... Yoanm\SymfonyJsonRpcHttpServerOpenAPIDoc\JsonRpcHttpServerOpenAPIDocBundle::class => ['all' => true], ... ];
Having made a new request, we will get JSON documentation in the OpenApi 3 format.
$ curl 'http://127.0.0.1:8000/doc/openapi.json'
{ "openapi": "3.0.0", "servers": [ { "url": "http:\/\/127.0.0.1:8000" } ], "paths": { "\/Params\/..\/json-rpc": { "post": { "summary": "\"params\" json-rpc method", "operationId": "Params", "requestBody": { "required": true, "content": { "application\/json": { "schema": { "allOf": [ { "type": "object", "required": [ "jsonrpc", "method" ], "properties": { "id": { "example": "req_id", "oneOf": [ { "type": "string" }, { "type": "number" } ] }, "jsonrpc": { "type": "string", "example": "2.0" }, "method": { "type": "string" }, "params": { "title": "Method parameters" } } }, { "type": "object", "required": [ "params" ], "properties": { "params": { "$ref": "#\/components\/schemas\/Method-Params-RequestParams" } } }, { "type": "object", "properties": { "method": { "example": "params" } } } ] } } } }, "responses": { "200": { "description": "JSON-RPC response", "content": { "application\/json": { "schema": { "allOf": [ { "type": "object", "required": [ "jsonrpc" ], "properties": { "id": { "example": "req_id", "oneOf": [ { "type": "string" }, { "type": "number" } ] }, "jsonrpc": { "type": "string", "example": "2.0" }, "result": { "title": "Result" }, "error": { "title": "Error" } } }, { "type": "object", "properties": { "result": { "description": "Method result" } } }, { "type": "object", "properties": { "error": { "oneOf": [ { "$ref": "#\/components\/schemas\/ServerError-ParseError-32700" }, { "$ref": "#\/components\/schemas\/ServerError-InvalidRequest-32600" }, { "$ref": "#\/components\/schemas\/ServerError-MethodNotFound-32601" }, { "$ref": "#\/components\/schemas\/ServerError-ParamsValidationsError-32602" }, { "$ref": "#\/components\/schemas\/ServerError-InternalError-32603" } ] } } } ] } } } } } } }, "\/Ping\/..\/json-rpc": { "post": { "summary": "\"ping\" json-rpc method", "operationId": "Ping", "requestBody": { "required": true, "content": { "application\/json": { "schema": { "allOf": [ { "type": "object", "required": [ "jsonrpc", "method" ], "properties": { "id": { "example": "req_id", "oneOf": [ { "type": "string" }, { "type": "number" } ] }, "jsonrpc": { "type": "string", "example": "2.0" }, "method": { "type": "string" }, "params": { "title": "Method parameters" } } }, { "type": "object", "properties": { "method": { "example": "ping" } } } ] } } } }, "responses": { "200": { "description": "JSON-RPC response", "content": { "application\/json": { "schema": { "allOf": [ { "type": "object", "required": [ "jsonrpc" ], "properties": { "id": { "example": "req_id", "oneOf": [ { "type": "string" }, { "type": "number" } ] }, "jsonrpc": { "type": "string", "example": "2.0" }, "result": { "title": "Result" }, "error": { "title": "Error" } } }, { "type": "object", "properties": { "result": { "description": "Method result" } } }, { "type": "object", "properties": { "error": { "oneOf": [ { "$ref": "#\/components\/schemas\/ServerError-ParseError-32700" }, { "$ref": "#\/components\/schemas\/ServerError-InvalidRequest-32600" }, { "$ref": "#\/components\/schemas\/ServerError-MethodNotFound-32601" }, { "$ref": "#\/components\/schemas\/ServerError-ParamsValidationsError-32602" }, { "$ref": "#\/components\/schemas\/ServerError-InternalError-32603" } ] } } } ] } } } } } } } }, "components": { "schemas": { "Method-Params-RequestParams": { "type": "object", "nullable": false, "required": [ "name", "age" ], "properties": { "name": { "type": "string", "nullable": true, "minLength": 1, "maxLength": 32 }, "age": { "type": "string", "nullable": true }, "sex": { "type": "string", "nullable": true, "enum": [ "f", "m" ] } } }, "ServerError-ParseError-32700": { "title": "Parse error", "allOf": [ { "type": "object", "required": [ "code", "message" ], "properties": { "code": { "type": "number" }, "message": { "type": "string" } } }, { "type": "object", "required": [ "code" ], "properties": { "code": { "example": -32700 } } } ] }, "ServerError-InvalidRequest-32600": { "title": "Invalid request", "allOf": [ { "type": "object", "required": [ "code", "message" ], "properties": { "code": { "type": "number" }, "message": { "type": "string" } } }, { "type": "object", "required": [ "code" ], "properties": { "code": { "example": -32600 } } } ] }, "ServerError-MethodNotFound-32601": { "title": "Method not found", "allOf": [ { "type": "object", "required": [ "code", "message" ], "properties": { "code": { "type": "number" }, "message": { "type": "string" } } }, { "type": "object", "required": [ "code" ], "properties": { "code": { "example": -32601 } } } ] }, "ServerError-ParamsValidationsError-32602": { "title": "Params validations error", "allOf": [ { "type": "object", "required": [ "code", "message" ], "properties": { "code": { "type": "number" }, "message": { "type": "string" } } }, { "type": "object", "required": [ "code", "data" ], "properties": { "code": { "example": -32602 }, "data": { "type": "object", "nullable": true, "properties": { "violations": { "type": "array", "nullable": true, "items": { "type": "object", "nullable": true, "required": [ "path", "message" ], "properties": { "path": { "type": "string", "nullable": true, "example": "[key]" }, "message": { "type": "string", "nullable": true }, "code": { "type": "string", "nullable": true } } } } } } } } ] }, "ServerError-InternalError-32603": { "title": "Internal error", "allOf": [ { "type": "object", "required": [ "code", "message" ], "properties": { "code": { "type": "number" }, "message": { "type": "string" } } }, { "type": "object", "required": [ "code" ], "properties": { "code": { "example": -32603 }, "data": { "type": "object", "nullable": true, "properties": { "previous": { "description": "Previous error message", "type": "string", "nullable": true } } } } } ] } } } }
There is no regular functionality (for example, by implementing an interface) that allows adding method responses to the documentation. But it is possible, by subscribing to events, to add the necessary information on your own.
Add a listener.
# config/services.yaml services: ... App\Listener\MethodDocListener: tags: - name: 'kernel.event_listener' event: 'json_rpc_http_server_doc.method_doc_created' method: 'enhanceMethodDoc' - name: 'kernel.event_listener' event: 'json_rpc_http_server_openapi_doc.array_created' method: 'enhanceDoc' ...
// src/Listener/MethodDocListener.php <?php namespace App\Listener; use App\Domain\JsonRpcMethodWithDocInterface; use Yoanm\JsonRpcServerDoc\Domain\Model\ErrorDoc; use Yoanm\SymfonyJsonRpcHttpServerDoc\Event\MethodDocCreatedEvent; use Yoanm\SymfonyJsonRpcHttpServerOpenAPIDoc\Event\OpenAPIDocCreatedEvent; class MethodDocListener { public function enhanceMethodDoc(MethodDocCreatedEvent $event) : void { $method = $event->getMethod(); if ($method instanceof JsonRpcMethodWithDocInterface) { $doc = $event->getDoc(); $doc->setResultDoc($method->getDocResponse()); foreach ($method->getDocErrors() as $error) { if ($error instanceof ErrorDoc) { $doc->addCustomError($error); } } $doc->setDescription($method->getDocDescription()); $doc->addTag($method->getDocTag()); } } public function enhanceDoc(OpenAPIDocCreatedEvent $event) { $doc = $event->getOpenAPIDoc(); $doc['info'] = [ 'title' => 'Main title', 'version' => '1.0.0', 'description' => 'Main description' ]; $event->setOpenAPIDoc($doc); } }
Also, in order not to describe the documentation of the methods directly in the listener, let's make an interface that the methods themselves will have to implement.
// src/Domain/JsonRpcMethodWithDocInterface.php <?php namespace App\Domain; use Yoanm\JsonRpcServerDoc\Domain\Model\ErrorDoc; use Yoanm\JsonRpcServerDoc\Domain\Model\Type\TypeDoc; interface JsonRpcMethodWithDocInterface { /** * @return TypeDoc */ public function getDocResponse(): TypeDoc; /** * @return ErrorDoc[] */ public function getDocErrors(): array; /** * @return string */ public function getDocDescription(): string; /** * @return string */ public function getDocTag(): string; }
Now add a new method that will contain the necessary information.
// src/Method/UserMethod.php <?php namespace App\Method; use App\Domain\JsonRpcMethodWithDocInterface; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\Choice; use Symfony\Component\Validator\Constraints\Collection; use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\Optional; use Symfony\Component\Validator\Constraints\Positive; use Symfony\Component\Validator\Constraints\Required; use Yoanm\JsonRpcParamsSymfonyValidator\Domain\MethodWithValidatedParamsInterface; use Yoanm\JsonRpcServer\Domain\JsonRpcMethodInterface; use Yoanm\JsonRpcServerDoc\Domain\Model\ErrorDoc; use Yoanm\JsonRpcServerDoc\Domain\Model\Type\ArrayDoc; use Yoanm\JsonRpcServerDoc\Domain\Model\Type\NumberDoc; use Yoanm\JsonRpcServerDoc\Domain\Model\Type\ObjectDoc; use Yoanm\JsonRpcServerDoc\Domain\Model\Type\StringDoc; use Yoanm\JsonRpcServerDoc\Domain\Model\Type\TypeDoc; class UserMethod implements JsonRpcMethodInterface, MethodWithValidatedParamsInterface, JsonRpcMethodWithDocInterface { public function apply(array $paramList = null) { return [ 'name' => $paramList['name'], 'age' => $paramList['age'], 'sex' => $paramList['sex'] ?? null, ]; } public function getParamsConstraint() : Constraint { return new Collection(['fields' => [ 'name' => new Required([ new Length(['min' => 1, 'max' => 32]) ]), 'age' => new Required([ new Positive() ]), 'sex' => new Optional([ new Choice(['f', 'm']) ]), ]]); } public function getDocDescription(): string { return 'User method'; } public function getDocTag(): string { return 'main'; } public function getDocErrors(): array { return [new ErrorDoc('Error 1', 1)]; } public function getDocResponse(): TypeDoc { $response = new ObjectDoc(); $response->setNullable(false); $response->addSibling((new StringDoc()) ->setNullable(false) ->setDescription('Name of user') ->setName('name') ); $response->addSibling((new NumberDoc()) ->setNullable(false) ->setDescription('Age of user') ->setName('age') ); $response->addSibling((new StringDoc()) ->setNullable(true) ->setDescription('Sex of user') ->setName('sex') ); return $response; } }
Do not forget to register a new service.
services: ... App\Method\UserMethod: public: false tags: [{ method: 'user', name: 'json_rpc_http_server.jsonrpc_method' }] ...
Now making a new request to /doc/openapi.json , we will get new data.
curl 'http://127.0.0.1:8000/doc/openapi.json'
{ "openapi": "3.0.0", "servers": [ { "url": "http:\/\/127.0.0.1:8000" } ], "paths": { ... "\/User\/..\/json-rpc": { "post": { "summary": "\"user\" json-rpc method", "description": "User method", "tags": [ "main" ], ... "responses": { "200": { "description": "JSON-RPC response", "content": { "application\/json": { "schema": { "allOf": [ ... { "type": "object", "properties": { "result": { "$ref": "#\/components\/schemas\/Method-User-Result" } } }, { "type": "object", "properties": { "error": { "oneOf": [ { "$ref": "#\/components\/schemas\/Error-Error11" }, ... ] } } } ] } } } } } } } }, "components": { "schemas": { ... "Method-User-Result": { "type": "object", "nullable": false, "properties": { "name": { "description": "Name of user", "type": "string", "nullable": false }, "age": { "description": "Age of user", "type": "number", "nullable": false }, "sex": { "description": "Sex of user", "type": "string", "nullable": true } } }, "Error-Error11": { "title": "Error 1", "allOf": [ { "type": "object", "required": [ "code", "message" ], "properties": { "code": { "type": "number" }, "message": { "type": "string" } } }, { "type": "object", "required": [ "code" ], "properties": { "code": { "example": 1 } } } ] }, ... } }, "info": { "title": "Main title", "version": "1.0.0", "description": "Main description" } }
JSON is cool, but people usually want to see a more human result. The file /doc/openapi.json can be given to external visualization services, for example, the Swagger Editor .
If you wish, you can install Swagger UI in our project. Use the package harmbandstra / swagger-ui-bundle .
To correctly publish resources, add the following with composer.json.
"scripts": { "auto-scripts": { "cache:clear": "symfony-cmd", "assets:install %PUBLIC_DIR%": "symfony-cmd" }, "post-install-cmd": [ "HarmBandstra\\SwaggerUiBundle\\Composer\\ScriptHandler::linkAssets", "@auto-scripts" ], "post-update-cmd": [ "HarmBandstra\\SwaggerUiBundle\\Composer\\ScriptHandler::linkAssets", "@auto-scripts" ] },
After we put the package.
$ composer require harmbandstra/swagger-ui-bundle
We connect the bundle.
// config/bundles.php <?php return [ // ... HarmBandstra\SwaggerUiBundle\HBSwaggerUiBundle::class => ['dev' => true] ];
# config/routes.yaml _swagger-ui: resource: '@HBSwaggerUiBundle/Resources/config/routing.yml' prefix: /docs
# config/packages/hb_swagger_ui.yaml hb_swagger_ui: directory: "http://127.0.0.1:8000" files: - "/doc/openapi.json"
Now, following the link http://127.0.0.1:8000/docs/, we will receive the documentation in a beautiful way.
As a result, we performed all the manipulations with a working JSON RPC based on Symfony 4 and automatic OpenAPI documentation with visualization using Swagger UI.
Thanks to all.
Source: https://habr.com/ru/post/457750/
All Articles