Ensure schemas are not duplicated

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>
This commit is contained in:
Artem Goncharov
2025-06-23 12:10:32 +02:00
parent f1e74e7be5
commit 4a711b1a2c
6 changed files with 131 additions and 51 deletions

View File

@@ -16,7 +16,6 @@ import datetime
import enum import enum
import importlib import importlib
import inspect import inspect
import itertools
import jsonref import jsonref
import logging import logging
from pathlib import Path from pathlib import Path
@@ -48,6 +47,25 @@ class Unset:
return 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() UNSET: Unset = Unset()
@@ -464,6 +482,7 @@ class OpenStackServerSourceBase:
) )
elif action and hasattr(contr, action): elif action and hasattr(contr, action):
# Normal REST operation without version bounds # Normal REST operation without version bounds
(start_version, end_version) = (None, None)
func = getattr(contr, action) func = getattr(contr, action)
# Get the path/op spec only when we have # Get the path/op spec only when we have
@@ -477,6 +496,11 @@ class OpenStackServerSourceBase:
operation_spec.tags.extend(operation_tags) operation_spec.tags.extend(operation_tags)
operation_spec.tags = list(set(operation_spec.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( self.process_operation(
func, func,
openapi_spec, openapi_spec,
@@ -486,6 +510,8 @@ class OpenStackServerSourceBase:
operation_name=action, operation_name=action,
method=method, method=method,
path=path, path=path,
start_version=start_version,
end_version=end_version,
) )
elif action != "action" and action in controller_actions: elif action != "action" and action in controller_actions:
# Normal REST operation without version bounds and present in # Normal REST operation without version bounds and present in
@@ -718,8 +744,8 @@ class OpenStackServerSourceBase:
) )
action_name = None action_name = None
query_params_versions = [] query_params_versions: set[QueryParamsSchema] = set()
body_schemas: list[str | None] | Unset = UNSET body_schemas: set[str | None] | Unset = UNSET
expected_errors = ["404"] expected_errors = ["404"]
response_code = None response_code = None
# Version bound on an operation are set only when it is not an # Version bound on an operation are set only when it is not an
@@ -813,9 +839,9 @@ class OpenStackServerSourceBase:
schema_name, TypeSchema(**body_schema) schema_name, TypeSchema(**body_schema)
) )
if body_schemas is UNSET: if body_schemas is UNSET:
body_schemas = [] body_schemas = set()
if isinstance(body_schemas, list): if isinstance(body_schemas, set):
body_schemas.append(f"#/components/schemas/{schema_name}") body_schemas.add(f"#/components/schemas/{schema_name}")
rsp_spec = getattr(fdef, "return_type", None) rsp_spec = getattr(fdef, "return_type", None)
if rsp_spec: if rsp_spec:
ser_schema = _convert_wsme_to_jsonschema(rsp_spec) ser_schema = _convert_wsme_to_jsonschema(rsp_spec)
@@ -839,18 +865,20 @@ class OpenStackServerSourceBase:
so = sorted( so = sorted(
query_params_versions, query_params_versions,
key=lambda d: ( key=lambda d: (
tuple(map(int, d[1].split("."))) if d[1] else (0, 0) tuple(map(int, d.min_version.split(".")))
if d.min_version
else (0, 0)
), ),
) )
for data, min_ver, max_ver in so: for item in so:
if data: if item.schema:
self.process_query_parameters( self.process_query_parameters(
openapi_spec, openapi_spec,
operation_spec, operation_spec,
path_resource_names, path_resource_names,
data, item.schema,
min_ver, item.min_version,
max_ver, item.max_version,
) )
# if body_schemas or mode == "action": # if body_schemas or mode == "action":
if method in ["PUT", "POST", "PATCH"]: if method in ["PUT", "POST", "PATCH"]:
@@ -1070,7 +1098,7 @@ class OpenStackServerSourceBase:
schema_ref = f"#/components/schemas/{cont_schema_name}" schema_ref = f"#/components/schemas/{cont_schema_name}"
else: else:
# otherwise just use schema as body # otherwise just use schema as body
schema_ref = body_schemas[0] schema_ref = body_schemas.pop()
elif body_schemas is not UNSET and len(body_schemas) > 1: 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 # 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", {}) op_body = operation_spec.requestBody.setdefault("content", {})
@@ -1198,11 +1226,19 @@ class OpenStackServerSourceBase:
]: ]:
if not schema.openstack: if not schema.openstack:
schema.openstack = {} schema.openstack = {}
schema.openstack["min-ver"] = start_version.get_string() 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 end_version and self._api_ver_major(end_version) not in ["0", 0]:
if not schema.openstack: if not schema.openstack:
schema.openstack = {} schema.openstack = {}
schema.openstack["max-ver"] = end_version.get_string() schema.openstack["max-ver"] = (
end_version.get_string()
if hasattr(end_version, "get_string")
else end_version
)
return schema return schema
def _get_param_ref( def _get_param_ref(
@@ -1341,32 +1377,48 @@ class OpenStackServerSourceBase:
end_version, end_version,
action_name: str | None = None, action_name: str | None = None,
operation_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.""" """Extract schemas from the decorated method."""
# Unwrap operation decorators to access all properties # Unwrap operation decorators to access all properties
expected_errors: list[str] = [] expected_errors: list[str] = []
body_schemas: list[str | None] | Unset = UNSET body_schemas: set[str | None] | Unset = UNSET
query_params_versions: list[tuple] = [] query_params_versions: set[QueryParamsSchema] = set()
response_body_schema: dict | Unset | None = UNSET response_body_schema: dict | Unset | None = UNSET
f = func f = func
while hasattr(f, "__wrapped__"): while hasattr(f, "__wrapped__"):
closure = inspect.getclosurevars(f) closure = inspect.getclosurevars(f)
closure_locals = closure.nonlocals closure_locals = closure.nonlocals
min_ver = closure_locals.get("min_version", start_version) min_ver = (
closure_locals.get("min_version", start_version)
or start_version
)
if min_ver and not isinstance(min_ver, str): if min_ver and not isinstance(min_ver, str):
min_ver = ( min_ver = (
min_ver.get_string() min_ver.get_string()
if hasattr(min_ver, "get_string") if hasattr(min_ver, "get_string")
else str(min_ver) else str(min_ver)
) )
max_ver = closure_locals.get("max_version", end_version) 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): if max_ver and not isinstance(max_ver, str):
max_ver = ( max_ver = (
max_ver.get_string() max_ver.get_string()
if hasattr(max_ver, "get_string") if hasattr(max_ver, "get_string")
else str(max_ver) else str(max_ver)
) )
if max_ver and not end_version:
end_version = max_ver
if "errors" in closure_locals: if "errors" in closure_locals:
expected_errors = closure_locals["errors"] expected_errors = closure_locals["errors"]
@@ -1389,11 +1441,12 @@ class OpenStackServerSourceBase:
) )
# body schemas are not UNSET anymore # body schemas are not UNSET anymore
if body_schemas is UNSET: if body_schemas is UNSET:
body_schemas = [] body_schemas = set()
if obj is not None: if obj is not None:
if obj.get("type") in ["object", "array"]: if obj.get("type") in ["object", "array"]:
# We only allow object and array bodies # We only allow object and array bodies
# To prevent type name collision keep module name part of the name # To prevent type name collision keep module name
# part of the name
typ_name = ( typ_name = (
"".join([x.title() for x in path_resource_names]) "".join([x.title() for x in path_resource_names])
+ func.__name__.title() + func.__name__.title()
@@ -1428,12 +1481,12 @@ class OpenStackServerSourceBase:
comp_schema.openstack["action-name"] = action_name comp_schema.openstack["action-name"] = action_name
ref_name = f"#/components/schemas/{typ_name}" ref_name = f"#/components/schemas/{typ_name}"
if isinstance(body_schemas, list): if isinstance(body_schemas, set):
body_schemas.append(ref_name) body_schemas.add(ref_name)
else: else:
# register no-body # register no-body
if isinstance(body_schemas, list): if isinstance(body_schemas, set):
body_schemas.append(None) body_schemas.add(None)
if "response_body_schema" in closure_locals or hasattr( if "response_body_schema" in closure_locals or hasattr(
f, "_response_body_schema" f, "_response_body_schema"
@@ -1451,17 +1504,21 @@ class OpenStackServerSourceBase:
"query_params_schema", "query_params_schema",
getattr(f, "_request_query_schema", {}), getattr(f, "_request_query_schema", {}),
) )
query_params_versions.append((obj, min_ver, max_ver)) query_params_versions.add(
QueryParamsSchema(obj, min_ver, max_ver)
)
elif "request_query_schema" in closure_locals: elif "request_query_schema" in closure_locals:
# Nova altered the decorator # Nova altered the decorator
obj = closure_locals.get( obj = closure_locals.get(
"request_query_schema", "request_query_schema",
getattr(f, "request_query_schema", {}), getattr(f, "request_query_schema", {}),
) )
query_params_versions.append((obj, min_ver, max_ver)) query_params_versions.add(
QueryParamsSchema(obj, min_ver, max_ver)
)
if "validators" in closure_locals: if "validators" in closure_locals:
validators = closure_locals.get("validators") validators = closure_locals.get("validators")
body_schemas = [] body_schemas = set()
if isinstance(validators, dict): if isinstance(validators, dict):
for k, v in validators.items(): for k, v in validators.items():
sig = inspect.signature(v) sig = inspect.signature(v)
@@ -1498,8 +1555,9 @@ class OpenStackServerSourceBase:
) )
ref_name = f"#/components/schemas/{typ_name}" ref_name = f"#/components/schemas/{typ_name}"
if isinstance(body_schemas, list):
body_schemas.append(ref_name) if isinstance(body_schemas, set):
body_schemas.add(ref_name)
f = f.__wrapped__ f = f.__wrapped__

View File

@@ -66,13 +66,22 @@ class CinderV3Generator(OpenStackServerSourceBase):
] ]
def _api_ver_major(self, ver): def _api_ver_major(self, ver):
return ver._ver_major if hasattr(ver, "_ver_major"):
return ver._ver_major
elif isinstance(ver, str) and "." in ver:
return ver.split(".")[0]
def _api_ver_minor(self, ver): def _api_ver_minor(self, ver):
return ver._ver_minor if hasattr(ver, "_ver_minor"):
return ver._ver_minor
elif isinstance(ver, str) and "." in ver:
return ver.split(".")[1]
def _api_ver(self, ver): def _api_ver(self, ver):
return (ver._ver_major, ver._ver_minor) if hasattr(ver, "_ver_major") and hasattr(ver, "_ver_minor"):
return (ver._ver_major, ver._ver_minor)
elif isinstance(ver, str):
return tuple(int(x) for x in ver.split("."))
def generate(self, target_dir, args): def generate(self, target_dir, args):
proc = Process(target=self._generate, args=[target_dir, args]) proc = Process(target=self._generate, args=[target_dir, args])

View File

@@ -22,6 +22,8 @@ from codegenerator.common.schema import PathSchema
from codegenerator.common.schema import SpecSchema from codegenerator.common.schema import SpecSchema
from codegenerator.common.schema import TypeSchema from codegenerator.common.schema import TypeSchema
from codegenerator.openapi.base import OpenStackServerSourceBase from codegenerator.openapi.base import OpenStackServerSourceBase
from codegenerator.openapi.base import QueryParamsSchema
from codegenerator.openapi.base import Unset
from codegenerator.openapi.keystone_schemas import application_credential from codegenerator.openapi.keystone_schemas import application_credential
from codegenerator.openapi.keystone_schemas import auth from codegenerator.openapi.keystone_schemas import auth
from codegenerator.openapi.keystone_schemas import common from codegenerator.openapi.keystone_schemas import common
@@ -378,13 +380,13 @@ class KeystoneGenerator(OpenStackServerSourceBase):
doc = rst_to_md(doc) doc = rst_to_md(doc)
operation_spec.description = LiteralScalarString(doc) operation_spec.description = LiteralScalarString(doc)
query_params_versions = [] query_params_versions: set[QueryParamsSchema] = set()
body_schemas = [] body_schemas: set[str | None] | Unset | None = set()
expected_errors = ["404"] expected_errors = ["404"]
response_code = None response_code = None
start_version = None start_version = None
end_version = None end_version = None
ser_schema: dict | None = {} ser_schema: dict | None | Unset = {}
(query_params_versions, body_schemas, ser_schema, expected_errors) = ( (query_params_versions, body_schemas, ser_schema, expected_errors) = (
self._process_decorators( self._process_decorators(
@@ -403,18 +405,20 @@ class KeystoneGenerator(OpenStackServerSourceBase):
so = sorted( so = sorted(
query_params_versions, query_params_versions,
key=lambda d: ( key=lambda d: (
tuple(map(int, d[1].split("."))) if d[1] else (0, 0) tuple(map(int, d.min_version.split(".")))
if d.min_version
else (0, 0)
), ),
) )
for data, min_ver, max_ver in so: for item in so:
if data: if item.schema:
self.process_query_parameters( self.process_query_parameters(
openapi_spec, openapi_spec,
operation_spec, operation_spec,
path_resource_names, path_resource_names,
data, item.schema,
min_ver, item.min_version,
max_ver, item.max_version,
) )
if method in ["PUT", "POST", "PATCH"]: if method in ["PUT", "POST", "PATCH"]:

View File

@@ -38,13 +38,22 @@ class NovaGenerator(OpenStackServerSourceBase):
} }
def _api_ver_major(self, ver): def _api_ver_major(self, ver):
return ver.ver_major if hasattr(ver, "ver_major"):
return ver.ver_major
elif isinstance(ver, str) and "." in ver:
return ver.split(".")[0]
def _api_ver_minor(self, ver): def _api_ver_minor(self, ver):
return ver.ver_minor if hasattr(ver, "ver_minor"):
return ver.ver_minor
elif isinstance(ver, str) and "." in ver:
return ver.split(".")[1]
def _api_ver(self, ver): def _api_ver(self, ver):
return (ver.ver_major, ver.ver_minor) if hasattr(ver, "ver_major") and hasattr(ver, "ver_minor"):
return (ver.ver_major, ver.ver_minor)
elif isinstance(ver, str):
return tuple(int(x) for x in ver.split("."))
def _generate(self, target_dir, args): def _generate(self, target_dir, args):
from nova.api.openstack import api_version_request from nova.api.openstack import api_version_request

View File

@@ -20,10 +20,9 @@ import fixtures
from codegenerator.common.schema import SpecSchema from codegenerator.common.schema import SpecSchema
from codegenerator.common.schema import TypeSchema from codegenerator.common.schema import TypeSchema
from codegenerator.openapi.base import ( from codegenerator.openapi.base import _convert_wsme_to_jsonschema
OpenStackServerSourceBase, from codegenerator.openapi.base import OpenStackServerSourceBase
_convert_wsme_to_jsonschema, from codegenerator.openapi.base import QueryParamsSchema
)
from codegenerator.openapi.utils import merge_api_ref_doc from codegenerator.openapi.utils import merge_api_ref_doc
from ruamel.yaml.scalarstring import LiteralScalarString from ruamel.yaml.scalarstring import LiteralScalarString
@@ -1177,7 +1176,7 @@ class OctaviaGenerator(OpenStackServerSourceBase):
} }
if qp: if qp:
query_params_versions.append((qp, None, None)) query_params_versions.add(QueryParamsSchema(qp, None, None))
return ( return (
query_params_versions, query_params_versions,

View File

@@ -12,6 +12,7 @@
- codegenerator-openapi-container-infrastructure-management-tips-with-api-ref - codegenerator-openapi-container-infrastructure-management-tips-with-api-ref
- codegenerator-openapi-dns-tips-with-api-ref - codegenerator-openapi-dns-tips-with-api-ref
- codegenerator-openapi-identity-tips-with-api-ref - codegenerator-openapi-identity-tips-with-api-ref
- codegenerator-openapi-identity-tips
- codegenerator-openapi-image-tips-with-api-ref - codegenerator-openapi-image-tips-with-api-ref
- codegenerator-openapi-key-manager-tips - codegenerator-openapi-key-manager-tips
- codegenerator-openapi-load-balancing-tips-with-api-ref - codegenerator-openapi-load-balancing-tips-with-api-ref