Merge pull request #37375 from ceph/47615-fix-endpoint-responses

Reviewed-by: Ernesto Puerta <epuertat@redhat.com>
Reviewed-by: Laura Paduano <lpaduano@suse.com>
Reviewed-by: Tatjana Dehler <tdehler@suse.com>
This commit is contained in:
Lenz Grimmer 2020-10-09 13:43:52 +02:00 committed by GitHub
commit 661aa031ee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 698 additions and 574 deletions

View File

@ -0,0 +1 @@
from __future__ import absolute_import

View File

@ -0,0 +1,53 @@
from enum import Enum
from typing import Any, Dict, List, Optional
class SchemaType(Enum):
"""
Representation of the type property of a schema object:
http://spec.openapis.org/oas/v3.0.3.html#schema-object
"""
ARRAY = 'array'
BOOLEAN = 'boolean'
INTEGER = 'integer'
NUMBER = 'number'
OBJECT = 'object'
STRING = 'string'
def __str__(self):
return str(self.value)
class Schema:
"""
Representation of a schema object:
http://spec.openapis.org/oas/v3.0.3.html#schema-object
"""
def __init__(self, schema_type: SchemaType = SchemaType.OBJECT,
properties: Optional[Dict] = None, required: Optional[List] = None):
self._type = schema_type
self._properties = properties if properties else {}
self._required = required if required else []
def as_dict(self) -> Dict[str, Any]:
schema: Dict[str, Any] = {'type': str(self._type)}
if self._type == SchemaType.ARRAY:
items = Schema(properties=self._properties)
schema['items'] = items.as_dict()
else:
schema['properties'] = self._properties
if self._required:
schema['required'] = self._required
return schema
class SchemaInput:
"""
Simple DTO to transfer data in a structured manner for creating a schema object.
"""
type: SchemaType
params: List[Any]

View File

@ -11,12 +11,13 @@ import os
import pkgutil
import re
import sys
import urllib
from functools import wraps
from urllib.parse import unquote
# pylint: disable=wrong-import-position
import cherrypy
from ..api.doc import SchemaInput, SchemaType
from ..exceptions import PermissionNotValid, ScopeNotValid
from ..plugins import PLUGIN_MANAGER
from ..security import Permission, Scope
@ -107,7 +108,12 @@ def EndpointDoc(description="", group="", parameters=None, responses=None): # n
resp = {}
if responses:
for status_code, response_body in responses.items():
resp[str(status_code)] = _split_parameters(response_body)
schema_input = SchemaInput()
schema_input.type = SchemaType.ARRAY if \
isinstance(response_body, list) else SchemaType.OBJECT
schema_input.params = _split_parameters(response_body)
resp[str(status_code)] = schema_input
def _wrapper(func):
func.doc_info = {
@ -662,7 +668,7 @@ class BaseController(object):
def inner(*args, **kwargs):
for key, value in kwargs.items():
if isinstance(value, str):
kwargs[key] = urllib.parse.unquote(value)
kwargs[key] = unquote(value)
# Process method arguments.
params = get_request_body_params(cherrypy.request)

View File

@ -2,11 +2,12 @@
from __future__ import absolute_import
import logging
from typing import Any, Dict, Union
from typing import Any, Dict, List, Union
import cherrypy
from .. import mgr
from ..api.doc import Schema, SchemaInput, SchemaType
from . import ENDPOINT_MAP, BaseController, Controller, Endpoint, allow_empty_body
NO_DESCRIPTION_AVAILABLE = "*No description available*"
@ -71,35 +72,35 @@ class Docs(BaseController):
param_name = param['name']
def_value = param['default'] if 'default' in param else None
if param_name.startswith("is_"):
return "boolean"
return str(SchemaType.BOOLEAN)
if "size" in param_name:
return "integer"
return str(SchemaType.INTEGER)
if "count" in param_name:
return "integer"
return str(SchemaType.INTEGER)
if "num" in param_name:
return "integer"
return str(SchemaType.INTEGER)
if isinstance(def_value, bool):
return "boolean"
return str(SchemaType.BOOLEAN)
if isinstance(def_value, int):
return "integer"
return "string"
return str(SchemaType.INTEGER)
return str(SchemaType.STRING)
@classmethod
# isinstance doesn't work: input is always <type 'type'>.
def _type_to_str(cls, type_as_type):
""" Used if type is explicitly defined. """
if type_as_type is str:
type_as_str = 'string'
type_as_str = str(SchemaType.STRING)
elif type_as_type is int:
type_as_str = 'integer'
type_as_str = str(SchemaType.INTEGER)
elif type_as_type is bool:
type_as_str = 'boolean'
type_as_str = str(SchemaType.BOOLEAN)
elif type_as_type is list or type_as_type is tuple:
type_as_str = 'array'
type_as_str = str(SchemaType.ARRAY)
elif type_as_type is float:
type_as_str = 'number'
type_as_str = str(SchemaType.NUMBER)
else:
type_as_str = 'object'
type_as_str = str(SchemaType.OBJECT)
return type_as_str
@classmethod
@ -143,13 +144,17 @@ class Docs(BaseController):
return parameters
@classmethod
def _gen_schema_for_content(cls, params):
def _gen_schema_for_content(cls, params: List[Any]) -> Dict[str, Any]:
"""
Generates information to the content-object in OpenAPI Spec.
Used to for request body and responses.
"""
required_params = []
properties = {}
schema_type = SchemaType.OBJECT
if isinstance(params, SchemaInput):
schema_type = params.type
params = params.params
for param in params:
if param['required']:
@ -159,12 +164,12 @@ class Docs(BaseController):
if 'type' in param:
props['type'] = cls._type_to_str(param['type'])
if 'nested_params' in param:
if props['type'] == 'array': # dict in array
if props['type'] == str(SchemaType.ARRAY): # dict in array
props['items'] = cls._gen_schema_for_content(param['nested_params'])
else: # dict in dict
props = cls._gen_schema_for_content(param['nested_params'])
elif props['type'] == 'object': # e.g. [int]
props['type'] = 'array'
elif props['type'] == str(SchemaType.OBJECT): # e.g. [int]
props['type'] = str(SchemaType.ARRAY)
props['items'] = {'type': cls._type_to_str(param['type'][0])}
else:
props['type'] = cls._gen_type(param)
@ -174,13 +179,10 @@ class Docs(BaseController):
props['default'] = param['default']
properties[param['name']] = props
schema = {
'type': 'object',
'properties': properties,
}
if required_params:
schema['required'] = required_params
return schema
schema = Schema(schema_type=schema_type, properties=properties,
required=required_params)
return schema.as_dict()
@classmethod
def _gen_responses(cls, method, resp_object=None):
@ -215,10 +217,11 @@ class Docs(BaseController):
if resp_object:
for status_code, response_body in resp_object.items():
resp[status_code].update({
'content': {
'application/json': {
'schema': cls._gen_schema_for_content(response_body)}}})
if status_code in resp:
resp[status_code].update({
'content': {
'application/json': {
'schema': cls._gen_schema_for_content(response_body)}}})
return resp

View File

@ -74,7 +74,7 @@ class Rgw(BaseController):
@ControllerDoc("RGW Daemon Management API", "RgwDaemon")
class RgwDaemon(RESTController):
@EndpointDoc("Display RGW Daemons",
responses={200: RGW_DAEMON_SCHEMA})
responses={200: [RGW_DAEMON_SCHEMA]})
def list(self):
# type: () -> List[dict]
daemons = []

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
# # -*- coding: utf-8 -*-
from __future__ import absolute_import
from ..api.doc import SchemaType
from ..controllers import ApiController, ControllerDoc, Endpoint, EndpointDoc, RESTController
from ..controllers.docs import Docs
from . import ControllerTestCase # pylint: disable=no-name-in-module
@ -10,6 +11,8 @@ from . import ControllerTestCase # pylint: disable=no-name-in-module
@ControllerDoc("Group description", group="FooGroup")
@ApiController("/doctest/", secure=False)
class DecoratedController(RESTController):
RESOURCE_ID = 'doctest'
@EndpointDoc(
description="Endpoint description",
group="BarGroup",
@ -17,12 +20,16 @@ class DecoratedController(RESTController):
'parameter': (int, "Description of parameter"),
},
responses={
200: {
'resp': (str, 'Description of response')
200: [{
'my_prop': (str, '200 property desc.')
}],
202: {
'my_prop': (str, '202 property desc.')
},
},
)
@Endpoint(json_response=False)
@RESTController.Resource('PUT')
def decorated_func(self, parameter):
pass
@ -54,18 +61,48 @@ class DocDecoratorsTest(ControllerTestCase):
class DocsTest(ControllerTestCase):
@classmethod
def setup_server(cls):
cls.setup_controllers([Docs], "/test")
cls.setup_controllers([DecoratedController, Docs], "/test")
def test_type_to_str(self):
self.assertEqual(Docs()._type_to_str(str), "string")
self.assertEqual(Docs()._type_to_str(str), str(SchemaType.STRING))
self.assertEqual(Docs()._type_to_str(int), str(SchemaType.INTEGER))
self.assertEqual(Docs()._type_to_str(bool), str(SchemaType.BOOLEAN))
self.assertEqual(Docs()._type_to_str(list), str(SchemaType.ARRAY))
self.assertEqual(Docs()._type_to_str(tuple), str(SchemaType.ARRAY))
self.assertEqual(Docs()._type_to_str(float), str(SchemaType.NUMBER))
self.assertEqual(Docs()._type_to_str(object), str(SchemaType.OBJECT))
self.assertEqual(Docs()._type_to_str(None), str(SchemaType.OBJECT))
def test_gen_paths(self):
outcome = Docs()._gen_paths(False)['/api/doctest//decorated_func/{parameter}']['get']
outcome = Docs()._gen_paths(False)['/api/doctest//{doctest}/decorated_func']['put']
self.assertIn('tags', outcome)
self.assertIn('summary', outcome)
self.assertIn('parameters', outcome)
self.assertIn('responses', outcome)
expected_response_content = {
'200': {
'application/json': {
'schema': {'type': 'array',
'items': {'type': 'object', 'properties': {
'my_prop': {
'type': 'string',
'description': '200 property desc.'}}},
'required': ['my_prop']}}},
'202': {
'application/json': {
'schema': {'type': 'object',
'properties': {'my_prop': {
'type': 'string',
'description': '202 property desc.'}},
'required': ['my_prop']}}
}
}
# Check that a schema of type 'array' is received in the response.
self.assertEqual(expected_response_content['200'], outcome['responses']['200']['content'])
# Check that a schema of type 'object' is received in the response.
self.assertEqual(expected_response_content['202'], outcome['responses']['202']['content'])
def test_gen_paths_all(self):
paths = Docs()._gen_paths(False)
for key in paths:

View File

@ -84,7 +84,7 @@ wrap_length = 80
[pylint]
# Allow similarity/code duplication detection
jobs = 1
dirs = . controllers plugins services tests
dirs = . api controllers plugins services tests
addopts = -rn --rcfile=.pylintrc --jobs={[pylint]jobs}
[rstlint]