Всем привет, сегодня поговорим о том, как подружить Symfony 4, JSON RPC и OpenAPI 3.


Данная статья рассчитана не на новичков, вы уже должны понимать как работать с Symfony, Depedency Injection и другими «страшными» вещами.


Сегодня рассмотрим одну конкретную реализацию JSON RPC.


Реализации


Есть множество реализаций JSON RPC для Symfony, в частности:



О последней как раз и поговорим в данной статье. Данная библиотека несколько преимуществ, которые определили мой выбор.


Она разработана без привязки к какому либо фреймворку (yoanm/php-jsonrpc-server-sdk), есть бандл для Symfony, имеет несколько дополнительных пакетов, позволяющие добавить проверку входящих данных, автоматическую документацию, события и интерфейсы для возможности дополнить работу без переопределения.


Установка


Для начала устанавливаем symfony/skeleton.


$ composer create-project symfony/skeleton jsonrpc

Переходим в папку проекта.


$ cd jsonrpc

И устанавливаем необходимую библиотеку.


$ composer require yoanm/symfony-jsonrpc-http-server

Настраиваем.


// 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: ~

Добавляем сервис, который будет хранить все наши методы.


// 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;
   }
}

И добавляем сервис в services.yaml.


# config/services.yaml
services:
    ...
    mapping_aware_service:
        class: App\MappingCollector
        tags: ['json_rpc_http_server.method_aware']
    ...

Реализация методов


Методы JSON RPC добавляются как обычные сервисы в файле services.yaml. Реализуем сначала сам метод ping.


// 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';
   }
}

И добавим как сервис.


# config/services.yaml
services:
    ...
    App\Method\PingMethod:
        public: false
        tags: [{ method: 'ping', name: 'json_rpc_http_server.jsonrpc_method' }]
    ...

Запускаем встроенный веб сервер Symfony.


$ symfony serve

Пробуем сделать вызов.


$ 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"
  }
]

Теперь реализуем метод, получающий параметры. В качестве ответа вернем входные данные.


// 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' }]
    ...

Пробуем вызвать.


$ 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
    }
  }
]

Валидация входных данных метода


Если требуется автоматическая проверка данных на входе метода, то на этот случай есть пакет yoanm/symfony-jsonrpc-params-validator.


$ composer require yoanm/symfony-jsonrpc-params-validator

Подключаем бандл.


// config/bundles.php
return [
    ...
    Yoanm\JsonRpcParamsValidatorBundle\JsonRpcParamsValidatorBundle::class => ['all' => true],
    ...
];

Методы, которые нуждаются в проверке входных данных должны реализовать интерфейс Yoanm\JsonRpcParamsSymfonyValidator\Domain\MethodWithValidatedParamsInterface. Изменим немного класс ParamsMethod.


// 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'])
           ]),
       ]]);
   }
}

Теперь если выполним запрос с пустыми параметрами или с ошибками, то получим в ответ соответствующие ошибки.


$ 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"
          }
        ]
      }
    }
  }
]

Автодокументация


Устанавливаем дополнительный пакет.


composer require yoanm/symfony-jsonrpc-http-server-doc

Настраиваем бандл.


// 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: ~

Теперь можно получить документацию в JSON формате.


$ 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"
  }
}

Но как же так? А где описание входных параметров? Для этого нужно поставить еще один бандл 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],
    ...
];

Теперь если сделать запрос, то получим JSON уже методы с параметрами.


$ 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"
  }
}

OpenAPI 3


Для того, чтобы JSON документация была совместима со стандартом OpenAPI 3, нужно установить yoanm/symfony-jsonrpc-http-server-openapi-doc.


$ composer require yoanm/symfony-jsonrpc-http-server-openapi-doc

Настраиваем.


// config/bundles.php
return [
    ...
    Yoanm\SymfonyJsonRpcHttpServerOpenAPIDoc\JsonRpcHttpServerOpenAPIDocBundle::class => ['all' => true],
    ...
];

Сделав новый запрос, мы получим JSON документацию в формате OpenApi 3.


$ 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
                  }
                }
              }
            }
          }
        ]
      }
    }
  }
}

Документация ответа метода


Штатного функционала (например путем реализации интерфейса), позволяющего добавлять ответы методов в документацию, нет. Но есть возможность, путем подписки на события, добавить нужную информацию самостоятельно.


Добавляем слушателя.


# 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);
    }
}

Еще, для того, чтобы прямо в слушателе не описывать документацию методов, сделаем интерфейс, который должны будут реализовывать сами методы.


// 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;
}

Теперь добавим новый метод, который будет в себе содержать нужную информацию.


// 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;
    }
}

Не забываем прописать новый сервис.


services:
    ...
    App\Method\UserMethod:
        public: false
        tags: [{ method: 'user', name: 'json_rpc_http_server.jsonrpc_method' }]
    ...

Теперь сделав новый запрос к /doc/openapi.json, получим новые данные.


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 документации


JSON это круто, но люди обычно хотят видеть более человечный результат. Файл /doc/openapi.json можно отдать внешним сервисам визуализации, например Swagger Editor.



При желании можно установить Swagger UI и в нашем проекте. Воспользуемся пакетом harmbandstra/swagger-ui-bundle.


Для корректной публикации ресурсов добавляем с 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"
        ]
    },

После ставим пакет.


$ composer require harmbandstra/swagger-ui-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"

Теперь перейдя по ссылке http://127.0.0.1:8000/docs/ получим документацию в красивом виде.



Итоги


В результате все проведенных манипуляций мы получили работающий JSON RPC на базе Symfony 4 и автоматическую документацию OpenAPI с визуализацией с помощью Swagger UI.


Всем спасибо.

Комментарии (4)


  1. BoShurik
    26.06.2019 17:21
    +1

    Она разработана без привязки к Symfony

    Все-таки yoanm/symfony-jsonrpc-http-server — это бандл для симфони, он очень даже привязан к ней. Речь наверное про yoanm/jsonrpc-server-sdk


    1. vladdnepr Автор
      26.06.2019 17:58

      Спасибо за замечание, исправил.


  1. alsii
    28.06.2019 09:45

    Хотелесь бы также послушать начальника транспортного цеха об авторизации и аутентификации.


    1. vladdnepr Автор
      28.06.2019 22:16

      Как раз планирую статью про Symfony 4 и Sonata Admin. Там будут и про авторизацию с аутентификацией.