diff --git a/codegenerator/common/__init__.py b/codegenerator/common/__init__.py index 10bcffc..dc859e0 100644 --- a/codegenerator/common/__init__.py +++ b/codegenerator/common/__init__.py @@ -152,13 +152,14 @@ def find_resource_schema( """ try: if "type" not in schema: - # Response of server create is a server or reservation_id - # if "oneOf" in schema: - # kinds = {} - # for kind in schema["oneOf"]: - # kinds.update(kind) - # schema["type"] = kinds["type"] - if "allOf" in schema: + # Response of server create is a server or reservation_id, + # identity.domain.group[config] is oneOf also + if "oneOf" in schema: + kinds = {} + for kind in schema["oneOf"]: + kinds.update(kind) + schema["type"] = kinds["type"] + elif "allOf" in schema: # {'allOf': [ # {'type': 'integer', 'minimum': 0}, # {'default': 0}] @@ -501,6 +502,18 @@ def get_resource_names_from_url(path: str): "delete_key", ]: path_resource_names = ["qos_spec"] + if path == "/v3/domains/{domain_id}/config": + path_resource_names = ["domain", "config"] + elif path == "/v3/domains/{domain_id}/config/{group}": + path_resource_names = ["domain", "config", "group"] + elif path == "/v3/domains/{domain_id}/config/{group}/{option}": + path_resource_names = ["domain", "config", "group", "option"] + elif path == "/v3/domains/config/default": + path_resource_names = ["domain", "config"] + elif path == "/v3/domains/config/{group}/default": + path_resource_names = ["domain", "config", "group"] + elif path == "/v3/domains/config/{group}/{option}/default": + path_resource_names = ["domain", "config", "group", "option"] if path == "/v2.0/ports/{port_id}/bindings/{id}/activate": path_resource_names = ["port", "binding"] diff --git a/codegenerator/common/rust.py b/codegenerator/common/rust.py index 2537713..04921b9 100644 --- a/codegenerator/common/rust.py +++ b/codegenerator/common/rust.py @@ -103,12 +103,16 @@ class String(BasePrimitiveType): class JsonValue(BasePrimitiveType): type_hint: str = "Value" - imports: set[str] = set(["serde_json::Value"]) builder_macros: set[str] = set(["setter(into)"]) def get_sample(self): return "json!({})" + @property + def imports(self): + imports: set[str] = set(["serde_json::Value"]) + return imports + class Option(BaseCombinedType): base_type: str = "Option" diff --git a/codegenerator/metadata.py b/codegenerator/metadata.py index 33af1e2..834ce1f 100644 --- a/codegenerator/metadata.py +++ b/codegenerator/metadata.py @@ -326,6 +326,32 @@ class MetadataGenerator(BaseGenerator): operation_key = "create" elif method == "patch": operation_key = "update" + if ( + args.service_type == "identity" + and resource_name + in [ + "domain/config", + "domain/config/group", + "domain/config/group/option", + ] + and path.endswith("/default") + and method == "get" + ): + operation_key = "default" + + if ( + args.service_type == "identity" + and resource_name + in [ + "domain/config", + "domain/config/group", + "domain/config/group/option", + ] + and path.endswith("/default") + and method == "head" + ): + # No need in HEAD defaults + continue if operation_key in resource_model: raise RuntimeError("Operation name conflict") @@ -838,6 +864,15 @@ def post_process_identity_operation( operation.targets["rust-cli"].response_key = "role_inferences" operation.targets["rust-sdk"].response_key = "role_inferences" + if resource_name == "domain/config/group": + operation.targets["rust-sdk"].response_key = "config" + if "rust-cli" in operation.targets: + operation.targets["rust-cli"].response_key = "config" + elif resource_name == "domain/config/group/option": + operation.targets["rust-sdk"].response_key = "config" + if "rust-cli" in operation.targets: + operation.targets["rust-cli"].response_key = "config" + if "rust-cli" in operation.targets: if "auth/catalog" == resource_name: operation.targets["rust-cli"].cli_full_command = operation.targets[ diff --git a/codegenerator/openapi/base.py b/codegenerator/openapi/base.py index 2faa574..f88d2ea 100644 --- a/codegenerator/openapi/base.py +++ b/codegenerator/openapi/base.py @@ -31,7 +31,7 @@ from ruamel.yaml import YAML from wsme import types as wtypes -VERSION_RE = re.compile(r"[Vv][0-9.]*") +VERSION_RE = re.compile(r"[Vv][0-9\.]*") def get_referred_type_data(func, name: str): @@ -1129,7 +1129,7 @@ class OpenStackServerSourceBase: def _get_tags_for_url(self, url): """Return Tag (group) name based on the URL""" # Drop version prefix - url = re.sub(r"^(/v[0-9.]*/)", "/", url) + url = re.sub(r"^(/v[0-9\.]*/)", "/", url) for k, v in self.URL_TAG_MAP.items(): if url.startswith(k): diff --git a/codegenerator/openapi/keystone.py b/codegenerator/openapi/keystone.py index 635034e..5cfd47f 100644 --- a/codegenerator/openapi/keystone.py +++ b/codegenerator/openapi/keystone.py @@ -39,7 +39,10 @@ from codegenerator.openapi.utils import merge_api_ref_doc class KeystoneGenerator(OpenStackServerSourceBase): URL_TAG_MAP = { - "/versions": "version", + "/domains/config": "domain-configuration", + "/domains/{domain_id}/config": "domain-configuration", + "/domains/{domain_id}/groups/{group_id}/roles": "roles", + "/domains/{domain_id}/users/{user_id}/roles": "roles", } RESOURCE_MODULES = [ @@ -178,10 +181,9 @@ class KeystoneGenerator(OpenStackServerSourceBase): if hasattr(view, "view_class"): controller = view.view_class - path = "" - path_elements = [] + path: str = "" + path_elements: list[str] = [] operation_spec = None - tag_name = None for part in route.rule.split("/"): if not part: @@ -190,18 +192,9 @@ class KeystoneGenerator(OpenStackServerSourceBase): param = part.strip("<>").split(":") path_elements.append("{" + param[-1] + "}") else: - if not tag_name and part != "" and part != "v3": - tag_name = part path_elements.append(part) - if not tag_name: - tag_name = "versions" - path = "/" + "/".join(path_elements) - if tag_name not in [x["name"] for x in openapi_spec.tags]: - openapi_spec.tags.append( - {"name": tag_name, "description": LiteralScalarString("")} - ) # Get rid of /v3 for further processing path_elements = path_elements[1:] @@ -210,14 +203,13 @@ class KeystoneGenerator(OpenStackServerSourceBase): # parameter before adding new params path_params: list[ParameterSchema] = [] path_resource_names: list[str] = [] + operation_tags = self._get_tags_for_url(path) for path_element in path_elements: if "{" in path_element: param_name = path_element.strip("{}") global_param_name = ( "_".join(path_resource_names) + f"_{param_name}" ) - # if global_param_name == "_project_id": - # global_param_name = "project_id" param_ref_name = f"#/components/parameters/{global_param_name}" # Ensure reference to the param is in the path_params if param_ref_name not in [ @@ -249,12 +241,17 @@ class KeystoneGenerator(OpenStackServerSourceBase): else: rn = rn.rstrip("s") path_resource_names[-1] = rn - if path == "/v3/domains/{domain_id}/config/{group}": - path_resource_names.append("group") - elif path == "/v3/domains/config/{group}/{option}/default": - path_resource_names.append("group") - elif path == "/v3/domains/{domain_id}/config/{group}/{option}": - path_resource_names.extend(["group", "option"]) + # Hack resource element names for domain configs + if path in [ + "/v3/domains/config/{group}/default", + "/v3/domains/{domain_id}/config/{group}", + ]: + path_resource_names = ["domains", "config", "group"] + elif path in [ + "/v3/domains/config/{group}/{option}/default", + "/v3/domains/{domain_id}/config/{group}/{option}", + ]: + path_resource_names = ["domains", "config", "group", "option"] path_spec = openapi_spec.paths.setdefault( path, PathSchema(parameters=path_params) @@ -332,8 +329,8 @@ class KeystoneGenerator(OpenStackServerSourceBase): operation_spec.description = LiteralScalarString( doc or f"{method} operation on {path}" ) - if tag_name and tag_name not in operation_spec.tags: - operation_spec.tags.append(tag_name) + operation_spec.tags.extend(operation_tags) + operation_spec.tags = list(set(operation_spec.tags)) self.process_operation( func, @@ -451,6 +448,11 @@ class KeystoneGenerator(OpenStackServerSourceBase): {"$ref": "#/components/headers/X-Subject-Token"}, ) + # Ensure operation tags are existing + for tag in operation_spec.tags: + if tag not in [x["name"] for x in openapi_spec.tags]: + openapi_spec.tags.append({"name": tag, "description": None}) + self._post_process_operation_hook( openapi_spec, operation_spec, path=path ) diff --git a/codegenerator/openapi/keystone_schemas/domain.py b/codegenerator/openapi/keystone_schemas/domain.py index 14e6ea0..1bf8e3f 100644 --- a/codegenerator/openapi/keystone_schemas/domain.py +++ b/codegenerator/openapi/keystone_schemas/domain.py @@ -13,18 +13,49 @@ from typing import Any -from keystone.resource import schema as ks_schema - +from codegenerator.common.schema import ParameterSchema from codegenerator.common.schema import TypeSchema DOMAIN_SCHEMA: dict[str, Any] = { "type": "object", + "description": "A domain object", "properties": { "id": {"type": "string", "format": "uuid", "readOnly": True}, - **ks_schema._domain_properties, + "name": { + "type": "string", + "description": "The name of the domain.", + "minLength": 1, + "maxLength": 255, + "pattern": r"[\S]+", + }, + "description": { + "type": "string", + "description": "The description of the domain.", + }, + "enabled": { + "type": "boolean", + "description": "If set to true, domain is enabled. If set to false, domain is disabled.", + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[^,/]*$", + "minLength": 1, + "maxLength": 255, + }, + }, + "options": { + "type": "object", + "description": "The resource options for the domain. Available resource options are immutable.", + }, }, - "additionalProperties": True, +} + +DOMAIN_CONTAINER_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"domain": DOMAIN_SCHEMA}, } DOMAINS_SCHEMA: dict[str, Any] = { @@ -32,55 +63,59 @@ DOMAINS_SCHEMA: dict[str, Any] = { "properties": {"domains": {"type": "array", "items": DOMAIN_SCHEMA}}, } - -DOMAIN_CONFIG_GROUP_LDAP = { - "type": "object", - "description": "An ldap object. Required to set the LDAP group configuration options.", - "properties": { - "url": { - "type": "string", - "format": "uri", - "description": "The LDAP URL.", - }, - "user_tree_dn": { - "type": "string", - "description": "The base distinguished name (DN) of LDAP, from where all users can be reached. For example, ou=Users,dc=root,dc=org.", - }, - }, - "additionalProperties": True, -} - -DOMAIN_CONFIG_GROUP_IDENTITY = { - "type": "object", - "description": "An identity object.", - "properties": { - "driver": { - "type": "string", - "description": "The Identity backend driver.", - }, - }, - "additionalProperties": True, -} - DOMAIN_CONFIGS_SCHEMA: dict[str, Any] = { "type": "object", "properties": { "config": { "type": "object", "description": "A config object.", - "properties": { - "identity": DOMAIN_CONFIG_GROUP_IDENTITY, - "ldap": DOMAIN_CONFIG_GROUP_LDAP, + "additionalProperties": { + "type": "object", + "additionalProperties": True, }, } }, } -DOMAIN_CONFIG_SCHEMA: dict[str, Any] = { - "oneOf": [ - DOMAIN_CONFIG_GROUP_IDENTITY, - DOMAIN_CONFIG_GROUP_LDAP, - ] +DOMAIN_CONFIG_GROUP_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "config": { + "type": "object", + "description": "A config object.", + "additionalProperties": { + "type": "object", + "additionalProperties": True, + }, + "maxProperties": 1, + } + }, +} + +DOMAIN_CONFIG_GROUP_OPTION_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "config": { + "type": "object", + "additionalProperties": True, + "maxProperties": 1, + } + }, +} + +DOMAIN_LIST_PARAMETERS: dict[str, dict] = { + "domain_name": { + "in": "query", + "name": "name", + "description": "Filters the response by a domain name.", + "schema": {"type": "string"}, + }, + "domain_enabled": { + "in": "query", + "name": "enabled", + "description": "If set to true, then only domains that are enabled will be returned, if set to false only that are disabled will be returned. Any value other than 0, including no value, will be interpreted as true.", + "schema": {"type": "boolean"}, + }, } @@ -88,7 +123,19 @@ def _post_process_operation_hook( openapi_spec, operation_spec, path: str | None = None ): """Hook to allow service specific generator to modify details""" - pass + + operationId = operation_spec.operationId + if operationId == "domains:get": + for ( + key, + val, + ) in DOMAIN_LIST_PARAMETERS.items(): + openapi_spec.components.parameters.setdefault( + key, ParameterSchema(**val) + ) + ref = f"#/components/parameters/{key}" + if ref not in [x.ref for x in operation_spec.parameters]: + operation_spec.parameters.append(ParameterSchema(ref=ref)) def _get_schema_ref( @@ -102,24 +149,16 @@ def _get_schema_ref( ref: str # Domains if name in [ + "DomainsPostRequest", "DomainsPostResponse", "DomainGetResponse", + "DomainPatchRequest", "DomainPatchResponse", ]: openapi_spec.components.schemas.setdefault( - "Domain", TypeSchema(**DOMAIN_SCHEMA) + "Domain", TypeSchema(**DOMAIN_CONTAINER_SCHEMA) ) ref = "#/components/schemas/Domain" - elif name == "DomainsPostRequest": - openapi_spec.components.schemas.setdefault( - name, TypeSchema(**ks_schema.domain_create) - ) - ref = f"#/components/schemas/{name}" - elif name == "DomainPatchRequest": - openapi_spec.components.schemas.setdefault( - name, TypeSchema(**ks_schema.domain_update) - ) - ref = f"#/components/schemas/{name}" elif name == "DomainsGetResponse": openapi_spec.components.schemas.setdefault( name, TypeSchema(**DOMAINS_SCHEMA) @@ -143,21 +182,31 @@ def _get_schema_ref( ) ref = "#/components/schemas/DomainConfig" elif name in [ + "DomainsConfigDefaultGroupGetResponse", "DomainsConfigGroupGetResponse", "DomainsConfigGroupPatchRequest", "DomainsConfigGroupPatchResponse", "DomainsConfigGroupPatchResponse", "DomainsConfigGroupPatchResponse", - "DomainsConfigDefaultGroupGetResponse", + ]: + openapi_spec.components.schemas.setdefault( + "DomainConfigGroup", + TypeSchema(**DOMAIN_CONFIG_GROUP_SCHEMA), + ) + ref = "#/components/schemas/DomainConfigGroup" + + elif name in [ + "DomainsConfigDefaultGroupOptionGetResponse", + "DomainsConfigGroupOptionPatchRequest", "DomainsConfigGroupOptionPatchResponse", "DomainsConfigGroupOptionGetResponse", "DomainsConfigGroupOptionPatchRequest", ]: openapi_spec.components.schemas.setdefault( - "DomainConfigGroup", - TypeSchema(**DOMAIN_CONFIG_SCHEMA), + "DomainConfigGroupOption", + TypeSchema(**DOMAIN_CONFIG_GROUP_OPTION_SCHEMA), ) - ref = "#/components/schemas/DomainConfigGroup" + ref = "#/components/schemas/DomainConfigGroupOption" else: return (None, None, False) diff --git a/codegenerator/rust_cli.py b/codegenerator/rust_cli.py index c971e76..3121e07 100644 --- a/codegenerator/rust_cli.py +++ b/codegenerator/rust_cli.py @@ -97,11 +97,21 @@ class VecString(common.BasePrimitiveType): class JsonValue(common_rust.JsonValue): """Arbitrary JSON value""" - imports: set[str] = set(["crate::common::parse_json", "serde_json::Value"]) clap_macros: set[str] = set( ['value_name="JSON"', "value_parser=parse_json"] ) - original_data_type: BaseCompoundType | BaseCompoundType | None = None + original_data_type: BaseCombinedType | BaseCompoundType | None = None + + @property + def imports(self): + imports: set[str] = set( + ["crate::common::parse_json", "serde_json::Value"] + ) + if self.original_data_type and isinstance( + self.original_data_type, common_rust.Dictionary + ): + imports.update(["std::collections::BTreeMap", "anyhow::Context"]) + return imports class StructInputField(common_rust.StructField): @@ -602,6 +612,15 @@ class RequestTypeManager(common_rust.TypeManager): original_data_type=original_data_type, item_type=String(), ) + elif isinstance(type_model, model.Dictionary) and isinstance( + type_model.value_type, model.Dictionary + ): + original_data_type = self.convert_model(type_model.value_type) + typ = JsonValue( + original_data_type=DictionaryInput( + value_type=original_data_type + ) + ) if typ: if model_ref: @@ -638,8 +657,10 @@ class RequestTypeManager(common_rust.TypeManager): field_data_type = field_data_type.item_type elif isinstance(field_data_type, EnumGroupStruct): field_data_type.is_required = field.is_required - elif isinstance(field_data_type, DictionaryInput) and isinstance( - field_data_type.value_type, common_rust.BaseCompoundType + elif isinstance( + field_data_type, DictionaryInput + ) and not isinstance( + field_data_type.value_type, common_rust.BasePrimitiveType ): dict_type_model = self._get_adt_by_reference(field.data_type) simplified_data_type = JsonValue() @@ -1176,29 +1197,49 @@ class RustCliGenerator(BaseGenerator): isinstance(response_def.get("type"), list) and "object" in response_def["type"] ): - (_, response_types) = openapi_parser.parse( + (root, response_types) = openapi_parser.parse( response_def ) - response_type_manager.set_models(response_types) + if isinstance(root, model.Dictionary): + value_type = ( + response_type_manager.convert_model( + root.value_type + ) + ) + # if not isinstance(value_type, common_rust.BasePrimitiveType): + # value_type = JsonValue(original_data_type=value_type) + root_dict = HashMapResponse( + value_type=value_type + ) + response_type_manager.refs[ + model.Reference( + name="Body", type=HashMapResponse + ) + ] = root_dict - 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", - ] + else: + response_type_manager.set_models( + response_types ) - (_, response_types) = openapi_parser.parse( - response_def, ignore_read_only=True - ) - type_manager.set_models(response_types) + + 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", + ] + ) + (_, 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) diff --git a/codegenerator/rust_sdk.py b/codegenerator/rust_sdk.py index f3639d9..9233e6e 100644 --- a/codegenerator/rust_sdk.py +++ b/codegenerator/rust_sdk.py @@ -176,7 +176,13 @@ class BTreeMap(common_rust.Dictionary): ".map(|(k, v)| (k, v.map(Into::into)))" ) else: - return "BTreeMap::::new().into_iter()" + if isinstance(self.value_type, BTreeMap): + return ( + f"BTreeMap::>::new().into_iter()" + f".map(|(k, v)| (k, v.into_iter()))" + ) + else: + return f"BTreeMap::::new().into_iter()" def get_mandatory_init(self): return "" diff --git a/codegenerator/templates/rust_macros.j2 b/codegenerator/templates/rust_macros.j2 index bb015bd..2fee35e 100644 --- a/codegenerator/templates/rust_macros.j2 +++ b/codegenerator/templates/rust_macros.j2 @@ -70,12 +70,19 @@ Option {%- macro sdk_builder_setter_btreemap(field) %} {%- set is_opt = False if field.data_type.__class__.__name__ != "Option" else True %} {%- set dt = field.data_type if not is_opt else field.data_type.item_type %} + {%- set val_type = field.data_type.value_type %} {{ docstring(field.description, indent=4) }} - pub fn {{ field.local_name }}(&mut self, iter: I) -> &mut Self + pub fn {{ field.local_name }}(&mut self, iter: I) -> &mut Self where I: Iterator, K: Into>, - V: Into<{{ dt.value_type.type_hint }}>, + {%- if val_type.__class__.__name__ != "BTreeMap" %} + V: Into<{{ dt.value_type.type_hint }}>, + {% else %} + V: Iterator, + K1: Into>, + V1: Into<{{ val_type.value_type.type_hint }}>, + {% endif%} { self.{{ field.local_name }} {%- if field.is_optional %} @@ -85,7 +92,17 @@ Option .get_or_insert(None) {%- endif %} .get_or_insert_with(BTreeMap::new) - .extend(iter.map(|(k, v)| (k.into(), v.into()))); + .extend(iter.map(|(k, v)| ( + k.into(), + {%- if val_type.__class__.__name__ != "BTreeMap" %} + v.into() + {%- else %} + BTreeMap::from_iter( + v.into_iter() + .map(|(k1, v1)| {(k1.into(), v1.into())}) + ) + {%- endif %} + ))); self } {%- endmacro %} @@ -149,6 +166,13 @@ Some({{ val }}) {{ dst_var }}.{{ param.remote_name }}( serde_json::from_value::<{{ sdk_mod_path[-1] }}::{{ original_type.name }}>({{ val_var }}.to_owned())? ); + {%- elif original_type and original_type.__class__.__name__ == "DictionaryInput" and original_type.value_type and original_type.value_type.__class__.__name__ == "DictionaryInput" %} + {{ dst_var }}.{{ param.remote_name }}( + serde_json::from_value::>>({{ val_var | replace("&", "") }}.clone()) + .with_context(|| "Failed to parse `{{ param.local_name }}` as dict of dicts of {{ original_type.value_type.value_type.type_hint }}")? + .into_iter() + .map(|(k, v)| (k, v.into_iter())), + ); {%- else %} {{ dst_var }}.{{ param.remote_name }}({{ val_var | replace("&", "" )}}.clone()); {%- endif %} diff --git a/tools/generate_rust_identity.sh b/tools/generate_rust_identity.sh index 28a703e..ee682ce 100755 --- a/tools/generate_rust_identity.sh +++ b/tools/generate_rust_identity.sh @@ -5,6 +5,7 @@ METADATA=metadata DST=~/workspace/github/gtema/openstack NET_RESOURCES=( "auth" + "domain" "group" "os_federation" "endpoint" @@ -25,4 +26,5 @@ for resource in "${NET_RESOURCES[@]}"; do cp -av "${WRK_DIR}/rust/openstack_sdk/src/api/identity/v3/${resource}" ${DST}/openstack_sdk/src/api/identity/v3 cp -av "${WRK_DIR}/rust/openstack_sdk/src/api/identity/v3/${resource}.rs" ${DST}/openstack_sdk/src/api/identity/v3 cp -av "${WRK_DIR}/rust/openstack_cli/src/identity/v3/${resource}" ${DST}/openstack_cli/src/identity/v3 + cp -av "${WRK_DIR}/rust/openstack_cli/tests/identity/v3/${resource}" ${DST}/openstack_cli/tests/identity/v3 done;