Respect MV based responses

Many calls have different schemas in the response per MV. Previously the
hope was that it is possible to somehow magically merge them providing a
single response type, but this failed dramatically. "Hail" to
microversions.

Depends-On: https://review.opendev.org/c/openstack/nova/+/986660
Change-Id: I6a19a897b49efee6ce9c33985b2e84cebe7827bc
Signed-off-by: Artem Goncharov <artem.goncharov@gmail.com>
This commit is contained in:
Artem Goncharov
2026-04-30 13:31:24 +02:00
parent 549d982851
commit f02ac4e2e9
18 changed files with 1361 additions and 555 deletions
+222 -47
View File
@@ -10,6 +10,7 @@
# License for the specific language governing permissions and limitations
# under the License.
#
import copy
import logging
import re
from pathlib import Path
@@ -237,6 +238,8 @@ def find_resource_schema(
schema["type"] = "string"
elif isinstance(schema["enum"][0], bool):
schema["type"] = "boolean"
elif "const" in schema:
return (None, None)
else:
raise RuntimeError(f"No type in {schema}")
schema_type = schema["type"]
@@ -310,31 +313,58 @@ def find_resource_schema(
return (None, None)
def find_response_schema(
responses: dict, response_key: str, action_name: str | None = None
):
"""Locate response schema
def ensure_microversion_bounds(
microversion: str | None, x_openstack: dict[str, Any]
) -> bool:
"""Check the microversion range match for the x-openstack data of the schema
candidate
:returns bool: true when the requested microversion is not outside of the
schema bounds.
"""
if microversion and "min-ver" in x_openstack:
# compare current min-ver >= requested
requested_mv = tuple(int(x) for x in microversion.split("."))
candidate_min_mv = tuple(
int(x) for x in x_openstack["min-ver"].split(".")
)
if candidate_min_mv > requested_mv:
return False
# compare current max-ver <= requested
if "max-ver" in x_openstack:
candidate_max_mv = tuple(
int(x) for x in x_openstack["max-ver"].split(".")
)
if candidate_max_mv < requested_mv:
return False
return True
def get_response_candidates(
responses: dict,
response_key: str,
action_name: str | None = None,
microversion: str | None = None,
) -> list[tuple[dict[str, Any], dict[str, Any] | None]]:
"""Find all operation response candidates
Some operations are having variety of possible responses (depending on
microversion, action, etc). Try to locate suitable response for the client.
The function iterates over all defined responses and for 2** applies the
following logic:
- if action_name is present AND oneOf is present AND action_name is in one
of the oneOf schemas -> return this schema
- if action_name is not present AND oneOf is present AND response_key is in
one of the OneOf candidates' properties (this is an object) -> return it
- action_name is not present AND oneOf is not present and (response_key or
plural of the response_key) in candidate -> return it
:param dict responses: Dictionary with responses as defined in OpenAPI spec
:param dict responses: Response definition from the OpenAPI spec.
:param str response_key: Response key to be searching in responses (when
action_name is not given) :param str action_name: Action name to be
searching response for
action_name is not given)
:param str action_name: Action name to be searching response for
:param str microversion: Optional microversion to search potential responses
for.
:returns list[tuple[dict, dict], dict[str, Any]|None responses: List of
tuples representing the response body and the extension data
associated with the response.
"""
response_schemas: list[tuple[dict[str, Any], dict[str, Any] | None]] = []
for code, rspec in responses.items():
if not code.startswith("2"):
continue
@@ -349,37 +379,152 @@ def find_response_schema(
# Server create returns server or reservation info. For the
# cli it is not very helpful and we look for response
# candidate with the resource_name in the response
kind_suffix = ord("a")
for candidate in oneof:
if (
action_name
and candidate.get("x-openstack", {}).get(
"action-name"
ext = candidate.get("x-openstack", {})
if "action-name" in ext and (
(
action_name
and action_name != ext.get("action-name")
)
== action_name
or (not action_name)
):
if response_key in candidate.get("properties", {}):
# If there is a object with resource_name in
# the props - this must be what we want to look
# at
return candidate["properties"][response_key]
else:
return candidate
# This is the action response and not the one we are
# interested in
continue
elif (
not action_name
and response_key
and candidate.get("type") == "object"
and response_key in candidate.get("properties", {})
ext
and action_name
and action_name == ext.get("action-name")
):
# Actually for the sake of the CLI it may make
# sense to merge all candidates
return candidate["properties"][response_key]
# action-name set inside the candidate - final
# response if the name matches
# When a microversion is requested check the
# range
if not ensure_microversion_bounds(
microversion, ext
):
continue
response_schemas.append((candidate, ext))
elif not action_name and response_key:
# response key present in the candidate - final
# response (but maybe a tricky one)
if (
candidate.get("type") == "object"
and not response_key in candidate["properties"]
and get_plural_form(response_key)
in candidate["properties"]
):
# Actually this looks like a list result
# compute.server.list_detailed is a oneOf of MVs
# with certain MVs {"properties": {"server":
# {"items": {"oneOf"....
if not ensure_microversion_bounds(
microversion, ext
):
continue
res_def = candidate["properties"][
get_plural_form(response_key)
]
if res_def.get("type") != "array":
raise RuntimeError(
f"List operation has been identified, but the response object is not array"
)
if "oneOf" in res_def.get("items"):
subkind_suffix = ord("a")
kind_ext = ext or {}
base = copy.deepcopy(candidate)
base["properties"][
get_plural_form(response_key)
]["items"].pop("oneOf", None)
for subkind in res_def["items"]["oneOf"]:
kind_ext = copy.deepcopy(kind_ext)
kind_ext["kind-suffix"] = chr(
subkind_suffix
)
kind_schema = copy.deepcopy(base)
kind_schema["properties"][
get_plural_form(response_key)
]["items"] = subkind
response_schemas.append(
(kind_schema, kind_ext)
)
subkind_suffix += 1
else:
response_schemas.append((candidate, ext))
elif (
candidate.get("type") == "object"
and (
response_key
in candidate.get("properties", {})
)
and "oneOf"
in candidate["properties"][response_key]
):
# This is a very dirty thing e.g., nova does
# with the response where the response schema
# is a "oneOf" from few incompatible kinds
# (server show). It can only be treated by
# splitting them into individual variants.
subkind_suffix = ord("a")
kind_ext = ext or {}
base = copy.deepcopy(candidate)
base["properties"][response_key].pop(
"oneOf", None
)
for kind in candidate["properties"][
response_key
]["oneOf"]:
# When a microversion is requested check the
# range
if not ensure_microversion_bounds(
microversion, kind_ext
):
continue
kind_ext = copy.deepcopy(kind_ext)
kind_ext["kind-suffix"] = chr(
subkind_suffix
)
kind_schema = copy.deepcopy(base)
kind_schema["properties"][
response_key
].update(kind)
response_schemas.append(
(kind_schema, kind_ext)
)
subkind_suffix += 1
# a candidate without any reasonable identity and
# we are not searching for an action - most likely
# a response
elif ext and "min-ver" in ext:
# When a microversion is requested check the
# range
if not ensure_microversion_bounds(
microversion, ext
):
continue
response_schemas.append((candidate, ext))
else:
# e.g., compute.server.create has a oneOf response. We need
# to treat them as individual possibilities since there is
# no other practical way.
kind_ext = ext or {}
kind_ext = copy.deepcopy(kind_ext)
kind_ext["kind-suffix"] = chr(kind_suffix)
response_schemas.append((candidate, kind_ext))
kind_suffix += 1
else:
raise NotImplementedError
elif (
not action_name
and schema
schema
and not action_name
and (
response_key in schema
or get_plural_form(response_key) in schema
or (
schema.get("type") == "object"
and (
@@ -391,10 +536,10 @@ def find_response_schema(
or schema.get("type") == "string"
)
):
return schema
if not action_name:
response_schemas.append((schema, schema.get("x-openstack")))
if not action_name and not microversion and len(response_schemas) == 0:
# Could not find anything with the given response_key. If there is any
# 200/204 response - return it
# 200/204 response and it is not an action/MV lookup - return it
for code in ["200", "201", "202", "204"]:
if code in responses:
schema = (
@@ -403,12 +548,38 @@ def find_response_schema(
.get("application/json", {})
.get("schema")
)
if schema and "type" in schema:
return schema
return None
if schema:
ext = schema.get("x-openstack", {})
if "oneOf" in schema:
# e.g., compute.server.create has a oneOf response. We need
# to treat them as individual possibilities since there is
# no other practical way.
# Build up the base (if any) that need to be merged with
# every candidate `{"type": "object", "oneOf": ...}`
base = copy.deepcopy(schema)
base.pop("oneOf")
kind_suffix = ord("a")
for kind in schema["oneOf"]:
# merge the base into the kind
kind.update(**base)
kind_ext = ext or {}
kind_ext = copy.deepcopy(kind_ext)
kind_ext["kind-suffix"] = chr(kind_suffix)
response_schemas.append((kind, kind_ext))
kind_suffix += 1
elif "type" in schema:
# just a single plain schema - last resort
response_schemas.append(
(schema, schema.get("x-openstack"))
)
return response_schemas
def get_operation_variants(spec: dict, action_name: str | None = None):
def get_operation_variants(
spec: dict, action_name: str | None = None
) -> list[dict[str, Any]]:
"""Find operation body suitable for the generator"""
request_body = spec.get("requestBody")
# List of operation variants (based on the body)
@@ -462,6 +633,9 @@ def get_operation_variants(spec: dict, action_name: str | None = None):
"min-ver": subvariant_spec.get(
"min-ver"
),
"max-ver": subvariant_spec.get(
"max-ver"
),
"mime_type": mime_type,
}
)
@@ -474,6 +648,7 @@ def get_operation_variants(spec: dict, action_name: str | None = None):
"body": variant,
"mode": "action",
"min-ver": variant_spec.get("min-ver"),
"max-ver": variant_spec.get("max-ver"),
"mime_type": mime_type,
}
)
+14 -8
View File
@@ -12,16 +12,17 @@
#
import logging
import re
from typing import Any
from collections.abc import Generator
from typing import Any
from pydantic import BaseModel
from codegenerator.common import BasePrimitiveType
from codegenerator.common import BaseCombinedType
from codegenerator.common import BaseCompoundType
from codegenerator import model
from codegenerator import common
from codegenerator import common, model
from codegenerator.common import (
BaseCombinedType,
BaseCompoundType,
BasePrimitiveType,
)
CODEBLOCK_RE = re.compile(r"```(\w*)$")
@@ -896,6 +897,10 @@ class TypeManager:
)
]
)
# It is forbidden in rust to use "Self" as an enum
# kind.
if val == "Self":
val = "Current"
if val and val[0].isdigit():
val = "_" + val
vals = variants.setdefault(val, set())
@@ -1015,9 +1020,10 @@ class TypeManager:
kind_description: str | None = None
if isinstance(kind_data["model"], model.ADT):
kind_name = self.get_model_name(kind_data["model"])
kind_description = kind_data["model"].description
else:
kind_name = f"F{cnt}"
if hasattr(kind_data["model"], "description"):
kind_description = kind_data["model"].description
enum_kind = enum_class._kind_type_class.get_default()(
name=kind_name,
description=sanitize_rust_docstrings(kind_description),
@@ -1286,7 +1292,7 @@ class TypeManager:
else:
raise RuntimeError(
f"Model name {new_name} is already present as"
f" {type(model_data_type)}"
f" {type(model_data_type)}. {model_data_type}"
)
elif (
name
+168 -83
View File
@@ -275,7 +275,7 @@ class OpenStackServerSourceBase:
)
if method.upper() != "HEAD":
response = common.find_response_schema(
responses = common.get_response_candidates(
spec["responses"],
response_key or resource_name,
(
@@ -285,7 +285,7 @@ class OpenStackServerSourceBase:
),
)
if response:
if responses:
if response_key:
response_key = (
response_key
@@ -294,18 +294,21 @@ class OpenStackServerSourceBase:
)
else:
response_key = resource_name
response_def, _ = common.find_resource_schema(
response, None, response_key
)
for schema, ext in responses:
response_def, _ = common.find_resource_schema(
schema, 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)
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(
@@ -391,11 +394,7 @@ class OpenStackServerSourceBase:
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]):
@@ -636,7 +635,16 @@ class OpenStackServerSourceBase:
"Versioned action %s", ver_method.func
)
else:
action_impls.append((op_name, None, None))
# An action without explicit versioned decorator, but
# version bounds may be present (e.g.,
# `wsgi.api_version(2.1, 2.5)`
start_version = closurevars.nonlocals.get(
"min_version"
)
end_version = closurevars.nonlocals.get("max_version")
action_impls.append(
(op_name, start_version, end_version)
)
# Get the path/op spec only when we have
# something to fill in
@@ -755,7 +763,7 @@ class OpenStackServerSourceBase:
action_name = None
query_params_versions: set[QueryParamsSchema] = set()
body_schemas: set[str | None] | Unset = UNSET
expected_errors = ["404"]
expected_errors: set[str] = {"404"}
response_code = None
# Version bound on an operation are set only when it is not an
# "action"
@@ -818,7 +826,7 @@ class OpenStackServerSourceBase:
(
query_params_versions,
body_schemas,
response_body_schema,
response_body_schemas,
expected_errors,
) = self._process_decorators(
func,
@@ -830,6 +838,7 @@ class OpenStackServerSourceBase:
action_name,
operation_name,
)
# response_body_schema: dict | None = None
if hasattr(func, "_wsme_definition"):
fdef = getattr(func, "_wsme_definition")
@@ -900,8 +909,6 @@ class OpenStackServerSourceBase:
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"})
@@ -911,6 +918,11 @@ class OpenStackServerSourceBase:
operation_spec.deprecated = True
if not response_code:
response_codes = getattr(func, "wsgi_code", None)
if not response_codes:
if hasattr(func, "wsgi_codes"):
# TODO: Nova exposes wsgi_codes as a tuple of (code, min_ver,
# max_ver)
pass
if response_codes:
if not isinstance(response_codes, list):
response_codes = [response_codes]
@@ -928,12 +940,14 @@ class OpenStackServerSourceBase:
method, operation_spec.operationId
)
if response_codes:
# Default mime_type which persists across RC iteration (glance image
# download uses 200, 206
mime_type = "application/json"
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])
+ (
@@ -945,53 +959,58 @@ class OpenStackServerSourceBase:
)
+ "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 not response_body_schemas:
(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=ser_schema,
action_name=action_name,
)
if mode == "action" and curr_schema:
if schema_ref:
response_body_schemas = {schema_ref}
if response_body_schemas:
for schema_ref in response_body_schemas:
curr_schema = (
rsp.get("content", {})
.get(mime_type, {})
.get("schema", {})
)
# There is existing response for the action. Need to
# merge them
curr_oneOf: list | None = None
curr_ref: str | None = None
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
elif isinstance(curr_schema, str):
curr_ref = curr_schema # .get("$ref")
if curr_oneOf:
if schema_ref not in [
x["$ref"] for x in curr_oneOf
]:
curr_oneOf.append({"$ref": schema_ref})
if schema_ref:
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=[
rsp["content"][mime_type]["schema"] = {
"oneOf": [
{"$ref": curr_ref},
{"$ref": schema_ref},
]
)
else:
rsp["content"] = {
"application/json": {
"schema": {"$ref": schema_ref}
}
}
else:
if schema_ref:
rsp["content"] = {
mime_type: {
"schema": {"$ref": schema_ref}
}
}
# Ensure operation tags are existing
for tag in operation_spec.tags:
@@ -1400,22 +1419,29 @@ class OpenStackServerSourceBase:
action_name: str | None = None,
operation_name: str | None = None,
) -> tuple[
set[QueryParamsSchema],
set[str | None] | Unset,
dict | Unset | None,
list[str],
set[QueryParamsSchema], # query params
set[str | None] | Unset, # body schemas
set[str] | Unset | None, # responses
set[str], # errors
]:
"""Extract schemas from the decorated method."""
# Unwrap operation decorators to access all properties
expected_errors: list[str] = []
expected_errors: set[str] = set()
body_schemas: set[str | None] | Unset = UNSET
query_params_versions: set[QueryParamsSchema] = set()
response_body_schema: dict | Unset | None = UNSET
response_body_schemas: set[str] | Unset | None = UNSET
# sometimes handler version bounds is set on the schema level, sometimes
# on the whole handler level
global_min_ver: str | None = None
global_max_ver: str | None = None
f = func
while hasattr(f, "__wrapped__"):
closure = inspect.getclosurevars(f)
closure_locals = closure.nonlocals
min_ver = None
max_ver = None
min_ver = (
closure_locals.get("min_version", start_version)
or start_version
@@ -1427,8 +1453,10 @@ class OpenStackServerSourceBase:
if hasattr(min_ver, "get_string")
else str(min_ver)
)
if min_ver and not start_version:
start_version = min_ver
# NOTE: setting start_version/min_version cause reset of the info
# between request and response
# if min_ver and not start_version:
# start_version = min_ver
max_ver = (
closure_locals.get("max_version", end_version) or end_version
@@ -1439,20 +1467,26 @@ class OpenStackServerSourceBase:
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
)
]
current_expected_errors = closure_locals["errors"]
# In nova `errors` is an int or set of ints. Additionally this
# may be duplicated for different MV. We are only interested in
# the overall errors and not when they come.
if isinstance(current_expected_errors, list) or isinstance(
current_expected_errors, tuple
):
expected_errors.update(
{
str(x)
for x in filter(
lambda x: isinstance(x, int),
current_expected_errors,
)
}
)
elif isinstance(expected_errors, int):
expected_errors = [str(expected_errors)]
expected_errors.append(str(current_expected_errors))
if "request_body_schema" in closure_locals or hasattr(
f, "_request_body_schema"
):
@@ -1489,14 +1523,18 @@ class OpenStackServerSourceBase:
)
)
if min_ver:
if min_ver or global_min_ver:
if not comp_schema.openstack:
comp_schema.openstack = {}
comp_schema.openstack["min-ver"] = min_ver
if max_ver:
comp_schema.openstack["min-ver"] = (
min_ver or global_min_ver
)
if max_ver or global_max_ver:
if not comp_schema.openstack:
comp_schema.openstack = {}
comp_schema.openstack["max-ver"] = max_ver
comp_schema.openstack["max-ver"] = (
max_ver or global_max_ver
)
if mode == "action":
if not comp_schema.openstack:
comp_schema.openstack = {}
@@ -1518,7 +1556,54 @@ class OpenStackServerSourceBase:
"response_body_schema",
getattr(f, "_response_body_schema", {}),
)
response_body_schema = obj
if response_body_schemas is UNSET:
response_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()
+ "Response"
+ (
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 or global_min_ver:
if not comp_schema.openstack:
comp_schema.openstack = {}
comp_schema.openstack["min-ver"] = (
min_ver or global_min_ver
)
if max_ver or global_max_ver:
if not comp_schema.openstack:
comp_schema.openstack = {}
comp_schema.openstack["max-ver"] = (
max_ver or global_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(response_body_schemas, set):
response_body_schemas.add(ref_name)
if "query_params_schema" in closure_locals or hasattr(
f, "_request_query_schema"
):
@@ -1586,7 +1671,7 @@ class OpenStackServerSourceBase:
return (
query_params_versions,
body_schemas,
response_body_schema,
response_body_schemas,
expected_errors,
)
+3 -4
View File
@@ -13,8 +13,7 @@
from typing import Any
from codegenerator.common.schema import TypeSchema
from codegenerator.common.schema import ParameterSchema
from codegenerator.common.schema import ParameterSchema, TypeSchema
ZONE_TYPES: list[str] = ["PRIMARY", "SECONDARY", "CATALOG"]
ZONE_ACTIONS: list[str] = ["CREATE", "DELETE", "UPDATE", "NONE"]
@@ -847,7 +846,7 @@ def _get_schema_ref(
]:
openapi_spec.components.schemas[name] = TypeSchema(**ZONE_SCHEMA)
ref = f"#/components/schemas/{name}"
if name in ["ZoneUpdateRequest"]:
elif name in ["ZoneUpdateRequest"]:
openapi_spec.components.schemas[name] = TypeSchema(
**ZONE_UPDATE_REQUEST_SCHEMA
)
@@ -876,7 +875,7 @@ def _get_schema_ref(
**NAMESERVERS_SCHEMA
)
ref = f"#/components/schemas/{name}"
if name in [
elif name in [
"ZonesShareShowResponse",
"ZonesSharesCreateRequest",
"ZonesSharesCreateResponse",
+34 -25
View File
@@ -11,32 +11,37 @@
# under the License.
#
import inspect
from multiprocessing import Process
import logging
from multiprocessing import Process
from pathlib import Path
from ruamel.yaml.scalarstring import LiteralScalarString
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.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 auth
from codegenerator.openapi.keystone_schemas import common
from codegenerator.openapi.keystone_schemas import domain
from codegenerator.openapi.keystone_schemas import endpoint
from codegenerator.openapi.keystone_schemas import federation
from codegenerator.openapi.keystone_schemas import group
from codegenerator.openapi.keystone_schemas import region
from codegenerator.openapi.keystone_schemas import role
from codegenerator.openapi.keystone_schemas import service
from codegenerator.openapi.keystone_schemas import user
from codegenerator.openapi.utils import merge_api_ref_doc
from codegenerator.openapi.utils import rst_to_md
from codegenerator.common.schema import (
ParameterSchema,
PathSchema,
SpecSchema,
TypeSchema,
)
from codegenerator.openapi.base import (
OpenStackServerSourceBase,
QueryParamsSchema,
Unset,
)
from codegenerator.openapi.keystone_schemas import (
application_credential,
auth,
common,
domain,
endpoint,
federation,
group,
region,
role,
service,
user,
)
from codegenerator.openapi.utils import merge_api_ref_doc, rst_to_md
class KeystoneGenerator(OpenStackServerSourceBase):
@@ -82,8 +87,8 @@ class KeystoneGenerator(OpenStackServerSourceBase):
raise RuntimeError("Error generating Keystone OpenAPI schema")
def _generate(self, target_dir, args, *pargs, **kwargs):
from keystone.server.flask import application
from keystone import version as keystone_version
from keystone.server.flask import application
self.app = application.application_factory()
self.router = self.app.url_map
@@ -388,11 +393,11 @@ class KeystoneGenerator(OpenStackServerSourceBase):
query_params_versions: set[QueryParamsSchema] = set()
body_schemas: set[str | None] | Unset | None = set()
expected_errors = ["404"]
expected_errors: set[str] = {"404"}
response_code = None
start_version = None
end_version = None
ser_schema: dict | None | Unset = {}
ser_schema: set[str] | None | Unset = set()
(query_params_versions, body_schemas, ser_schema, expected_errors) = (
self._process_decorators(
@@ -479,11 +484,15 @@ class KeystoneGenerator(OpenStackServerSourceBase):
openapi_spec,
schema_name,
description=f"Response of the {operation_spec.operationId} operation",
schema_def=ser_schema,
# schema_def=ser_schema,
)
if schema_ref:
rsp["content"] = {mime_type: {"schema": {"$ref": schema_ref}}}
elif ser_schema:
rsp["content"] = {
"application/json": {"schema": {"$ref": ser_schema.pop()}}
}
if path == "/v3/auth/tokens":
rsp_headers = rsp.setdefault("headers", {})
+6 -6
View File
@@ -177,13 +177,13 @@ class NovaGenerator(OpenStackServerSourceBase):
# NOTE(gtema): This must go away once schemas are merged directly to
# Nova
# /servers
if name == "ServersCreateResponse":
schema = openapi_spec.components.schemas.setdefault(
name, TypeSchema(**nova_schemas.SERVER_CREATED_SCHEMA)
)
ref = f"#/components/schemas/{name}"
# if name == "ServersCreateResponse":
# schema = openapi_spec.components.schemas.setdefault(
# name, TypeSchema(**nova_schemas.SERVER_CREATED_SCHEMA)
# )
# ref = f"#/components/schemas/{name}"
elif name == "ServersListResponse":
if name == "ServersListResponse":
schema = openapi_spec.components.schemas.setdefault(
name, TypeSchema(**nova_schemas.SERVER_LIST_SCHEMA)
)
+144 -86
View File
@@ -933,104 +933,157 @@ class RustCliGenerator(BaseGenerator):
result_def: dict = {}
response_def: dict | None = {}
# response candidates module names (typically including the MV suffix) with
# corresponding min-ver and max-ver.
response_mod_candidates: list[
tuple[
str, # mod_name
str | None, # min_ver
str | None, # max_ver
]
] = []
resource_header_metadata: dict = {}
# Process response information
# # Prepare information about response
if method.upper() != "HEAD":
response = common.find_response_schema(
responses = common.get_response_candidates(
spec["responses"],
args.response_key or resource_name,
args.action_name,
microversion,
)
if response:
response_key: str | None
if args.response_key:
response_key = (
args.response_key
if args.response_key != "null"
else None
)
else:
response_key = resource_name
response_def, response_key = common.find_resource_schema(
response, None, response_key
)
if not response_def and response.get("type") == "string":
response_def = response
if response_def:
if response_def.get("type", "object") == "object" or (
# BS metadata is defined with type: ["object",
# "null"]
isinstance(response_def.get("type"), list)
and "object" in response_def["type"]
):
(root, response_types) = openapi_parser.parse(
response_def
if responses:
for response, ext in responses:
response_key: str | None
if args.response_key:
response_key = (
args.response_key
if args.response_key != "null"
else None
)
if not isinstance(root, model.Dictionary):
if method == "patch" and not request_types:
# image patch is a jsonpatch based operation
# where there is no request. For it we need to
# look at the response and get writable
# parameters as a base
is_json_patch = True
if not args.find_implemented_by_sdk:
raise NotImplementedError
additional_imports.update(
[
"json_patch::{Patch, diff}",
"serde_json::json",
]
)
additional_imports.add(
f"openstack_types::"
+ "::".join(types_mod_path)
+ "::*"
)
(_, response_types) = openapi_parser.parse(
response_def, ignore_read_only=True
)
type_manager.set_models(response_types)
else:
response_key = resource_name
response_def, response_key = (
common.find_resource_schema(
response, None, response_key
)
)
elif response_def["type"] == "string":
(root_dt, _) = openapi_parser.parse(response_def)
if not root_dt:
raise RuntimeError(
"Response data can not be processed"
)
if (
not response_def
and response.get("type") == "string"
):
response_def = response
response_props = response.get("properties", {})
if response_props and (
(
response_key
and response_props.get(response_key, {}).get(
"type"
if response_def:
if response_def.get(
"type", "object"
) == "object" or (
# BS metadata is defined with type: ["object",
# "null"]
isinstance(response_def.get("type"), list)
and "object" in response_def["type"]
):
(root, response_types) = openapi_parser.parse(
response_def
)
if not isinstance(root, model.Dictionary):
if method == "patch" and not request_types:
# image patch is a jsonpatch based operation
# where there is no request. For it we need to
# look at the response and get writable
# parameters as a base
is_json_patch = True
if not args.find_implemented_by_sdk:
raise NotImplementedError
additional_imports.update(
[
"json_patch::{Patch, diff}",
"serde_json::json",
]
)
additional_imports.add(
f"openstack_types::"
+ "::".join(types_mod_path)
+ "::*"
)
(_, response_types) = (
openapi_parser.parse(
response_def,
ignore_read_only=True,
)
)
type_manager.set_models(response_types)
elif response_def["type"] == "string":
(root_dt, _) = openapi_parser.parse(
response_def
)
if not root_dt:
raise RuntimeError(
"Response data can not be processed"
)
response_props = response.get("properties", {})
if response_props and (
(
response_key
and response_props.get(
response_key, {}
).get("type")
== "array"
)
or response_props[
list(response_props.keys())[0]
].get("type")
== "array"
)
or response_props[
list(response_props.keys())[0]
].get("type")
== "array"
):
result_is_list = True
):
result_is_list = True
mod_response_path = "openstack_types::" + "::".join(
[
f"r#{x}" if x in ["trait", "type"] else x
for x in types_mod_path
]
+ [response_class_name]
)
additional_imports.add(mod_response_path)
else:
response_class_name = None
response_mod_path = types_mod_path.copy()
response_class_name_candidate = response_class_name
if ext and (
"min-ver" in ext or "kind-suffix" in ext
):
# Combine min_ver and kind-suffix into the final
# schema suffix
ver_suffix = ext.get("min-ver")
kind_suffix = ext.get("kind-suffix")
final_suffix = "_".join(
x for x in [ver_suffix, kind_suffix] if x
).replace(".", "")
response_mod_path[-1] = (
response_mod_path[-1] + f"_{final_suffix}"
)
response_mod_candidates.append(
(
response_mod_path[-1],
ext.get("min-ver"),
ext.get("max-ver"),
)
)
else:
response_mod_candidates.append(
(response_mod_path[-1], None, None)
)
mod_response_path = (
"openstack_types::"
+ "::".join(
[
f"r#{x}"
if x in ["trait", "type"]
else x
for x in response_mod_path[:-1]
]
)
)
additional_imports.add(mod_response_path)
else:
response_class_name_candidate = None
else:
response_class_name = None
response_class_name_candidate = None
mod_import_name = "openstack_sdk::api::" + "::".join(
f"r#{x}" if x in ["trait", "type"] else x
@@ -1132,11 +1185,15 @@ class RustCliGenerator(BaseGenerator):
command_description: str = spec.get("description")
command_summary: str = spec.get("summary")
if args.operation_type == "action":
command_description = operation_body.get(
"description", command_description
command_description = (
operation_body.get("description", command_description)
if operation_body
else command_description
)
command_summary = operation_body.get(
"summary", command_summary
command_summary = (
operation_body.get("summary", command_summary)
if operation_body
else command_summary
)
if command_summary and microversion:
@@ -1184,9 +1241,10 @@ class RustCliGenerator(BaseGenerator):
"is_image_download": is_image_download,
"is_json_patch": is_json_patch,
"is_list_paginated": is_list_paginated,
"response_class_name": response_class_name,
"response_class_name": response_class_name_candidate,
"types_mod_path": types_mod_path,
"resource_key": res,
"response_mod_candidates": response_mod_candidates,
}
cli_output_path = [
+10 -22
View File
@@ -531,27 +531,16 @@ class RustSdkGenerator(BaseGenerator):
else:
# Get basic information about response
if method.upper() != "HEAD":
for code, rspec in spec["responses"].items():
if not code.startswith("2"):
continue
content = rspec.get("content", {})
if "application/json" in content:
response_spec = content["application/json"]
try:
(_, response_key) = (
common.find_resource_schema(
response_spec["schema"],
None,
res_name.lower(),
)
)
except Exception:
# Most likely we have response which is oneOf.
# For the SDK it does not really harm to ignore
# this.
pass
# response_def = (None,)
response_key = None
responses = common.get_response_candidates(
spec["responses"], res_name.lower(), args.action_name
)
if len(responses) > 0:
logging.warning(
f"Multiple response candidates are present. For the SDK only look into the last one for detecting the response key"
)
(_, response_key) = common.find_resource_schema(
responses[-1][0], None, res_name.lower()
)
context = {
"operation_id": operation_id,
@@ -603,7 +592,6 @@ class RustSdkGenerator(BaseGenerator):
"resource_name": resource_name,
"service_name": service_type,
}
print(f"context is {context}")
# Generate methods for the GET resource command
self._render_command(context, "rust_sdk/mod.rs.j2", impl_path)
+1 -1
View File
@@ -493,7 +493,7 @@ class RustTuiGenerator(BaseGenerator):
# Get basic information about response
if args.operation_type == "list":
response = common.find_response_schema(
response = common.get_response_candidates(
spec["responses"],
args.response_key or resource_name,
(
+196 -151
View File
@@ -10,9 +10,11 @@
# License for the specific language governing permissions and limitations
# under the License.
#
import copy
import logging
import re
import subprocess
from collections.abc import Generator
from pathlib import Path
from codegenerator import common, model
@@ -280,156 +282,46 @@ class RustTypesGenerator(BaseGenerator):
# Remember the version prefix to discard it in the template
ver_prefix = path_elements[0]
for operation_variant in operation_variants:
logging.debug(f"Processing variant {operation_variant}")
response_type_manager: common_rust.TypeManager = (
ResponseTypeManager()
# for operation_variant in operation_variants:
# logging.debug(f"Processing variant {operation_variant}")
response_type_manager: common_rust.TypeManager = ResponseTypeManager()
additional_imports: set[str] = set()
if api_ver_matches:
api_ver = {
"major": api_ver_matches.group(1),
"minor": api_ver_matches.group(3) or 0,
}
else:
api_ver = {}
class_name = "".join(
x.capitalize()
for x in re.split(common.SPLIT_NAME_RE, resource_name)
)
response_type_manager.root_name = class_name + "Response"
mod_name = "_".join(
x.lower()
for x in re.split(
common.SPLIT_NAME_RE,
(
args.module_name
or args.operation_name
or args.operation_type.value
or method
),
)
additional_imports = set()
)
if api_ver_matches:
api_ver = {
"major": api_ver_matches.group(1),
"minor": api_ver_matches.group(3) or 0,
}
else:
api_ver = {}
class_name = "".join(
x.capitalize()
for x in re.split(common.SPLIT_NAME_RE, resource_name)
)
response_type_manager.root_name = class_name + "Response"
mod_name = "_".join(
x.lower()
for x in re.split(
common.SPLIT_NAME_RE,
(
args.module_name
or args.operation_name
or args.operation_type.value
or method
),
)
)
mod_path = common.get_rust_types_mod_path(
args.service_type,
args.api_version,
args.alternative_module_path or path,
)
mod_path.append("response")
response_key: str | None = None
response_def: dict | None = {}
# Get basic information about response
if method.upper() != "HEAD":
response = common.find_response_schema(
spec["responses"],
args.response_key or resource_name,
args.action_name,
)
if response:
if args.response_key:
response_key = (
args.response_key
if args.response_key != "null"
else None
)
else:
response_key = resource_name
response_def, _ = common.find_resource_schema(
response, None, response_key
)
if not response_def and response.get("type") == "string":
response_def = response
if response_def:
if response_def.get("type", "object") == "object" or (
# BS metadata is defined with type: ["object",
# "null"]
isinstance(response_def.get("type"), list)
and "object" in response_def["type"]
):
(root, response_types) = openapi_parser.parse(
response_def
)
if isinstance(root, model.Dictionary):
value_type: (
common_rust.BasePrimitiveType
| common_rust.BaseCombinedType
| common_rust.BaseCompoundType
| None
) = None
try:
value_type = (
response_type_manager.convert_model(
root.value_type
)
)
except Exception:
# In rare cases we can not convert
# value_type since it depends on different
# types. We are here in the output
# simplification, so just downcast it to
# JsonValue (what is anyway our goal)
value_type = common_rust.JsonValue()
root_dict = common_rust.HashMapResponse(
name=response_type_manager.root_name,
value_type=value_type,
)
response_type_manager.refs[
model.Reference(
name=response_type_manager.root_name,
type=common_rust.HashMapResponse,
)
] = root_dict
else:
response_type_manager.set_models(
response_types
)
elif response_def["type"] == "string":
(root_dt, _) = openapi_parser.parse(response_def)
if not root_dt:
raise RuntimeError(
"Response data can not be processed"
)
field = common_rust.StructField(
local_name="dummy",
remote_name="dummy",
data_type=response_type_manager.convert_model(
root_dt
),
is_optional=False,
)
tuple_struct = common_rust.TupleStruct(
name=response_type_manager.root_name
)
tuple_struct.tuple_fields.append(field)
response_type_manager.refs[
model.Reference(
name=response_type_manager.root_name,
type=common_rust.TupleStruct,
)
] = tuple_struct
elif (
response_def["type"] == "array"
and "items" in response_def
):
(_, response_types) = openapi_parser.parse(
response_def["items"]
)
response_type_manager.set_models(response_types)
else:
return
additional_imports.update(response_type_manager.get_imports())
mod_path = common.get_rust_types_mod_path(
args.service_type,
args.api_version,
args.alternative_module_path or path,
)
mod_path.append("response")
# Get basic information about response
if method.upper() != "HEAD":
context = {
"operation_id": operation_id,
"operation_type": spec.get(
@@ -451,17 +343,170 @@ class RustTypesGenerator(BaseGenerator):
"additional_imports": additional_imports,
}
mod_path = mod_path[1:]
yield from self.generate_type_mod(
args,
openapi_parser,
response_type_manager,
context,
class_name,
mod_path,
target_dir,
mod_name,
resource_name,
spec,
)
def generate_type_mod(
self,
args,
openapi_parser,
response_type_manager,
context,
class_name,
mod_path,
target_dir,
mod_name,
resource_name,
spec,
) -> Generator[tuple[list[str], str, str, str]]:
"""Generate the type module"""
responses = common.get_response_candidates(
spec["responses"],
args.response_key or resource_name,
args.action_name,
)
for response, schema_ext in responses:
response_suffix: str | None = None
local_response_type_manager = copy.deepcopy(response_type_manager)
local_mod_path = mod_path.copy()
if not response:
continue
if schema_ext:
min_ver = schema_ext.get("min-ver")
if min_ver:
response_suffix = min_ver.replace(".", "")
if "kind-suffix" in schema_ext:
kind_suffix = schema_ext["kind-suffix"]
if response_suffix:
response_suffix = f"{response_suffix}_{kind_suffix}"
else:
response_suffix = kind_suffix
if args.response_list_item_key:
response_key = args.response_list_item_key
elif args.response_key:
response_key = (
args.response_key if args.response_key != "null" else None
)
else:
response_key = resource_name
response_def, _ = common.find_resource_schema(
response, None, response_key
)
if not response_def and response.get("type") == "string":
response_def = response
if response_def:
if response_def.get("type", "object") == "object" or (
# BS metadata is defined with type: ["object",
# "null"]
isinstance(response_def.get("type"), list)
and "object" in response_def["type"]
):
(root, response_types) = openapi_parser.parse(response_def)
if isinstance(root, model.Dictionary):
value_type: (
common_rust.BasePrimitiveType
| common_rust.BaseCombinedType
| common_rust.BaseCompoundType
| None
) = None
try:
value_type = (
local_response_type_manager.convert_model(
root.value_type
)
)
except Exception:
# In rare cases we can not convert
# value_type since it depends on different
# types. We are here in the output
# simplification, so just downcast it to
# JsonValue (what is anyway our goal)
value_type = common_rust.JsonValue()
root_dict = common_rust.HashMapResponse(
name=local_response_type_manager.root_name,
value_type=value_type,
)
local_response_type_manager.refs[
model.Reference(
name=local_response_type_manager.root_name,
type=common_rust.HashMapResponse,
)
] = root_dict
else:
local_response_type_manager.set_models(response_types)
elif response_def["type"] == "string":
(root_dt, _) = openapi_parser.parse(response_def)
if not root_dt:
raise RuntimeError(
"Response data can not be processed"
)
field = common_rust.StructField(
local_name="dummy",
remote_name="dummy",
data_type=local_response_type_manager.convert_model(
root_dt
),
is_optional=False,
)
tuple_struct = common_rust.TupleStruct(
name=local_response_type_manager.root_name
)
tuple_struct.tuple_fields.append(field)
local_response_type_manager.refs[
model.Reference(
name=local_response_type_manager.root_name,
type=common_rust.TupleStruct,
)
] = tuple_struct
elif (
response_def["type"] == "array" and "items" in response_def
):
(_, response_types) = openapi_parser.parse(
response_def["items"]
)
local_response_type_manager.set_models(response_types)
context.update(
{
"additional_imports": local_response_type_manager.get_imports(),
"response_type_manager": local_response_type_manager,
}
)
work_dir = Path(
target_dir, "rust", "types", args.service_type, "src"
)
impl_path = Path(work_dir, "/".join(mod_path), f"{mod_name}.rs")
# Generate methods for the GET resource command
final_mod_name = (
mod_name
if not response_suffix
else f"{mod_name}_{response_suffix}"
)
impl_path = Path(
work_dir, "/".join(mod_path[1:]), f"{final_mod_name}.rs"
)
# Generate type definitions for the resource command
self._render_command(context, "rust_types/impl.rs.j2", impl_path)
self._format_code(impl_path)
yield (mod_path, mod_name, "response", class_name)
yield (mod_path[1:], final_mod_name, "response", class_name)
def generate_mod(
self, target_dir, mod_path, mod_list, url, resource_name, service_type
+24 -6
View File
@@ -189,10 +189,20 @@ impl {{ target_class_name }}Command {
{%- elif operation_type in ["show"] %}
{#- Show/get implementation #}
{%- if find_present %}
op.output_single::<{{ response_class_name }}>(find_data)?;
{% if response_mod_candidates|length > 0 %}
op.output_single::<response::{{ response_mod_candidates[0].0 }}::{{ response_class_name }}>(find_data.clone())
{%- for mod in response_mod_candidates[1:] %}
.or_else(|_| op.output_single::<response::{{ mod.0 }}::{{ response_class_name }}>(find_data.clone()))
{%- endfor %}?;
{%- endif %}
{%- else %}
let data = ep.query_async(client).await?;
op.output_single::<{{ response_class_name }}>(data)?;
let data: serde_json::Value = ep.query_async(client).await?;
{% if response_mod_candidates|length > 0 %}
op.output_single::<response::{{ response_mod_candidates[0].0 }}::{{ response_class_name }}>(data.clone())
{%- for mod in response_mod_candidates[1:] %}
.or_else(|_| op.output_single::<response::{{ mod.0 }}::{{ response_class_name }}>(data.clone()))
{%- endfor %}?;
{%- endif %}
{%- endif %}
{%- elif operation_type == "create" %}
@@ -212,10 +222,18 @@ impl {{ target_class_name }}Command {
{%- elif result_is_list %}
let data: Vec<serde_json::Value> = ep.query_async(client).await?;
op.output_list::<{{ response_class_name }}>(data)?;
op.output_list::<response::{{ response_mod_candidates[0].0 }}::{{ response_class_name }}>(data.clone())
{%- for mod in response_mod_candidates[1:] %}
.or_else(|_| op.output_list::<response::{{ mod.0 }}::{{ response_class_name }}>(data.clone()))
{%- endfor %}?;
{%- else %}
let data = ep.query_async(client).await?;
op.output_single::<{{ response_class_name }}>(data)?;
let data: serde_json::Value = ep.query_async(client).await?;
{% if response_mod_candidates|length > 0 %}
op.output_single::<response::{{ response_mod_candidates[0].0 }}::{{ response_class_name }}>(data.clone())
{%- for mod in response_mod_candidates[1:] %}
.or_else(|_| op.output_single::<response::{{ mod.0 }}::{{ response_class_name }}>(data.clone()))
{%- endfor %}?;
{%- endif %}
{%- endif %}
{%- elif operation_type not in ["delete", "download", "upload", "json"] %}
@@ -1,3 +1,8 @@
{#- Create operation handling #}
let data = ep.query_async(client).await?;
op.output_single::<{{ response_class_name }}>(data)?;
let data: serde_json::Value = ep.query_async(client).await?;
{% if response_mod_candidates|length > 0 %}
op.output_single::<response::{{ response_mod_candidates[0].0 }}::{{ response_class_name }}>(data.clone())
{%- for mod in response_mod_candidates[1:] %}
.or_else(|_| op.output_single::<response::{{ mod.0 }}::{{ response_class_name }}>(data.clone()))
{%- endfor %}?;
{%- endif %}
@@ -6,9 +6,19 @@
{%- else %}
let data: Vec<serde_json::Value> = ep.query_async(client).await?;
{%- endif %}
op.output_list::<{{ response_class_name }}>(data)?;
{% if response_mod_candidates|length > 0 %}
op.output_list::<response::{{ response_mod_candidates[0].0 }}::{{ response_class_name }}>(data.clone())
{%- for mod in response_mod_candidates[1:] %}
.or_else(|_| op.output_list::<response::{{ mod.0 }}::{{ response_class_name }}>(data.clone()))
{%- endfor %}?;
{%- endif %}
{%- else %}
let data = ep.query_async(client).await?;
op.output_single::<{{ response_class_name }}>(data)?;
let data: serde_json::Value = ep.query_async(client).await?;
{% if response_mod_candidates|length > 0 %}
op.output_single::<response::{{ response_mod_candidates[0].0 }}::{{ response_class_name }}>(data.clone())
{%- for mod in response_mod_candidates[1:] %}
.or_else(|_| op.output_single::<response::{{ mod.0 }}::{{ response_class_name }}>(data.clone()))
{%- endfor %}?;
{%- endif %}
{%- endif %}
@@ -1,3 +1,8 @@
{#- List operation #}
let data: serde_json::Value = ep.query_async(client).await?;
op.output_single::<{{ response_class_name }}>(data)?;
{% if response_mod_candidates|length > 0 %}
op.output_single::<response::{{ response_mod_candidates[0].0 }}::{{ response_class_name }}>(data)
{%- for mod in response_mod_candidates[1:] %}
.or_else(|_| op.output_single::<response::{{ mod.0 }}::{{ response_class_name }}>(data.clone()))
{%- endfor %}?;
{%- endif %}
+483 -100
View File
@@ -10,11 +10,10 @@
# License for the specific language governing permissions and limitations
# under the License.
#
from collections import OrderedDict
import json
from unittest import TestCase
from collections import OrderedDict
from typing import Any
from unittest import TestCase
from codegenerator import common
@@ -171,103 +170,7 @@ class TestSortSchema(TestCase):
)
class TestFindResponseSchema(TestCase):
FOO = {"foo": {"type": "string"}}
# def setUp(self):
# super().setUp()
# logging.basicConfig(level=logging.DEBUG)
def test_find_with_single_candidate(self):
responses = {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {**self.FOO},
}
}
}
}
}
self.assertEqual(
responses["200"]["content"]["application/json"]["schema"],
common.find_response_schema(responses, "foo"),
)
def test_find_with_list(self):
responses = {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"foos": {"type": "array", "items": self.FOO}
},
}
}
}
}
}
self.assertEqual(
responses["200"]["content"]["application/json"]["schema"],
common.find_response_schema(responses, "foo"),
)
def test_find_correct_action(self):
foo_action = {
"type": "string",
"x-openstack": {"action-name": "foo-action"},
}
bar_action = {
"type": "string",
"x-openstack": {"action-name": "bar-action"},
}
responses: dict[str, Any] = {
"200": {
"content": {
"application/json": {
"schema": {"type": "object", "properties": self.FOO}
}
}
},
"204": {
"content": {
"application/json": {
"schema": {"oneOf": [foo_action, bar_action]}
}
}
},
}
self.assertEqual(
foo_action,
common.find_response_schema(responses, "foo", "foo-action"),
)
self.assertEqual(
bar_action,
common.find_response_schema(responses, "foo", "bar-action"),
)
self.assertIsNone(
common.find_response_schema(responses, "foo", "baz-action")
)
self.assertEqual(
responses["200"]["content"]["application/json"]["schema"],
common.find_response_schema(responses, "foo"),
)
def test_no_candidates_returns_root(self):
responses = {
"200": {
"content": {"application/json": {"schema": self.FOO["foo"]}}
}
}
self.assertEqual(
responses["200"]["content"]["application/json"]["schema"],
common.find_response_schema(responses, "foo"),
)
class TestPlural(TestCase):
def test_plural(self):
map = {
"policy": "policies",
@@ -300,3 +203,483 @@ class TestFindResponseSchema(TestCase):
}
for singular, plural in map.items():
self.assertEqual(singular, common.get_singular_form(plural))
class TestCheckMvRange(TestCase):
def test_mv_range_filter(self):
self.assertEqual(
True, common.ensure_microversion_bounds(None, {"min-ver": "2.0"})
)
self.assertEqual(True, common.ensure_microversion_bounds(None, {}))
self.assertEqual(
False,
common.ensure_microversion_bounds("2.1", {"min-ver": "2.100"}),
)
self.assertEqual(
True,
common.ensure_microversion_bounds(
"2.1", {"min-ver": "2.0", "max-ver": "2.1"}
),
)
self.assertEqual(
True,
common.ensure_microversion_bounds(
"2.0", {"min-ver": "2.0", "max-ver": "2.1"}
),
)
self.assertEqual(
False,
common.ensure_microversion_bounds(
"2.2", {"min-ver": "2.0", "max-ver": "2.1"}
),
)
class TestGetResponseCandidates(TestCase):
FOO = {"foo": {"type": "string"}}
def test_find_with_single_candidate(self):
responses = {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {**self.FOO},
}
}
}
}
}
self.assertEqual(
[
(
responses["200"]["content"]["application/json"]["schema"],
None,
)
],
common.get_response_candidates(responses, "foo"),
)
def test_find_with_list(self):
responses = {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"fooes": {"type": "array", "items": self.FOO}
},
}
}
}
}
}
self.assertEqual(
[
(
responses["200"]["content"]["application/json"]["schema"],
None,
)
],
common.get_response_candidates(responses, "foo"),
)
def test_no_candidates_returns_root(self):
responses = {
"200": {
"content": {"application/json": {"schema": self.FOO["foo"]}}
}
}
self.assertEqual(
[
(
responses["200"]["content"]["application/json"]["schema"],
None,
)
],
common.get_response_candidates(responses, "foo"),
)
def test_server_create_response(self):
s1 = {
"type": "object",
"properties": {"reservation_id": {"type": "string"}},
}
s2 = {"type": "object", "properties": {"server": {"type": "string"}}}
rsp_def = {
"201": {
"content": {
"application/json": {
"schema": {"oneOf": [s1, s2], "type": "object"}
}
}
}
}
candidates = common.get_response_candidates(rsp_def, "server", None)
self.assertIn((s1, {"kind-suffix": "a"}), candidates)
self.assertIn((s2, {"kind-suffix": "b"}), candidates)
def test_server_show_response(self):
srv1 = {
"type": "object",
"properties": {"server": {"type": "string"}},
"x-openstack": {"min-ver": "2.0", "max-ver": "2.1"},
}
srv2_a = {"type": "object", "properties": {"foo": {"type": "string"}}}
srv2_b = {"type": "object", "properties": {"bar": {"type": "bool"}}}
rsp_def = {
"200": {
"content": {
"application/json": {
"schema": {
"oneOf": [
srv1,
{
"type": "object",
"properties": {
"server": {"oneOf": [srv2_a, srv2_b]}
},
"x-openstack": {"min-ver": "2.100"},
},
]
}
}
}
}
}
candidates = common.get_response_candidates(rsp_def, "server")
self.assertIn((srv1, {"min-ver": "2.0", "max-ver": "2.1"}), candidates)
self.assertIn(
(
{
"type": "object",
"properties": {"server": srv2_a},
"x-openstack": {"min-ver": "2.100"},
},
{"min-ver": "2.100", "kind-suffix": "a"},
),
candidates,
)
self.assertIn(
(
{
"type": "object",
"properties": {"server": srv2_b},
"x-openstack": {"min-ver": "2.100"},
},
{"min-ver": "2.100", "kind-suffix": "b"},
),
candidates,
)
candidates = common.get_response_candidates(
rsp_def, "server", None, "2.101"
)
self.assertIn(
(
{
"type": "object",
"properties": {"server": srv2_a},
"x-openstack": {"min-ver": "2.100"},
},
{"min-ver": "2.100", "kind-suffix": "a"},
),
candidates,
)
self.assertIn(
(
{
"type": "object",
"properties": {"server": srv2_b},
"x-openstack": {"min-ver": "2.100"},
},
{"min-ver": "2.100", "kind-suffix": "b"},
),
candidates,
)
self.assertEqual(2, len(candidates))
candidates = common.get_response_candidates(
rsp_def, "server", None, "2.0"
)
self.assertIn((srv1, {"min-ver": "2.0", "max-ver": "2.1"}), candidates)
self.assertEqual(1, len(candidates))
def test_compute_service_update_response(self):
srv1 = {
"type": "object",
"properties": {"service": {"type": "string"}},
"x-openstack": {"min-ver": "2.0", "max-ver": "2.1"},
}
srv2_a = {"properties": {"foo": {"type": "string"}}}
srv2_b = {"properties": {"bar": {"type": "bool"}}}
rsp_def = {
"200": {
"content": {
"application/json": {
"schema": {
"oneOf": [
srv1,
{
"type": "object",
"properties": {
"service": {
"oneOf": [srv2_a, srv2_b],
"type": "object",
}
},
"x-openstack": {"min-ver": "2.100"},
},
]
}
}
}
}
}
candidates = common.get_response_candidates(rsp_def, "service")
self.assertIn((srv1, {"min-ver": "2.0", "max-ver": "2.1"}), candidates)
self.assertIn(
(
{
"type": "object",
"properties": {"service": {**srv2_a, "type": "object"}},
"x-openstack": {"min-ver": "2.100"},
},
{"min-ver": "2.100", "kind-suffix": "a"},
),
candidates,
)
self.assertIn(
(
{
"type": "object",
"properties": {"service": {**srv2_b, "type": "object"}},
"x-openstack": {"min-ver": "2.100"},
},
{"min-ver": "2.100", "kind-suffix": "b"},
),
candidates,
)
def test_server_list_detailed_response(self):
srv1 = {
"type": "object",
"properties": {"baz": {"type": "string"}},
"x-openstack": {"min-ver": "2.0", "max-ver": "2.1"},
}
srv2_a = {"type": "object", "properties": {"foo": {"type": "string"}}}
srv2_b = {"type": "object", "properties": {"bar": {"type": "bool"}}}
rsp_def = {
"200": {
"content": {
"application/json": {
"schema": {
"oneOf": [
srv1,
{
"type": "object",
"properties": {
"servers": {
"type": "array",
"items": {
"oneOf": [srv2_a, srv2_b]
},
}
},
"x-openstack": {"min-ver": "2.100"},
},
]
}
}
}
}
}
candidates = common.get_response_candidates(rsp_def, "server")
self.assertIn((srv1, {"min-ver": "2.0", "max-ver": "2.1"}), candidates)
self.assertIn(
(
{
"type": "object",
"properties": {
"servers": {"type": "array", "items": srv2_a}
},
"x-openstack": {"min-ver": "2.100"},
},
{"min-ver": "2.100", "kind-suffix": "a"},
),
candidates,
)
self.assertIn(
(
{
"type": "object",
"properties": {
"servers": {"type": "array", "items": srv2_b}
},
"x-openstack": {"min-ver": "2.100"},
},
{"min-ver": "2.100", "kind-suffix": "b"},
),
candidates,
)
candidates = common.get_response_candidates(
rsp_def, "server", None, "2.101"
)
self.assertIn(
(
{
"type": "object",
"properties": {
"servers": {"type": "array", "items": srv2_a}
},
"x-openstack": {"min-ver": "2.100"},
},
{"min-ver": "2.100", "kind-suffix": "a"},
),
candidates,
)
self.assertIn(
(
{
"type": "object",
"properties": {
"servers": {"type": "array", "items": srv2_b}
},
"x-openstack": {"min-ver": "2.100"},
},
{"min-ver": "2.100", "kind-suffix": "b"},
),
candidates,
)
self.assertEqual(2, len(candidates))
candidates = common.get_response_candidates(
rsp_def, "server", None, "2.0"
)
self.assertIn((srv1, {"min-ver": "2.0", "max-ver": "2.1"}), candidates)
self.assertEqual(1, len(candidates))
def test_get_image_show_candidate(self):
responses = {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": {"type": "string"},
"properties": {
"foo": {"type": ["null", "string"]}
},
"type": "object",
}
}
}
}
}
self.assertEqual(
[
(
responses["200"]["content"]["application/json"]["schema"],
None,
)
],
common.get_response_candidates(responses, "image"),
)
def test_aggregate_action_response(self):
ah1 = {
"type": "object",
"properties": {"aggregate": {"type": "string"}},
"x-openstack": {"min-ver": "2.0", "action-name": "add_host"},
}
ah2 = {
"type": "object",
"properties": {"aggregate": {"type": "string"}},
"x-openstack": {"min-ver": "2.4", "action-name": "add_host"},
}
rh1 = {
"type": "object",
"properties": {"aggregate": {"type": "string"}},
"x-openstack": {"min-ver": "2.0", "action-name": "remove_host"},
}
rh2 = {
"type": "object",
"properties": {"aggregate": {"type": "string"}},
"x-openstack": {"min-ver": "2.4", "action-name": "remove_host"},
}
rsp_def = {
"200": {
"content": {
"application/json": {
"schema": {"oneOf": [ah1, ah2, rh1, rh2]}
}
}
}
}
candidates = common.get_response_candidates(
rsp_def, "server", "add_host"
)
self.assertIn((ah1, ah1["x-openstack"]), candidates)
self.assertIn((ah2, ah2["x-openstack"]), candidates)
self.assertEqual(2, len(candidates))
candidates = common.get_response_candidates(
rsp_def, "server", "add_host", "2.0"
)
self.assertIn((ah1, ah1["x-openstack"]), candidates)
self.assertNotIn((ah2, ah2["x-openstack"]), candidates)
candidates = common.get_response_candidates(
rsp_def, "server", "remove_host"
)
self.assertIn((rh1, rh1["x-openstack"]), candidates)
self.assertIn((rh2, rh2["x-openstack"]), candidates)
self.assertEqual(2, len(candidates))
def test_find_correct_action(self):
foo_action = {
"type": "string",
"x-openstack": {"action-name": "foo-action"},
}
bar_action = {
"type": "string",
"x-openstack": {"action-name": "bar-action"},
}
responses: dict[str, Any] = {
"200": {
"content": {
"application/json": {
"schema": {"type": "object", "properties": self.FOO}
}
}
},
"204": {
"content": {
"application/json": {
"schema": {"oneOf": [foo_action, bar_action]}
}
}
},
}
self.assertIn(
(foo_action, {"action-name": "foo-action"}),
common.get_response_candidates(responses, "foo", "foo-action"),
)
self.assertIn(
(bar_action, {"action-name": "bar-action"}),
common.get_response_candidates(responses, "foo", "bar-action"),
)
self.assertCountEqual(
common.get_response_candidates(responses, "foo", "baz-action"), []
)
self.assertEqual(
[
(
responses["200"]["content"]["application/json"]["schema"],
None,
)
],
common.get_response_candidates(responses, "foo"),
"when no action requested and non-action response found - return it",
)
+18 -10
View File
@@ -13,14 +13,14 @@
import logging
from unittest import TestCase
from jinja2 import Environment
from jinja2 import FileSystemLoader
from jinja2 import select_autoescape
from jinja2 import StrictUndefined
from jinja2 import (
Environment,
FileSystemLoader,
StrictUndefined,
select_autoescape,
)
from codegenerator import base
from codegenerator import model
from codegenerator import rust_cli
from codegenerator import base, model, rust_cli
class TestRustCliResponseManager(TestCase):
@@ -30,7 +30,7 @@ class TestRustCliResponseManager(TestCase):
def test_generate_array_of_array_of_strings(self):
expected_content = """
// Licensed under the Apache License, Version 2.0 (the "License");
// 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
//
@@ -54,10 +54,12 @@ class TestRustCliResponseManager(TestCase):
use clap::Args;
use tracing::info;
use eyre::{OptionExt, WrapErr};
use openstack_sdk::AsyncOpenStack;
use openstack_cli_core::cli::CliArgs;
use openstack_cli_core::output::OutputProcessor;
use openstack_cli_core::error::OpenStackCliError;
use bar::import;
use foo::import;
@@ -72,11 +74,13 @@ pub struct fooCommand {
/// Path parameters
#[command(flatten)]
path: PathParameters,
/// aoaos
///
/// Parameter is an array, may be provided multiple times.
#[arg(action=clap::ArgAction::Append, help_heading = "Body parameters", long, value_name="[String] as JSON", value_parser=openstack_cli_core::common::parse_json)]
foo: Vec<Vec<String>>,
}
/// Query parameters
@@ -84,6 +88,7 @@ pub struct fooCommand {
struct QueryParameters {
}
/// Path parameters
#[derive(Args)]
struct PathParameters {
@@ -112,13 +117,14 @@ impl fooCommand {
.build()
.map_err(|x| OpenStackCliError::EndpointBuild(x.to_string()))?;
let data = ep.query_async(client).await?;
op.output_single::<rsp>(data)?;
let data: serde_json::Value = ep.query_async(client).await?;
// Show command specific hints
op.show_command_hint()?;
Ok(())
}
}
"""
schema = {
"type": "object",
@@ -172,7 +178,9 @@ impl fooCommand {
response_class_name="rsp",
result_is_list=False,
resource_key="srv.foo",
response_mod_candidates=[],
)
print(content)
self.assertEqual(
"".join([x.rstrip() for x in expected_content.split()]),
"".join([x.rstrip() for x in content.split()]),
+6
View File
@@ -645,6 +645,7 @@ resources:
sdk_mod_name: list
rust-sdk:
module_name: list
response_key: keypairs
response_list_item_key: keypair
show:
operation_id: os-keypairs/id:get
@@ -657,6 +658,7 @@ resources:
sdk_mod_name: get
rust-sdk:
module_name: get
response_key: keypair
spec_file: wrk/openapi_specs/compute/v2.yaml
compute.limit:
api_version: v2
@@ -958,6 +960,7 @@ resources:
sdk_mod_name: list_detailed
rust-sdk:
module_name: list_detailed
#response_key: servers
rust-tui:
module_name: list_detailed
lock:
@@ -1971,6 +1974,7 @@ resources:
sdk_mod_name: list
rust-sdk:
module_name: list
response_key: tenant_usages
show:
operation_id: os-simple-tenant-usage/id:get
operation_type: show
@@ -1979,8 +1983,10 @@ resources:
cli_full_command: simple-tenant-usage show
module_name: show
sdk_mod_name: get
response_key: tenant_usage
rust-sdk:
module_name: get
response_key: tenant_usage
spec_file: wrk/openapi_specs/compute/v2.yaml
compute.version:
api_version: v2
+6
View File
@@ -11,11 +11,17 @@
name: "."
virtualenv: "{{ ansible_user_dir }}/.venv"
- name: Install openstack-codegenerator from sources
ansible.builtin.get_url:
url: "https://releases.openstack.org/constraints/upper/master"
dest: "{{ ansible_user_dir }}/upper-constraints.txt"
- name: Install additional dependencies from sources
ansible.builtin.pip:
chdir: "{{ zuul.projects[zj_dep.project].src_dir }}"
name: "{{ zj_dep.name }}"
virtualenv: "{{ ansible_user_dir }}/.venv"
extra_args: "-c {{ ansible_user_dir}}/upper-constraints.txt"
loop: "{{ install_additional_projects }}"
loop_control:
loop_var: zj_dep