Работа с JSON RPC в Symfony 4


Всем привет, сегодня поговорим о том, как подружить 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\SymfonyJsonRpcParamsValidator\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.


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

Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 14

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

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

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

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

        0

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

          0

          Самое лучшее, на мой дилетантский взгляд, это передача токена авторизации в метод. Можно, конечно, воспользоваться заголовками запроса, но json-rpc всё-таки транспортно-независимый протокол.

            0

            Все зависит от того, что это за токен. Если токен является неотъемлемым параметром некоего конкретного метода — тогда передавать параметром метода. А если токен на вообще доступ к данному API, тогда в заголовке самое то: в этом случае авторизация лежит вне скоупа json-rpc.

              0

              Авторизация и аутентификация — это скорее всего не про ограничение доступа к API. А так да, согласен

            0
            Упс, я подумал об авторизации и аутентификации применительно к Symfony вообще, а оказалось, что имеется ввиду авторизация при запросе к API.

            Самый просто способ и логичный это заголовок Authorization. В нем уже можно передавать все что нужно, будь то Basic метод, OAuth или JWT.
            0

            Не понял только, зачем здесь swagger, он на rpc ну вообще не ложится… Есть же SMD. sergeyfast даже как-то предлагал инструменты для визуализации


            Ещё бы научить всё это асинхронной обработке методов при batch-запросах, чтобы можно быть не ждать, пока обработается уведомление (потому что оно всё равно не ответит), чтобы время ответа соответствовало обработке самого долгого метода...

              +1

              Можно класть notifications (запросы без ID), пришедшие в батчах по HTTP в очередь — вот прямо jsonrpc-запросами как есть, а фоновым процессом очередь грести и каждый ее элемент обрабатывать тем же самым JSON RPC сервером: ему ж должно быть без разницы, какой транспорт.

                0
                Swagger здесь только как средство визуализации OpenAPI 3 JSON документации. У меня не было цели написать свою реализацию генерации JSON (на данный момент есть Swagger 2 и OpenAPI 3), поэтому воспользовался тем, что есть. Но реализовать SMD вариант документации хорошая идея, просто пока что она сейчас не в моих приоритетах.
                0

                Для Laravel у меня есть вот такой велосипед, может, кому пригодится.


                Кстати, отвязать его от Laravel совсем несложно. Я для нового проекта активно посматриваю на SF4, скорее всего, так и сделаю. Заменить Middleware/Pipelines на PSR-15 несложно, но еще надо придумать, что делать с FormRequests (которые у меня сейчас завязаны на illuminate/validation и сделаны по аналогии с Laravel-овскими).

                  0
                  Я перешел на новую работу и начал проект на Symfony 4, хотя не имел ранее опыта работы с ним, а работал с Yii2. Поначалу мозги вскипели, но теперь я не понимаю как я раньше пользовался Yii2 с его «прибитыми гвоздями» библиотеками. Так что — дерзай.
                    0

                    Не, ну в Laravel все не так плохо, гвоздями прибито намного меньше. Но косяков с этим хватает, многие интерфейсы, хоть формально и присутствуют, на самом деле зачастую просто дублируют фактический интерфейс конкретной реализации со всеми ее особенностями, и, чтобы заменить реализацию, временами приходится писать довольно костыльные адаптеры. Особенно это ощущается, когда берешь за основу Lumen и хочешь реюзать только часть Laravel-овских библиотек.


                    Основные претензии у меня к Eloquent, с которым следовать DDD и CQRS довольно затруднительно (да даже нормально заперсистить Aggregate Root можно только чудовищными костылями). Был странноватый, но в целом неплохой Analogue ORM, но его забросили. Доктрину, впрочем, тоже недолюбливаю за ее монстроузность: меня пугает такая масса сложного кода и архитектура с God Object-ом. Прочитал вот в дайджесте про Cycle ORM, наверное, это то, что мне надо.

                Only users with full accounts can post comments. Log in, please.