Consume decorated Keystone methods

We started decorating Keystone with schema validation similarly to other
services. Since Keystone uses Flask and thus we do not use base
processing split the processing into separate method and call it.

Change-Id: I4e70b85bb16a40cb3dff0d5ddc6004aabe0d1c62
This commit is contained in:
Artem Goncharov 2024-07-05 20:17:07 +02:00
parent fdf8bda6be
commit d038d9d8d1
No known key found for this signature in database
GPG Key ID: 334C245686980408
3 changed files with 187 additions and 106 deletions

View File

@ -647,88 +647,20 @@ class OpenStackServerSourceBase:
if action_name: if action_name:
operation_name = action_name operation_name = action_name
# Unwrap operation decorators to access all properties (
f = func query_params_versions,
while hasattr(f, "__wrapped__"): body_schemas,
closure = inspect.getclosurevars(f) response_body_schema,
closure_locals = closure.nonlocals expected_errors,
min_ver = closure_locals.get("min_version", start_version) ) = self._process_decorators(
if min_ver and not isinstance(min_ver, str): func,
min_ver = min_ver.get_string() path_resource_names,
max_ver = closure_locals.get("max_version", end_version) openapi_spec,
if max_ver and not isinstance(max_ver, str): mode,
max_ver = max_ver.get_string() start_version,
end_version,
if "errors" in closure_locals: action_name,
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", {}),
)
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}"
body_schemas.append(ref_name)
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", {}),
)
ser_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.append((obj, min_ver, max_ver))
f = f.__wrapped__
if hasattr(func, "_wsme_definition"): if hasattr(func, "_wsme_definition"):
fdef = getattr(func, "_wsme_definition") fdef = getattr(func, "_wsme_definition")
@ -786,6 +718,8 @@ class OpenStackServerSourceBase:
operation_name, operation_name,
) )
if ser_schema and not response_body_schema:
response_body_schema = ser_schema
responses_spec = operation_spec.responses responses_spec = operation_spec.responses
for error in expected_errors: for error in expected_errors:
responses_spec.setdefault(str(error), dict(description="Error")) responses_spec.setdefault(str(error), dict(description="Error"))
@ -830,7 +764,7 @@ class OpenStackServerSourceBase:
if not action_name if not action_name
else f"Response of the {operation_spec.operationId}:{action_name} action" else f"Response of the {operation_spec.operationId}:{action_name} action"
), # noqa ), # noqa
schema_def=ser_schema, schema_def=response_body_schema,
action_name=action_name, action_name=action_name,
) )
@ -910,7 +844,8 @@ class OpenStackServerSourceBase:
**copy.deepcopy(spec["items"]) **copy.deepcopy(spec["items"])
) )
else: else:
raise RuntimeError("Error") param_attrs["schema"] = TypeSchema(**copy.deepcopy(spec))
param_attrs["description"] = spec.get("description")
if min_ver: if min_ver:
os_ext = param_attrs.setdefault("x-openstack", {}) os_ext = param_attrs.setdefault("x-openstack", {})
os_ext["min-ver"] = min_ver os_ext["min-ver"] = min_ver
@ -1218,6 +1153,113 @@ class OpenStackServerSourceBase:
response_code = "200" response_code = "200"
return [response_code] 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,
):
"""Extract schemas from the decorated method."""
# Unwrap operation decorators to access all properties
expected_errors: list[str] = []
body_schemas: list[str] = []
query_params_versions: list[tuple] = []
response_body_schema: dict | None = None
f = func
while hasattr(f, "__wrapped__"):
closure = inspect.getclosurevars(f)
closure_locals = closure.nonlocals
min_ver = closure_locals.get("min_version", start_version)
if min_ver and not isinstance(min_ver, str):
min_ver = min_ver.get_string()
max_ver = closure_locals.get("max_version", end_version)
if max_ver and not isinstance(max_ver, str):
max_ver = max_ver.get_string()
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", {}),
)
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}"
body_schemas.append(ref_name)
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.append((obj, min_ver, max_ver))
f = f.__wrapped__
return (
query_params_versions,
body_schemas,
response_body_schema,
expected_errors,
)
def _convert_wsme_to_jsonschema(body_spec): def _convert_wsme_to_jsonschema(body_spec):
"""Convert WSME type description to JsonSchema""" """Convert WSME type description to JsonSchema"""

View File

@ -10,6 +10,7 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
# #
import copy
import inspect import inspect
from multiprocessing import Process from multiprocessing import Process
import logging import logging
@ -35,6 +36,7 @@ from codegenerator.openapi.keystone_schemas import role
from codegenerator.openapi.keystone_schemas import service from codegenerator.openapi.keystone_schemas import service
from codegenerator.openapi.keystone_schemas import user from codegenerator.openapi.keystone_schemas import user
from codegenerator.openapi.utils import merge_api_ref_doc from codegenerator.openapi.utils import merge_api_ref_doc
from codegenerator.openapi.utils import rst_to_md
class KeystoneGenerator(OpenStackServerSourceBase): class KeystoneGenerator(OpenStackServerSourceBase):
@ -151,10 +153,6 @@ class KeystoneGenerator(OpenStackServerSourceBase):
for route in self.router.iter_rules(): for route in self.router.iter_rules():
if route.rule.startswith("/static"): if route.rule.startswith("/static"):
continue continue
# if not route.rule.startswith("/v3/domains"):
# continue
if "/credentials/OS-EC2" in route.rule:
continue
self._process_route(route, openapi_spec) self._process_route(route, openapi_spec)
@ -358,25 +356,61 @@ class KeystoneGenerator(OpenStackServerSourceBase):
path, path,
method, method,
) )
if method in ["PUT", "POST", "PATCH"]: doc = inspect.getdoc(func)
# This is clearly a modification operation but we know nothing about request if doc and not operation_spec.description:
schema_name = ( doc = rst_to_md(doc)
"".join([x.title() for x in path_resource_names]) operation_spec.description = LiteralScalarString(doc)
+ method.title()
+ "Request"
)
(schema_ref, mime_type) = self._get_schema_ref( query_params_versions = []
body_schemas = []
expected_errors = ["404"]
response_code = None
start_version = None
end_version = None
deser_schema: dict = {}
ser_schema: dict = {}
(
query_params_versions,
body_schemas,
ser_schema,
expected_errors,
) = self._process_decorators(
func,
path_resource_names,
openapi_spec, openapi_spec,
schema_name, method,
description=f"Request of the {operation_spec.operationId} operation", start_version,
end_version,
None,
) )
if schema_ref: if query_params_versions:
content = operation_spec.requestBody = {"content": {}} so = sorted(
content["content"][mime_type] = { query_params_versions,
"schema": {"$ref": schema_ref} key=lambda d: (
} tuple(map(int, d[1].split("."))) if d[1] else (0, 0)
),
)
for data, min_ver, max_ver in so:
self.process_query_parameters(
openapi_spec,
operation_spec,
path_resource_names,
data,
min_ver,
max_ver,
)
if method in ["PUT", "POST", "PATCH"]:
self.process_body_parameters(
openapi_spec,
operation_spec,
path_resource_names,
body_schemas,
None,
method,
)
responses_spec = operation_spec.responses responses_spec = operation_spec.responses
# Errors # Errors
@ -420,6 +454,7 @@ class KeystoneGenerator(OpenStackServerSourceBase):
openapi_spec, openapi_spec,
schema_name, schema_name,
description=f"Response of the {operation_spec.operationId} operation", description=f"Response of the {operation_spec.operationId} operation",
schema_def=ser_schema,
) )
if schema_ref: if schema_ref:
@ -486,7 +521,11 @@ class KeystoneGenerator(OpenStackServerSourceBase):
# Default # Default
(ref, mime_type) = super()._get_schema_ref( (ref, mime_type) = super()._get_schema_ref(
openapi_spec, name, description, action_name=action_name openapi_spec,
name,
description,
schema_def=schema_def,
action_name=action_name,
) )
return (ref, mime_type) return (ref, mime_type)

View File

@ -170,7 +170,7 @@ def _get_schema_ref(
TypeSchema(**APPLICATION_CREDENTIAL_CREATE_SCHEMA), TypeSchema(**APPLICATION_CREDENTIAL_CREATE_SCHEMA),
) )
ref = f"#/components/schemas/{name}" ref = f"#/components/schemas/{name}"
elif name in "UsersApplication_CredentialsPostResponse": elif name == "UsersApplication_CredentialsPostResponse":
openapi_spec.components.schemas.setdefault( openapi_spec.components.schemas.setdefault(
name, name,
TypeSchema(**APPLICATION_CREDENTIAL_CREATE_RESPONSE_SCHEMA), TypeSchema(**APPLICATION_CREDENTIAL_CREATE_RESPONSE_SCHEMA),