
It can happen (in Keystone it does for sure) that certain API method decorators when applied together result in certain schemas being present in multiple decorator processing iterations. This results in some schemas being multiplied. It should be safe to just use `set` instead of a `list` to deduplicate them. While we were working on this Nova "again" changed some decorators breaking us so fix that as well since generated code is broken. Change-Id: I25b2506264d6027f9d605c74297e6f0cc6ab2767 Signed-off-by: Artem Goncharov <artem.goncharov@gmail.com>
1657 lines
65 KiB
Python
1657 lines
65 KiB
Python
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
# not use this file except in compliance with the License. You may obtain
|
|
# a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
# License for the specific language governing permissions and limitations
|
|
# under the License.
|
|
#
|
|
import abc
|
|
import copy
|
|
import datetime
|
|
import enum
|
|
import importlib
|
|
import inspect
|
|
import jsonref
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Literal
|
|
import re
|
|
|
|
from codegenerator import common
|
|
from codegenerator.common.schema import ParameterSchema
|
|
from codegenerator.common.schema import PathSchema
|
|
from codegenerator.common.schema import SpecSchema
|
|
from codegenerator.common.schema import TypeSchema
|
|
from codegenerator.metadata import MetadataGenerator
|
|
from codegenerator import model
|
|
from codegenerator.openapi.utils import rst_to_md
|
|
from openapi_core import Spec
|
|
from openapi_spec_validator import validate
|
|
from ruamel.yaml.scalarstring import LiteralScalarString
|
|
from ruamel.yaml import YAML
|
|
from wsme import types as wtypes
|
|
|
|
|
|
VERSION_RE = re.compile(r"[Vv][0-9\.]*")
|
|
|
|
|
|
# Workaround Python's lack of an undefined sentinel
|
|
# https://python-patterns.guide/python/sentinel-object/
|
|
class Unset:
|
|
def __bool__(self) -> Literal[False]:
|
|
return False
|
|
|
|
|
|
class QueryParamsSchema:
|
|
schema: dict[Any, Any] | None = None
|
|
min_version: str | None = None
|
|
max_version: str | None = None
|
|
|
|
def __init__(
|
|
self,
|
|
schema: dict[Any, Any] | None,
|
|
min_version: str | None,
|
|
max_version: str | None,
|
|
):
|
|
self.schema = schema
|
|
self.min_version = min_version
|
|
self.max_version = max_version
|
|
|
|
def __hash(self):
|
|
return hash((self.min_version, self.max_version))
|
|
|
|
|
|
UNSET: Unset = Unset()
|
|
|
|
|
|
def get_referred_type_data(func, name: str):
|
|
"""Get python type object referred by the function
|
|
|
|
Return `some.object` for a function like:
|
|
|
|
@wsgi.validation(some.object)
|
|
def foo():
|
|
pass
|
|
|
|
:param func: Function
|
|
:param str name: object name
|
|
"""
|
|
module = inspect.getmodule(func)
|
|
if module:
|
|
(mod, obj) = (None, None)
|
|
if "." in name:
|
|
(mod, obj) = name.split(".")
|
|
else:
|
|
raise RuntimeError('No "." in %s', name)
|
|
m = importlib.import_module(module.__name__)
|
|
if hasattr(m, mod):
|
|
mod = getattr(m, mod)
|
|
else:
|
|
raise RuntimeError("Cannot find attr %s", name)
|
|
if hasattr(mod, obj):
|
|
return getattr(mod, obj)
|
|
else:
|
|
raise RuntimeError("Cannot find definition for %s", name)
|
|
else:
|
|
raise RuntimeError("Cannot get module the function was defined in")
|
|
|
|
|
|
def sort_schema(data: Any):
|
|
def schema_tag_order(item):
|
|
orders = {
|
|
"openapi": 0,
|
|
"info": 1,
|
|
"jsonSchemaDialect": 2,
|
|
"servers": 3,
|
|
"paths": 4,
|
|
"components": 5,
|
|
"security": 6,
|
|
"webhooks": 7,
|
|
"tags": 8,
|
|
"externalDocs": 9,
|
|
}
|
|
return orders.get(item[0], item[0])
|
|
|
|
return {
|
|
key: sort_data(value)
|
|
for key, value in sorted(data.items(), key=schema_tag_order)
|
|
}
|
|
|
|
|
|
def sort_data(data: Any):
|
|
def get_key(item: Any) -> str:
|
|
if isinstance(item, dict):
|
|
return str(item)
|
|
elif isinstance(item, (str, bool, int, float)):
|
|
return str(item)
|
|
elif item is None:
|
|
return ""
|
|
else:
|
|
raise RuntimeError(f"Cannot determine key for {item}")
|
|
|
|
if isinstance(data, dict):
|
|
return {
|
|
key: sort_data(value)
|
|
for key, value in sorted(data.items(), key=lambda item: item[0])
|
|
}
|
|
elif isinstance(data, list):
|
|
return [sort_data(item) for item in sorted(data, key=get_key)]
|
|
elif isinstance(data, tuple):
|
|
return [sort_data(item) for item in sorted(data, key=get_key)]
|
|
elif isinstance(data, (str, bool, int, float)):
|
|
return data
|
|
elif data is None:
|
|
return data
|
|
else:
|
|
raise RuntimeError(f"Cannot sort {data} [{type(data)}]")
|
|
|
|
|
|
class OpenStackServerSourceBase:
|
|
# A URL to Operation tag (OpenApi group) mapping. Can be used when first
|
|
# non parameter path element grouping is not enough
|
|
# ("/qos/policies/{policy_id}/packet_rate_limit_rules" should be
|
|
# "qos-packet-rate-limit-rules" instead of "qos")
|
|
URL_TAG_MAP: dict[str, str] = {}
|
|
|
|
def _api_ver_major(self, ver):
|
|
return ver.ver_major
|
|
|
|
def _api_ver_minor(self, ver):
|
|
return ver.ver_minor
|
|
|
|
def _api_ver(self, ver):
|
|
return (ver.ver_major, ver.ver_minor)
|
|
|
|
def useFixture(self, fixture):
|
|
try:
|
|
fixture.setUp()
|
|
except Exception as ex:
|
|
logging.exception("Got exception", ex)
|
|
else:
|
|
return fixture
|
|
|
|
@abc.abstractmethod
|
|
def generate(self, target_dir, args) -> Path:
|
|
pass
|
|
|
|
def load_openapi(self, path):
|
|
"""Load existing OpenAPI spec from the file"""
|
|
if not path.exists():
|
|
return
|
|
yaml = YAML(typ="safe")
|
|
yaml.preserve_quotes = True
|
|
with open(path) as fp:
|
|
spec = yaml.load(fp)
|
|
|
|
if spec:
|
|
return SpecSchema(**spec)
|
|
|
|
def dump_openapi(self, spec, path, validate: bool, service_type: str):
|
|
"""Dump OpenAPI spec into the file"""
|
|
if validate:
|
|
self.validate_spec(spec, service_type)
|
|
yaml = YAML()
|
|
yaml.preserve_quotes = True
|
|
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
with open(path, "w") as fp:
|
|
yaml.dump(
|
|
sort_schema(
|
|
spec.model_dump(
|
|
exclude_none=True, exclude_defaults=True, by_alias=True
|
|
)
|
|
),
|
|
fp,
|
|
)
|
|
|
|
def validate_spec(self, openapi_spec, service_type: str):
|
|
# Perform openapi validation
|
|
model_data = openapi_spec.model_dump(
|
|
exclude_none=True, exclude_defaults=True, by_alias=True
|
|
)
|
|
Spec.from_dict(model_data)
|
|
validate(model_data)
|
|
|
|
openapi_spec = Spec.from_dict(
|
|
jsonref.replace_refs(model_data, proxies=False)
|
|
)
|
|
|
|
# Build the metadata as if we would do this normally
|
|
metadata = MetadataGenerator.build_metadata(
|
|
SpecSchema(**jsonref.replace_refs(model_data, proxies=False)),
|
|
openapi_spec,
|
|
service_type,
|
|
"",
|
|
)
|
|
# Try to parse schema as if we would be doing for generating the code
|
|
openapi_parser = model.OpenAPISchemaParser()
|
|
for res, res_spec in metadata.resources.items():
|
|
for operation_name, operation_spec in res_spec.operations.items():
|
|
try:
|
|
operation_id = operation_spec.operation_id
|
|
|
|
(path, method, spec) = common.find_openapi_operation(
|
|
openapi_spec, operation_id
|
|
)
|
|
resource_name = common.get_resource_names_from_url(path)[
|
|
-1
|
|
]
|
|
|
|
# Parse params
|
|
for param in openapi_spec["paths"][path].get(
|
|
"parameters", []
|
|
):
|
|
openapi_parser.parse_parameter(param)
|
|
|
|
action_name: str | None = None
|
|
response_key: str | None = None
|
|
sdk_target = operation_spec.targets.get("rust-sdk")
|
|
if sdk_target:
|
|
action_name = sdk_target.action_name
|
|
response_key = sdk_target.response_key
|
|
|
|
operation_variants = common.get_operation_variants(
|
|
spec, action_name=action_name
|
|
)
|
|
|
|
for operation_variant in operation_variants:
|
|
operation_body = operation_variant.get("body")
|
|
if operation_body:
|
|
openapi_parser.parse(
|
|
operation_body, ignore_read_only=True
|
|
)
|
|
|
|
if method.upper() != "HEAD":
|
|
response = common.find_response_schema(
|
|
spec["responses"],
|
|
response_key or resource_name,
|
|
(
|
|
operation_name
|
|
if operation_spec.operation_type == "action"
|
|
else None
|
|
),
|
|
)
|
|
|
|
if response:
|
|
if response_key:
|
|
response_key = (
|
|
response_key
|
|
if response_key != "null"
|
|
else None
|
|
)
|
|
else:
|
|
response_key = resource_name
|
|
response_def, _ = common.find_resource_schema(
|
|
response, None, response_key
|
|
)
|
|
|
|
if response_def:
|
|
if response_def.get(
|
|
"type", "object"
|
|
) == "object" or (
|
|
isinstance(response_def.get("type"), list)
|
|
and "object" in response_def["type"]
|
|
):
|
|
openapi_parser.parse(response_def)
|
|
|
|
except Exception as ex:
|
|
logging.exception(
|
|
"Error validating %s %s %s", res, operation_name, spec
|
|
)
|
|
raise
|
|
|
|
def _sanitize_param_ver_info(self, openapi_spec, min_api_version):
|
|
# Remove min_version of params if it matches to min_api_version
|
|
for k, v in openapi_spec.components.parameters.items():
|
|
os_ext = v.openstack
|
|
if os_ext:
|
|
if os_ext.get("min-ver") == min_api_version:
|
|
v.openstack.pop("min-ver")
|
|
if "max_ver" in os_ext and os_ext["max-ver"] is None:
|
|
v.openstack.pop("max-ver")
|
|
if os_ext == {}:
|
|
v.openstack = None
|
|
|
|
def _process_route(
|
|
self, route, openapi_spec, ver_prefix=None, framework=None
|
|
):
|
|
# Placement exposes "action" as controller in route defaults, all others - "controller"
|
|
if not ("controller" in route.defaults or "action" in route.defaults):
|
|
return
|
|
if "action" in route.defaults and "_methods" in route.defaults:
|
|
# placement 405 handler
|
|
return
|
|
# Path can be "/servers/{id}", but can be
|
|
# "/volumes/:volume_id/types/:(id)" - process
|
|
# according to the routes lib logic
|
|
path = ver_prefix if ver_prefix else ""
|
|
operation_spec = None
|
|
for part in route.routelist:
|
|
if isinstance(part, dict):
|
|
path += "{" + part["name"] + "}"
|
|
else:
|
|
path += part
|
|
|
|
if path == "":
|
|
# placement has "" path - see weird explanation in the placement source code
|
|
return
|
|
|
|
# if "method" not in route.conditions:
|
|
# raise RuntimeError("Method not set for %s", route)
|
|
method = (
|
|
route.conditions.get("method", "GET")[0]
|
|
if route.conditions
|
|
else "GET"
|
|
)
|
|
|
|
controller = route.defaults.get("controller")
|
|
action = route.defaults.get("action")
|
|
logging.info(
|
|
"Path: %s; method: %s; operation: %s", path, method, action
|
|
)
|
|
|
|
versioned_methods = {}
|
|
controller_actions = {}
|
|
# framework = None
|
|
if hasattr(controller, "controller") and framework != "pecan":
|
|
# wsgi
|
|
framework = "wsgi"
|
|
contr = controller.controller
|
|
if hasattr(contr, "versioned_methods"):
|
|
versioned_methods = contr.versioned_methods
|
|
if hasattr(contr, "wsgi_actions"):
|
|
controller_actions = contr.wsgi_actions
|
|
if hasattr(controller, "wsgi_actions"):
|
|
# Nova flavors mess with wsgi_action instead of normal operation
|
|
# and actions on the wrong controller
|
|
parent_controller_actions = controller.wsgi_actions
|
|
if parent_controller_actions:
|
|
controller_actions.update(parent_controller_actions)
|
|
elif hasattr(controller, "_pecan") or framework == "pecan":
|
|
# Pecan base app
|
|
framework = "pecan"
|
|
contr = controller
|
|
if hasattr(controller, "versioned_methods"):
|
|
versioned_methods = contr.versioned_methods
|
|
|
|
else:
|
|
raise RuntimeError(
|
|
f"Unsupported controller {controller} {framework}"
|
|
)
|
|
# logging.debug("Actions: %s, Versioned methods: %s", actions, versioned_methods)
|
|
|
|
# path_spec = openapi_spec.paths.setdefault(path, PathSchema())
|
|
|
|
# operation_spec = dict() #= getattr(path_spec, method.lower()) # , {})
|
|
# Get Path elements
|
|
path_elements: list[str] = list(filter(None, path.split("/")))
|
|
if path_elements and VERSION_RE.match(path_elements[0]):
|
|
path_elements.pop(0)
|
|
operation_tags = self._get_tags_for_url(path)
|
|
|
|
# Build path parameters (/foo/{foo_id}/bar/{id} => $foo_id, $foo_bar_id)
|
|
# Since for same path we are here multiple times check presence of
|
|
# parameter before adding new params
|
|
path_params: list[ParameterSchema] = []
|
|
path_resource_names: list[str] = [
|
|
x.replace("-", "_")
|
|
for x in filter(lambda x: not x.startswith("{"), path_elements)
|
|
]
|
|
for path_element in path_elements:
|
|
if "{" in path_element:
|
|
param_name = path_element.strip("{}")
|
|
global_param_name = (
|
|
"_".join(path_resource_names) + f"_{param_name}"
|
|
)
|
|
|
|
param_ref_name = self._get_param_ref(
|
|
openapi_spec,
|
|
global_param_name,
|
|
param_name,
|
|
param_location="path",
|
|
path=path,
|
|
)
|
|
# Ensure reference to the param is in the path_params
|
|
if param_ref_name not in [k.ref for k in list(path_params)]:
|
|
path_params.append(ParameterSchema(ref=param_ref_name))
|
|
# Cleanup path_resource_names
|
|
# if len(path_resource_names) > 0 and VERSION_RE.match(path_resource_names[0]):
|
|
# # We should not have version prefix in the path_resource_names
|
|
# path_resource_names.pop(0)
|
|
if len(path_resource_names) == 0:
|
|
path_resource_names.append("root")
|
|
elif path_elements[-1].startswith("{"):
|
|
rn = path_resource_names[-1]
|
|
if rn.endswith("ies"):
|
|
rn = rn.replace("ies", "y")
|
|
elif rn.endswith("sses"):
|
|
rn = rn[:-2]
|
|
elif rn.endswith("statuses"):
|
|
rn = rn[:-2]
|
|
else:
|
|
rn = rn.rstrip("s")
|
|
path_resource_names[-1] = rn
|
|
|
|
# Set operationId
|
|
operation_id = re.sub(
|
|
r"^(/?v[0-9.]*/)",
|
|
"",
|
|
"/".join([x.strip("{}") for x in path_elements])
|
|
+ f":{method.lower()}", # noqa
|
|
)
|
|
|
|
if action in versioned_methods:
|
|
# Normal REST operation with version bounds
|
|
(start_version, end_version) = (None, None)
|
|
|
|
# if len(versioned_methods[action]) > 1:
|
|
# for m in versioned_methods[action]:
|
|
# raise RuntimeError("Multiple versioned methods for action %s:%s: %s", path, action, versioned_methods[action])
|
|
for versioned_method in sorted(
|
|
versioned_methods[action], key=lambda v: v.start_version
|
|
):
|
|
start_version = versioned_method.start_version
|
|
end_version = versioned_method.end_version
|
|
func = versioned_method.func
|
|
|
|
# Get the path/op spec only when we have
|
|
# something to fill in
|
|
path_spec = openapi_spec.paths.setdefault(
|
|
path, PathSchema(parameters=path_params)
|
|
)
|
|
operation_spec = getattr(path_spec, method.lower())
|
|
if not operation_spec.operationId:
|
|
operation_spec.operationId = operation_id
|
|
operation_spec.tags.extend(operation_tags)
|
|
operation_spec.tags = list(set(operation_spec.tags))
|
|
|
|
self.process_operation(
|
|
func,
|
|
openapi_spec,
|
|
operation_spec,
|
|
path_resource_names,
|
|
controller=controller,
|
|
method=method,
|
|
operation_name=action,
|
|
start_version=start_version,
|
|
end_version=end_version,
|
|
)
|
|
elif action and hasattr(contr, action):
|
|
# Normal REST operation without version bounds
|
|
(start_version, end_version) = (None, None)
|
|
func = getattr(contr, action)
|
|
|
|
# Get the path/op spec only when we have
|
|
# something to fill in
|
|
path_spec = openapi_spec.paths.setdefault(
|
|
path, PathSchema(parameters=path_params)
|
|
)
|
|
operation_spec = getattr(path_spec, method.lower())
|
|
if not operation_spec.operationId:
|
|
operation_spec.operationId = operation_id
|
|
operation_spec.tags.extend(operation_tags)
|
|
operation_spec.tags = list(set(operation_spec.tags))
|
|
|
|
closure_vars = inspect.getclosurevars(func)
|
|
if closure_vars and closure_vars.nonlocals:
|
|
start_version = closure_vars.nonlocals.get("min_version")
|
|
end_version = closure_vars.nonlocals.get("max_version")
|
|
|
|
self.process_operation(
|
|
func,
|
|
openapi_spec,
|
|
operation_spec,
|
|
path_resource_names,
|
|
controller=controller,
|
|
operation_name=action,
|
|
method=method,
|
|
path=path,
|
|
start_version=start_version,
|
|
end_version=end_version,
|
|
)
|
|
elif action != "action" and action in controller_actions:
|
|
# Normal REST operation without version bounds and present in
|
|
# wsgi_actions of child or parent controller. Example is
|
|
# compute.flavor.create/update which are exposed as wsgi actions
|
|
# (BUG)
|
|
func = controller_actions[action]
|
|
|
|
# Get the path/op spec only when we have
|
|
# something to fill in
|
|
path_spec = openapi_spec.paths.setdefault(
|
|
path, PathSchema(parameters=path_params)
|
|
)
|
|
operation_spec = getattr(path_spec, method.lower())
|
|
if not operation_spec.operationId:
|
|
operation_spec.operationId = operation_id
|
|
operation_spec.tags.extend(operation_tags)
|
|
operation_spec.tags = list(set(operation_spec.tags))
|
|
|
|
self.process_operation(
|
|
func,
|
|
openapi_spec,
|
|
operation_spec,
|
|
path_resource_names,
|
|
controller=controller,
|
|
operation_name=action,
|
|
method=method,
|
|
path=path,
|
|
)
|
|
|
|
elif (
|
|
controller_actions and action == "action"
|
|
): # and action in controller_actions:
|
|
# There are ACTIONS present on the controller
|
|
for action, op_name in controller_actions.items():
|
|
logging.info("Action %s: %s", action, op_name)
|
|
(start_version, end_version) = (None, None)
|
|
action_impls: list[
|
|
tuple[Callable, str | None, str | None]
|
|
] = []
|
|
if isinstance(op_name, str):
|
|
# wsgi action value is a string
|
|
if op_name in versioned_methods:
|
|
# ACTION with version bounds
|
|
for ver_method in versioned_methods[op_name]:
|
|
action_impls.append(
|
|
(
|
|
ver_method.func,
|
|
ver_method.start_version,
|
|
ver_method.end_version,
|
|
)
|
|
)
|
|
logging.info(
|
|
"Versioned action %s", ver_method.func
|
|
)
|
|
elif hasattr(contr, op_name):
|
|
# ACTION with no version bounds
|
|
func = getattr(contr, op_name)
|
|
action_impls.append((func, None, None))
|
|
logging.info("Unversioned action %s", func)
|
|
else:
|
|
logging.error(
|
|
"Cannot find code for %s:%s:%s [%s]",
|
|
path,
|
|
method,
|
|
action,
|
|
dir(contr),
|
|
)
|
|
continue
|
|
elif callable(op_name):
|
|
# Action is already a function (compute.flavors)
|
|
closurevars = inspect.getclosurevars(op_name)
|
|
# Versioned actions in nova can be themelves as a
|
|
# version_select wrapped callable (i.e. baremetal.action)
|
|
key = closurevars.nonlocals.get("key", None)
|
|
slf = closurevars.nonlocals.get("self", None)
|
|
|
|
if key and key in versioned_methods:
|
|
# ACTION with version bounds
|
|
if len(versioned_methods[key]) > 1:
|
|
logging.warning(
|
|
f"There are multiple callables for action {key} instead of multiple bodies"
|
|
)
|
|
for ver_method in versioned_methods[key]:
|
|
action_impls.append(
|
|
(
|
|
ver_method.func,
|
|
ver_method.start_version,
|
|
ver_method.end_version,
|
|
)
|
|
)
|
|
logging.info(
|
|
"Versioned action %s", ver_method.func
|
|
)
|
|
elif slf and key:
|
|
vm = getattr(slf, "versioned_methods", None)
|
|
if vm and key in vm:
|
|
# ACTION with version bounds
|
|
if len(vm[key]) > 1:
|
|
raise RuntimeError(
|
|
"Multiple versioned methods for action %s",
|
|
action,
|
|
)
|
|
for ver_method in vm[key]:
|
|
action_impls.append(
|
|
(
|
|
ver_method.func,
|
|
ver_method.start_version,
|
|
ver_method.end_version,
|
|
)
|
|
)
|
|
logging.info(
|
|
"Versioned action %s", ver_method.func
|
|
)
|
|
else:
|
|
action_impls.append((op_name, None, None))
|
|
|
|
# Get the path/op spec only when we have
|
|
# something to fill in
|
|
path_spec = openapi_spec.paths.setdefault(
|
|
path, PathSchema(parameters=path_params)
|
|
)
|
|
operation_spec = getattr(path_spec, method.lower())
|
|
if not operation_spec.operationId:
|
|
operation_spec.operationId = operation_id
|
|
operation_spec.tags.extend(operation_tags)
|
|
operation_spec.tags = list(set(operation_spec.tags))
|
|
|
|
for func, start_version, end_version in action_impls:
|
|
self.process_operation(
|
|
func,
|
|
openapi_spec,
|
|
operation_spec,
|
|
path_resource_names,
|
|
controller=controller,
|
|
operation_name=action,
|
|
method=method,
|
|
start_version=start_version,
|
|
end_version=end_version,
|
|
mode="action",
|
|
path=path,
|
|
)
|
|
elif framework == "pecan":
|
|
if callable(controller):
|
|
func = controller
|
|
# Get the path/op spec only when we have
|
|
# something to fill in
|
|
path_spec = openapi_spec.paths.setdefault(
|
|
path, PathSchema(parameters=path_params)
|
|
)
|
|
operation_spec = getattr(path_spec, method.lower())
|
|
if not operation_spec.operationId:
|
|
operation_spec.operationId = operation_id
|
|
operation_spec.tags.extend(operation_tags)
|
|
operation_spec.tags = list(set(operation_spec.tags))
|
|
|
|
self.process_operation(
|
|
func,
|
|
openapi_spec,
|
|
operation_spec,
|
|
path_resource_names,
|
|
controller=controller,
|
|
operation_name=action,
|
|
method=method,
|
|
path=path,
|
|
)
|
|
|
|
else:
|
|
logging.warning(controller.__dict__.items())
|
|
logging.warning(contr.__dict__.items())
|
|
logging.warning("No operation found")
|
|
|
|
return operation_spec
|
|
|
|
def process_operation(
|
|
self,
|
|
func,
|
|
openapi_spec,
|
|
operation_spec,
|
|
path_resource_names,
|
|
*,
|
|
controller=None,
|
|
operation_name=None,
|
|
method=None,
|
|
start_version=None,
|
|
end_version=None,
|
|
mode=None,
|
|
path: str | None = None,
|
|
):
|
|
logging.info(
|
|
"%s: %s [%s]", (mode or "operation").title(), operation_name, func
|
|
)
|
|
# New decorators start having explicit null ApiVersion instead of being null
|
|
if (
|
|
start_version
|
|
and not isinstance(start_version, str)
|
|
and self._api_ver_major(start_version) in [0, None]
|
|
and self._api_ver_minor(start_version) in [0, None]
|
|
):
|
|
start_version = None
|
|
if (
|
|
end_version
|
|
and not isinstance(end_version, str)
|
|
and self._api_ver_major(end_version) in [0, None]
|
|
and self._api_ver_minor(end_version) in [0, None]
|
|
):
|
|
end_version = None
|
|
|
|
deser_schema = None
|
|
deser = getattr(controller, "deserializer", None)
|
|
if deser:
|
|
deser_schema = getattr(deser, "schema", None)
|
|
ser = getattr(controller, "serializer", None)
|
|
# deser_schema = getattr(deser, "schema", None)
|
|
ser_schema = getattr(ser, "schema", None)
|
|
if not ser_schema and hasattr(ser, "task_schema"):
|
|
# Image Task serializer is a bit different
|
|
ser_schema = getattr(ser, "task_schema")
|
|
|
|
if mode != "action":
|
|
doc = inspect.getdoc(func)
|
|
if doc and not operation_spec.description:
|
|
doc = rst_to_md(doc)
|
|
operation_spec.description = LiteralScalarString(doc)
|
|
if operation_spec.description:
|
|
# Reading spec from yaml file it was converted back to regular
|
|
# string. Therefore need to force it back to Literal block.
|
|
operation_spec.description = LiteralScalarString(
|
|
operation_spec.description
|
|
)
|
|
|
|
action_name = None
|
|
query_params_versions: set[QueryParamsSchema] = set()
|
|
body_schemas: set[str | None] | Unset = UNSET
|
|
expected_errors = ["404"]
|
|
response_code = None
|
|
# Version bound on an operation are set only when it is not an
|
|
# "action"
|
|
if (
|
|
mode != "action"
|
|
and start_version
|
|
and self._api_ver_major(start_version) != 0
|
|
):
|
|
if not (
|
|
"min-ver" in operation_spec.openstack
|
|
and tuple(
|
|
[
|
|
int(x)
|
|
for x in operation_spec.openstack["min-ver"].split(".")
|
|
]
|
|
)
|
|
< (self._api_ver(start_version))
|
|
):
|
|
operation_spec.openstack["min-ver"] = (
|
|
start_version.get_string()
|
|
if hasattr(start_version, "get_string")
|
|
else str(start_version)
|
|
)
|
|
|
|
if (
|
|
mode != "action"
|
|
and end_version
|
|
and self._api_ver_major(end_version)
|
|
):
|
|
if self._api_ver_major(end_version) == 0:
|
|
operation_spec.openstack.pop("max-ver", None)
|
|
operation_spec.deprecated = None
|
|
else:
|
|
# There is some end_version. Set the deprecated flag and wait
|
|
# for final version to be processed which drop it if max_ver
|
|
# is not set
|
|
operation_spec.deprecated = True
|
|
if not (
|
|
"max-ver" in operation_spec.openstack
|
|
and tuple(
|
|
[
|
|
int(x)
|
|
for x in operation_spec.openstack["max-ver"].split(
|
|
"."
|
|
)
|
|
]
|
|
)
|
|
> self._api_ver(end_version)
|
|
):
|
|
operation_spec.openstack["max-ver"] = (
|
|
end_version.get_string()
|
|
if hasattr(end_version, "get_string")
|
|
else str(end_version)
|
|
)
|
|
|
|
action_name = getattr(func, "wsgi_action", None)
|
|
if action_name:
|
|
operation_name = action_name
|
|
|
|
(
|
|
query_params_versions,
|
|
body_schemas,
|
|
response_body_schema,
|
|
expected_errors,
|
|
) = self._process_decorators(
|
|
func,
|
|
path_resource_names,
|
|
openapi_spec,
|
|
mode,
|
|
start_version,
|
|
end_version,
|
|
action_name,
|
|
operation_name,
|
|
)
|
|
|
|
if hasattr(func, "_wsme_definition"):
|
|
fdef = getattr(func, "_wsme_definition")
|
|
body_spec = getattr(fdef, "body_type", None)
|
|
if body_spec:
|
|
body_schema = _convert_wsme_to_jsonschema(body_spec)
|
|
if hasattr(body_spec, "__name__"):
|
|
schema_name = body_spec.__name__
|
|
else:
|
|
schema_name = (
|
|
"".join([x.title() for x in path_resource_names])
|
|
+ func.__name__.title()
|
|
+ "Request"
|
|
)
|
|
openapi_spec.components.schemas.setdefault(
|
|
schema_name, TypeSchema(**body_schema)
|
|
)
|
|
if body_schemas is UNSET:
|
|
body_schemas = set()
|
|
if isinstance(body_schemas, set):
|
|
body_schemas.add(f"#/components/schemas/{schema_name}")
|
|
rsp_spec = getattr(fdef, "return_type", None)
|
|
if rsp_spec:
|
|
ser_schema = _convert_wsme_to_jsonschema(rsp_spec)
|
|
response_code = getattr(fdef, "status_code", None)
|
|
|
|
if body_schemas is UNSET and deser_schema:
|
|
# Glance may have request deserializer attached schema
|
|
schema_name = (
|
|
"".join([x.title() for x in path_resource_names])
|
|
+ func.__name__.title()
|
|
+ "Request"
|
|
)
|
|
(body_schema, mime_type) = self._get_schema_ref(
|
|
openapi_spec,
|
|
schema_name,
|
|
description=f"Request of the {operation_spec.operationId} operation",
|
|
schema_def=deser_schema,
|
|
)
|
|
|
|
if query_params_versions:
|
|
so = sorted(
|
|
query_params_versions,
|
|
key=lambda d: (
|
|
tuple(map(int, d.min_version.split(".")))
|
|
if d.min_version
|
|
else (0, 0)
|
|
),
|
|
)
|
|
for item in so:
|
|
if item.schema:
|
|
self.process_query_parameters(
|
|
openapi_spec,
|
|
operation_spec,
|
|
path_resource_names,
|
|
item.schema,
|
|
item.min_version,
|
|
item.max_version,
|
|
)
|
|
# if body_schemas or mode == "action":
|
|
if method in ["PUT", "POST", "PATCH"]:
|
|
self.process_body_parameters(
|
|
openapi_spec,
|
|
operation_spec,
|
|
path_resource_names,
|
|
body_schemas,
|
|
mode,
|
|
operation_name,
|
|
)
|
|
|
|
if ser_schema and not response_body_schema:
|
|
response_body_schema = ser_schema
|
|
responses_spec = operation_spec.responses
|
|
for error in expected_errors:
|
|
responses_spec.setdefault(str(error), {"description": "Error"})
|
|
|
|
if mode != "action" and str(error) == "410":
|
|
# This looks like a deprecated operation still hanging out there
|
|
operation_spec.deprecated = True
|
|
if not response_code:
|
|
response_codes = getattr(func, "wsgi_code", None)
|
|
if response_codes:
|
|
if not isinstance(response_codes, list):
|
|
response_codes = [response_codes]
|
|
# py starting from 3.11 magically works for str(enum.IntEnum),
|
|
# while older ones need an explicit conversion
|
|
response_codes = [
|
|
rc.value if isinstance(rc, enum.IntEnum) else rc
|
|
for rc in response_codes
|
|
]
|
|
else:
|
|
response_codes = [response_code]
|
|
if not response_codes:
|
|
# No expected response code known, take "normal" defaults
|
|
response_codes = self._get_response_codes(
|
|
method, operation_spec.operationId
|
|
)
|
|
if response_codes:
|
|
for response_code in response_codes:
|
|
rsp = responses_spec.setdefault(
|
|
str(response_code), {"description": "Ok"}
|
|
)
|
|
if str(response_code) != "204" and method != "DELETE":
|
|
# Arrange response placeholder
|
|
schema_name = (
|
|
"".join([x.title() for x in path_resource_names])
|
|
+ (
|
|
operation_name.replace("index", "list").title()
|
|
if not path_resource_names[-1].endswith(
|
|
operation_name
|
|
)
|
|
else ""
|
|
)
|
|
+ "Response"
|
|
)
|
|
(schema_ref, mime_type) = self._get_schema_ref(
|
|
openapi_spec,
|
|
schema_name,
|
|
description=(
|
|
f"Response of the {operation_spec.operationId} operation"
|
|
if not action_name
|
|
else f"Response of the {operation_spec.operationId}:{action_name} action"
|
|
), # noqa
|
|
schema_def=response_body_schema,
|
|
action_name=action_name,
|
|
)
|
|
|
|
if schema_ref:
|
|
curr_schema = (
|
|
rsp.get("content", {})
|
|
.get("application/json", {})
|
|
.get("schema", {})
|
|
)
|
|
if mode == "action" and curr_schema:
|
|
# There is existing response for the action. Need to
|
|
# merge them
|
|
if isinstance(curr_schema, dict):
|
|
curr_oneOf = curr_schema.get("oneOf")
|
|
curr_ref = curr_schema.get("$ref")
|
|
else:
|
|
curr_oneOf = curr_schema.oneOf
|
|
curr_ref = curr_schema.ref
|
|
if curr_oneOf:
|
|
if schema_ref not in [
|
|
x["$ref"] for x in curr_oneOf
|
|
]:
|
|
curr_oneOf.append({"$ref": schema_ref})
|
|
elif curr_ref and curr_ref != schema_ref:
|
|
rsp["content"]["application/json"][
|
|
"schema"
|
|
] = TypeSchema(
|
|
oneOf=[
|
|
{"$ref": curr_ref},
|
|
{"$ref": schema_ref},
|
|
]
|
|
)
|
|
else:
|
|
rsp["content"] = {
|
|
"application/json": {
|
|
"schema": {"$ref": schema_ref}
|
|
}
|
|
}
|
|
|
|
# Ensure operation tags are existing
|
|
for tag in operation_spec.tags:
|
|
if tag not in [x["name"] for x in openapi_spec.tags]:
|
|
openapi_spec.tags.append({"name": tag})
|
|
|
|
self._post_process_operation_hook(
|
|
openapi_spec, operation_spec, path=path
|
|
)
|
|
|
|
def _post_process_operation_hook(
|
|
self, openapi_spec, operation_spec, path: str | None = None
|
|
):
|
|
"""Hook to allow service specific generator to modify details"""
|
|
pass
|
|
|
|
def process_query_parameters(
|
|
self,
|
|
openapi_spec,
|
|
operation_spec,
|
|
path_resource_names,
|
|
obj,
|
|
min_ver,
|
|
max_ver,
|
|
):
|
|
"""Process query parameters in different versions
|
|
|
|
It is expected, that this method is invoked in the raising min_ver order to do proper cleanup of max_ver
|
|
"""
|
|
if "type" not in obj:
|
|
# Nova has empty definitions for deprecated methods
|
|
return
|
|
# Yey - we have query_parameters
|
|
if obj["type"] == "object":
|
|
params = obj["properties"]
|
|
for prop, spec in params.items():
|
|
param_name = "_".join(path_resource_names) + f"_{prop}"
|
|
|
|
param_attrs: dict[str, TypeSchema | dict] = {}
|
|
if spec == {}:
|
|
# Nova added empty params since it was never validating them. Skip
|
|
param_attrs["schema"] = TypeSchema(type="string")
|
|
elif spec["type"] == "array":
|
|
param_attrs["schema"] = TypeSchema(
|
|
**copy.deepcopy(spec["items"])
|
|
)
|
|
else:
|
|
param_attrs["schema"] = TypeSchema(**copy.deepcopy(spec))
|
|
param_attrs["description"] = spec.get("description")
|
|
if min_ver:
|
|
os_ext = param_attrs.setdefault("x-openstack", {})
|
|
os_ext["min-ver"] = min_ver
|
|
if max_ver:
|
|
os_ext = param_attrs.setdefault("x-openstack", {})
|
|
os_ext["max-ver"] = max_ver
|
|
ref_name = self._get_param_ref(
|
|
openapi_spec,
|
|
param_name,
|
|
prop,
|
|
param_location="query",
|
|
path=None,
|
|
**param_attrs,
|
|
)
|
|
if ref_name not in [x.ref for x in operation_spec.parameters]:
|
|
operation_spec.parameters.append(
|
|
ParameterSchema(ref=ref_name)
|
|
)
|
|
|
|
else:
|
|
raise RuntimeError(
|
|
f"Query parameters {obj} is not an object as expected"
|
|
)
|
|
|
|
def process_body_parameters(
|
|
self,
|
|
openapi_spec,
|
|
operation_spec,
|
|
path_resource_names,
|
|
body_schemas,
|
|
mode,
|
|
action_name,
|
|
):
|
|
# Body is not expected, exit (unless we are in the "action")
|
|
if body_schemas is None or (body_schemas == [] and mode != "action"):
|
|
return
|
|
mime_type: str | None = "application/json"
|
|
schema_name = None
|
|
schema_ref: str | Unset | None = None
|
|
# We should not modify path_resource_names of the caller
|
|
path_resource_names = path_resource_names.copy()
|
|
# Create container schema with version discriminator
|
|
if action_name:
|
|
path_resource_names.append(action_name)
|
|
|
|
cont_schema_name = (
|
|
"".join([x.title() for x in path_resource_names]) + "Request"
|
|
)
|
|
cont_schema = None
|
|
|
|
if body_schemas is not UNSET and len(body_schemas) == 1:
|
|
# There is only one body known at the moment
|
|
# None is a special case with explicitly no body supported
|
|
if True: # body_schemas[0] is not UNSET:
|
|
if cont_schema_name in openapi_spec.components.schemas:
|
|
# if we have already oneOf - add there
|
|
cont_schema = openapi_spec.components.schemas[
|
|
cont_schema_name
|
|
]
|
|
if cont_schema.oneOf and body_schemas[0] not in [
|
|
x["$ref"] for x in cont_schema.oneOf
|
|
]:
|
|
cont_schema.oneOf.append({"$ref": body_schemas[0]})
|
|
schema_ref = f"#/components/schemas/{cont_schema_name}"
|
|
else:
|
|
# otherwise just use schema as body
|
|
schema_ref = body_schemas.pop()
|
|
elif body_schemas is not UNSET and len(body_schemas) > 1:
|
|
# We may end up here multiple times if we have versioned operation. In this case merge to what we have already
|
|
op_body = operation_spec.requestBody.setdefault("content", {})
|
|
old_schema = op_body.get(mime_type, {}).get("schema", {})
|
|
old_ref = (
|
|
old_schema.ref
|
|
if isinstance(old_schema, TypeSchema)
|
|
else old_schema.get("$ref")
|
|
)
|
|
cont_schema = openapi_spec.components.schemas.setdefault(
|
|
cont_schema_name,
|
|
TypeSchema(
|
|
oneOf=[], openstack={"discriminator": "microversion"}
|
|
),
|
|
)
|
|
# Add new refs to the container oneOf if they are not already
|
|
# there
|
|
cont_schema.oneOf.extend(
|
|
[
|
|
{"$ref": n}
|
|
for n in body_schemas
|
|
if n not in [x.get("$ref") for x in cont_schema.oneOf]
|
|
]
|
|
)
|
|
schema_ref = f"#/components/schemas/{cont_schema_name}"
|
|
if (
|
|
old_ref
|
|
and old_ref != schema_ref
|
|
and old_ref not in [x["$ref"] for x in cont_schema.oneOf]
|
|
):
|
|
# In a previous iteration we only had one schema and decided
|
|
# not to create container. Now we need to change that by
|
|
# merging with previous data
|
|
cont_schema.oneOf.append({"$ref": old_ref})
|
|
elif mode == "action":
|
|
# There are actions without a real body description, but we know that action requires dummy body
|
|
cont_schema = openapi_spec.components.schemas.setdefault(
|
|
cont_schema_name,
|
|
TypeSchema(
|
|
description=f"Empty body for {action_name} action",
|
|
type="object",
|
|
properties={action_name: {"type": "null"}},
|
|
openstack={"action-name": action_name},
|
|
),
|
|
)
|
|
schema_ref = f"#/components/schemas/{cont_schema_name}"
|
|
elif body_schemas is UNSET:
|
|
# We know nothing about request
|
|
schema_name = (
|
|
"".join([x.title() for x in path_resource_names])
|
|
+ (
|
|
action_name.replace("index", "list").title()
|
|
if not path_resource_names[-1].endswith(action_name)
|
|
else ""
|
|
)
|
|
+ "Request"
|
|
)
|
|
(schema_ref, mime_type) = self._get_schema_ref(
|
|
openapi_spec,
|
|
schema_name,
|
|
description=f"Request of the {operation_spec.operationId} operation",
|
|
action_name=action_name,
|
|
schema_def=UNSET,
|
|
)
|
|
|
|
if mode == "action":
|
|
op_body = operation_spec.requestBody.setdefault("content", {})
|
|
js_content = op_body.setdefault(mime_type, {})
|
|
body_schema = js_content.setdefault("schema", {})
|
|
one_of = body_schema.setdefault("oneOf", [])
|
|
if schema_ref and schema_ref not in [
|
|
x.get("$ref") for x in one_of
|
|
]:
|
|
one_of.append({"$ref": schema_ref})
|
|
os_ext = body_schema.setdefault("x-openstack", {})
|
|
os_ext["discriminator"] = "action"
|
|
if cont_schema and action_name:
|
|
cont_schema.openstack["action-name"] = action_name
|
|
elif schema_ref is not None:
|
|
op_body = operation_spec.requestBody.setdefault("content", {})
|
|
js_content = op_body.setdefault(mime_type, {})
|
|
body_schema = js_content.setdefault("schema", {})
|
|
operation_spec.requestBody["content"][mime_type]["schema"] = (
|
|
TypeSchema(ref=schema_ref)
|
|
)
|
|
|
|
def _sanitize_schema(
|
|
self, schema, *, start_version=None, end_version=None
|
|
):
|
|
"""Various schemas are broken in various ways"""
|
|
|
|
if isinstance(schema, dict):
|
|
# Forcibly convert to TypeSchema
|
|
schema = TypeSchema(**schema)
|
|
properties = getattr(schema, "properties", None)
|
|
if properties:
|
|
# Nova aggregates schemas are broken since they have "type": "object" inside "properties
|
|
if properties.get("type") == "object":
|
|
schema.properties.pop("type")
|
|
|
|
if "anyOf" in properties:
|
|
# anyOf must be on the properties level and not under (nova host update)
|
|
anyOf = schema.properties.pop("anyOf")
|
|
schema.anyOf = anyOf
|
|
|
|
for k, v in properties.items():
|
|
typ = v.get("type")
|
|
if typ == "object":
|
|
schema.properties[k] = self._sanitize_schema(v)
|
|
if typ == "array" and "additionalItems" in v:
|
|
# additionalItems have nothing to do under the type array (create servergroup)
|
|
schema.properties[k].pop("additionalItems")
|
|
if (
|
|
typ == "array"
|
|
and "items" in v
|
|
and isinstance(v["items"], list)
|
|
):
|
|
# server_group create - type array "items" is a dict and not list
|
|
# NOTE: server_groups recently changed to "prefixItems",
|
|
# so this may be not necessary anymore
|
|
schema.properties[k]["items"] = v["items"][0]
|
|
if start_version and self._api_ver_major(start_version) not in [
|
|
"0",
|
|
0,
|
|
]:
|
|
if not schema.openstack:
|
|
schema.openstack = {}
|
|
schema.openstack["min-ver"] = (
|
|
start_version.get_string()
|
|
if hasattr(start_version, "get_string")
|
|
else start_version
|
|
)
|
|
if end_version and self._api_ver_major(end_version) not in ["0", 0]:
|
|
if not schema.openstack:
|
|
schema.openstack = {}
|
|
schema.openstack["max-ver"] = (
|
|
end_version.get_string()
|
|
if hasattr(end_version, "get_string")
|
|
else end_version
|
|
)
|
|
return schema
|
|
|
|
def _get_param_ref(
|
|
self,
|
|
openapi_spec,
|
|
ref_name: str,
|
|
param_name: str,
|
|
param_location: str,
|
|
path: str | None = None,
|
|
**param_attrs,
|
|
) -> str:
|
|
if ref_name == "_project_id":
|
|
ref_name = "project_id"
|
|
ref_name = ref_name.replace(":", "_")
|
|
# Pop extensions for easier post processing
|
|
if param_attrs:
|
|
os_ext = param_attrs.pop("x-openstack", {})
|
|
else:
|
|
os_ext = {}
|
|
# Ensure global parameter is present
|
|
param = ParameterSchema(
|
|
location=param_location, name=param_name, **param_attrs
|
|
)
|
|
if param_location == "path":
|
|
param.required = True
|
|
if not param.description and path:
|
|
param.description = f"{param_name} parameter for {path} API"
|
|
# We can only assume the param type. For path it is logically a string only
|
|
if not param.type_schema:
|
|
param.type_schema = TypeSchema(type="string")
|
|
if os_ext and ("min-ver" in os_ext or "max-ver" in os_ext):
|
|
# min_ver is present
|
|
old_param = openapi_spec.components.parameters.get(ref_name, None)
|
|
if not old_param:
|
|
# Param was not present, just set what we have
|
|
param.openstack = os_ext
|
|
else:
|
|
# Param is already present. Check whether we need to modify min_ver
|
|
min_ver = os_ext.get("min-ver")
|
|
max_ver = os_ext.get("max-ver")
|
|
param.openstack = {}
|
|
if not old_param.openstack:
|
|
old_param.openstack = {}
|
|
old_min_ver = old_param.openstack.get("min-ver")
|
|
old_max_ver = old_param.openstack.get("max-ver")
|
|
if old_min_ver and tuple(old_min_ver.split(".")) < tuple(
|
|
min_ver.split(".")
|
|
):
|
|
# Existing param has lower min_ver. Keep the old value
|
|
os_ext["min-ver"] = old_min_ver
|
|
if (
|
|
old_max_ver
|
|
and max_ver
|
|
and tuple(old_max_ver.split("."))
|
|
> tuple(max_ver.split("."))
|
|
):
|
|
# Existing param has max_ver higher then what we have now. Keep old value
|
|
os_ext["max_ver"] = old_max_ver
|
|
if param_name == "user_id":
|
|
os_ext["resource_link"] = "identity/v3/user.id"
|
|
if param_name == "domain_id":
|
|
os_ext["resource_link"] = "identity/v3/domain.id"
|
|
if param_name == "project_id":
|
|
os_ext["resource_link"] = "identity/v3/project.id"
|
|
if os_ext != {}:
|
|
param.openstack = os_ext
|
|
|
|
# Overwrite param
|
|
openapi_spec.components.parameters[ref_name] = param
|
|
return f"#/components/parameters/{ref_name}"
|
|
|
|
def _get_schema_ref(
|
|
self,
|
|
openapi_spec,
|
|
name,
|
|
description: str | None = None,
|
|
schema_def=UNSET,
|
|
action_name=None,
|
|
) -> tuple[str | None, str | None]:
|
|
if schema_def is UNSET:
|
|
logging.warning(
|
|
"No Schema definition for %s[%s] is known", name, action_name
|
|
)
|
|
# Create dummy schema since we got no data for it
|
|
schema_def = {
|
|
"type": "object",
|
|
"description": LiteralScalarString(description),
|
|
}
|
|
if schema_def is not None:
|
|
schema = openapi_spec.components.schemas.setdefault(
|
|
name, TypeSchema(**schema_def)
|
|
)
|
|
|
|
if action_name:
|
|
if not schema.openstack:
|
|
schema.openstack = {}
|
|
schema.openstack.setdefault("action-name", action_name)
|
|
|
|
return (f"#/components/schemas/{name}", "application/json")
|
|
else:
|
|
return (None, None)
|
|
|
|
def _get_tags_for_url(self, url):
|
|
"""Return Tag (group) name based on the URL"""
|
|
# Drop version prefix
|
|
url = re.sub(r"^(/v[0-9\.]*/)", "/", url)
|
|
|
|
for k, v in self.URL_TAG_MAP.items():
|
|
if url.startswith(k):
|
|
return [v]
|
|
if url == "/":
|
|
return ["version"]
|
|
path_elements: list[str] = list(filter(None, url.split("/")))
|
|
for el in path_elements:
|
|
# Use 1st (non project_id) path element as tag
|
|
if not el.startswith("{"):
|
|
return [el]
|
|
|
|
@classmethod
|
|
def _get_response_codes(cls, method: str, operationId: str) -> list[str]:
|
|
if method == "DELETE":
|
|
response_code = "204"
|
|
elif method == "POST":
|
|
response_code = "201"
|
|
else:
|
|
response_code = "200"
|
|
return [response_code]
|
|
|
|
def _process_decorators(
|
|
self,
|
|
func,
|
|
path_resource_names: list[str],
|
|
openapi_spec,
|
|
mode: str,
|
|
start_version,
|
|
end_version,
|
|
action_name: str | None = None,
|
|
operation_name: str | None = None,
|
|
) -> tuple[
|
|
set[QueryParamsSchema],
|
|
set[str | None] | Unset,
|
|
dict | Unset | None,
|
|
list[str],
|
|
]:
|
|
"""Extract schemas from the decorated method."""
|
|
# Unwrap operation decorators to access all properties
|
|
expected_errors: list[str] = []
|
|
body_schemas: set[str | None] | Unset = UNSET
|
|
query_params_versions: set[QueryParamsSchema] = set()
|
|
response_body_schema: dict | Unset | None = UNSET
|
|
|
|
f = func
|
|
while hasattr(f, "__wrapped__"):
|
|
closure = inspect.getclosurevars(f)
|
|
closure_locals = closure.nonlocals
|
|
min_ver = (
|
|
closure_locals.get("min_version", start_version)
|
|
or start_version
|
|
)
|
|
|
|
if min_ver and not isinstance(min_ver, str):
|
|
min_ver = (
|
|
min_ver.get_string()
|
|
if hasattr(min_ver, "get_string")
|
|
else str(min_ver)
|
|
)
|
|
if min_ver and not start_version:
|
|
start_version = min_ver
|
|
|
|
max_ver = (
|
|
closure_locals.get("max_version", end_version) or end_version
|
|
)
|
|
if max_ver and not isinstance(max_ver, str):
|
|
max_ver = (
|
|
max_ver.get_string()
|
|
if hasattr(max_ver, "get_string")
|
|
else str(max_ver)
|
|
)
|
|
if max_ver and not end_version:
|
|
end_version = max_ver
|
|
|
|
if "errors" in closure_locals:
|
|
expected_errors = closure_locals["errors"]
|
|
if isinstance(expected_errors, list):
|
|
expected_errors = [
|
|
str(x)
|
|
for x in filter(
|
|
lambda x: isinstance(x, int), expected_errors
|
|
)
|
|
]
|
|
elif isinstance(expected_errors, int):
|
|
expected_errors = [str(expected_errors)]
|
|
if "request_body_schema" in closure_locals or hasattr(
|
|
f, "_request_body_schema"
|
|
):
|
|
# Body type is known through method decorator
|
|
obj = closure_locals.get(
|
|
"request_body_schema",
|
|
getattr(f, "_request_body_schema", {}),
|
|
)
|
|
# body schemas are not UNSET anymore
|
|
if body_schemas is UNSET:
|
|
body_schemas = set()
|
|
if obj is not None:
|
|
if obj.get("type") in ["object", "array"]:
|
|
# We only allow object and array bodies
|
|
# To prevent type name collision keep module name
|
|
# part of the name
|
|
typ_name = (
|
|
"".join([x.title() for x in path_resource_names])
|
|
+ func.__name__.title()
|
|
+ (
|
|
f"_{min_ver.replace('.', '')}"
|
|
if min_ver
|
|
else ""
|
|
)
|
|
)
|
|
comp_schema = (
|
|
openapi_spec.components.schemas.setdefault(
|
|
typ_name,
|
|
self._sanitize_schema(
|
|
copy.deepcopy(obj),
|
|
start_version=start_version,
|
|
end_version=end_version,
|
|
),
|
|
)
|
|
)
|
|
|
|
if min_ver:
|
|
if not comp_schema.openstack:
|
|
comp_schema.openstack = {}
|
|
comp_schema.openstack["min-ver"] = min_ver
|
|
if max_ver:
|
|
if not comp_schema.openstack:
|
|
comp_schema.openstack = {}
|
|
comp_schema.openstack["max-ver"] = max_ver
|
|
if mode == "action":
|
|
if not comp_schema.openstack:
|
|
comp_schema.openstack = {}
|
|
comp_schema.openstack["action-name"] = action_name
|
|
|
|
ref_name = f"#/components/schemas/{typ_name}"
|
|
if isinstance(body_schemas, set):
|
|
body_schemas.add(ref_name)
|
|
else:
|
|
# register no-body
|
|
if isinstance(body_schemas, set):
|
|
body_schemas.add(None)
|
|
|
|
if "response_body_schema" in closure_locals or hasattr(
|
|
f, "_response_body_schema"
|
|
):
|
|
# Response type is known through method decorator - PERFECT
|
|
obj = closure_locals.get(
|
|
"response_body_schema",
|
|
getattr(f, "_response_body_schema", {}),
|
|
)
|
|
response_body_schema = obj
|
|
if "query_params_schema" in closure_locals or hasattr(
|
|
f, "_request_query_schema"
|
|
):
|
|
obj = closure_locals.get(
|
|
"query_params_schema",
|
|
getattr(f, "_request_query_schema", {}),
|
|
)
|
|
query_params_versions.add(
|
|
QueryParamsSchema(obj, min_ver, max_ver)
|
|
)
|
|
elif "request_query_schema" in closure_locals:
|
|
# Nova altered the decorator
|
|
obj = closure_locals.get(
|
|
"request_query_schema",
|
|
getattr(f, "request_query_schema", {}),
|
|
)
|
|
query_params_versions.add(
|
|
QueryParamsSchema(obj, min_ver, max_ver)
|
|
)
|
|
if "validators" in closure_locals:
|
|
validators = closure_locals.get("validators")
|
|
body_schemas = set()
|
|
if isinstance(validators, dict):
|
|
for k, v in validators.items():
|
|
sig = inspect.signature(v)
|
|
vals = sig.parameters.get("validators", None)
|
|
if vals:
|
|
sig2 = inspect.signature(vals.default[0])
|
|
schema_param = sig2.parameters.get("schema", None)
|
|
if schema_param:
|
|
schema = schema_param.default
|
|
|
|
typ_name = (
|
|
"".join(
|
|
[
|
|
x.title()
|
|
for x in path_resource_names
|
|
]
|
|
)
|
|
+ func.__name__.title()
|
|
+ (
|
|
f"_{min_ver.replace('.', '')}"
|
|
if min_ver
|
|
else ""
|
|
)
|
|
)
|
|
comp_schema = (
|
|
openapi_spec.components.schemas.setdefault(
|
|
typ_name,
|
|
self._sanitize_schema(
|
|
copy.deepcopy(schema),
|
|
start_version=None,
|
|
end_version=None,
|
|
),
|
|
)
|
|
)
|
|
|
|
ref_name = f"#/components/schemas/{typ_name}"
|
|
|
|
if isinstance(body_schemas, set):
|
|
body_schemas.add(ref_name)
|
|
|
|
f = f.__wrapped__
|
|
|
|
return (
|
|
query_params_versions,
|
|
body_schemas,
|
|
response_body_schema,
|
|
expected_errors,
|
|
)
|
|
|
|
|
|
def _convert_wsme_to_jsonschema(body_spec):
|
|
"""Convert WSME type description to JsonSchema"""
|
|
res: dict[str, Any] = {}
|
|
if wtypes.iscomplex(body_spec) or isinstance(body_spec, wtypes.wsattr):
|
|
res = {"type": "object", "properties": {}}
|
|
doc = inspect.getdoc(body_spec)
|
|
if doc:
|
|
res.setdefault("description", LiteralScalarString(doc))
|
|
required = set()
|
|
for attr in wtypes.list_attributes(body_spec):
|
|
attr_value = getattr(body_spec, attr.key)
|
|
if isinstance(attr_value, wtypes.wsproperty):
|
|
r = _convert_wsme_to_jsonschema(attr_value)
|
|
else:
|
|
r = _convert_wsme_to_jsonschema(attr_value._get_datatype())
|
|
res["properties"][attr.key] = r
|
|
if attr.mandatory:
|
|
required.add(attr.name)
|
|
# todo: required
|
|
if required:
|
|
res.setdefault("required", list(required))
|
|
elif isinstance(body_spec, wtypes.ArrayType):
|
|
res = {
|
|
"type": "array",
|
|
"items": _convert_wsme_to_jsonschema(body_spec.item_type),
|
|
}
|
|
elif isinstance(body_spec, wtypes.StringType) or body_spec is str:
|
|
res = {"type": "string"}
|
|
min_len = getattr(body_spec, "min_length", None)
|
|
max_len = getattr(body_spec, "max_length", None)
|
|
if min_len:
|
|
res["minLength"] = min_len
|
|
if max_len:
|
|
res["maxLength"] = max_len
|
|
elif isinstance(body_spec, wtypes.IntegerType):
|
|
res = {"type": "integer"}
|
|
minimum = getattr(body_spec, "minimum", None)
|
|
maximum = getattr(body_spec, "maximum", None)
|
|
if minimum:
|
|
res["minimum"] = minimum
|
|
if maximum:
|
|
res["maximum"] = maximum
|
|
elif isinstance(body_spec, wtypes.Enum):
|
|
basetype = body_spec.basetype
|
|
values = body_spec.values
|
|
if basetype is str:
|
|
res = {"type": "string"}
|
|
elif basetype is float:
|
|
res = {"type": "number"}
|
|
elif basetype is int:
|
|
res = {"type": "integer"}
|
|
else:
|
|
raise RuntimeError(f"Unsupported basetype {basetype}")
|
|
res["enum"] = list(values)
|
|
# elif hasattr(body_spec, "__name__") and body_spec.__name__ == "bool":
|
|
elif wtypes.isdict(body_spec):
|
|
res = {
|
|
"type": "object",
|
|
"additionalProperties": _convert_wsme_to_jsonschema(
|
|
body_spec.value_type
|
|
),
|
|
}
|
|
elif wtypes.isusertype(body_spec):
|
|
basetype = body_spec.basetype
|
|
name = body_spec.name
|
|
if basetype is str:
|
|
res = {"type": "string", "format": name}
|
|
else:
|
|
raise RuntimeError(f"Unsupported basetype {basetype}")
|
|
elif isinstance(body_spec, wtypes.wsproperty):
|
|
res = _convert_wsme_to_jsonschema(body_spec.datatype)
|
|
elif body_spec is bool:
|
|
# wsattr(bool) lands here as <class 'bool'>
|
|
res = {"type": "boolean"}
|
|
elif body_spec is float:
|
|
res = {"type": "number", "format": "float"}
|
|
elif (
|
|
isinstance(body_spec, wtypes.dt_types)
|
|
or body_spec is datetime.datetime
|
|
):
|
|
res = {"type": "string", "format": "date-time"}
|
|
else:
|
|
raise RuntimeError(f"Unsupported object {body_spec}")
|
|
|
|
return res
|