diff --git a/codegenerator/cli.py b/codegenerator/cli.py
index e4419ee..e32b679 100644
--- a/codegenerator/cli.py
+++ b/codegenerator/cli.py
@@ -32,6 +32,7 @@ from codegenerator.openapi_spec import OpenApiSchemaGenerator
# from codegenerator.osc import OSCGenerator
from codegenerator.rust_cli import RustCliGenerator
+from codegenerator.rust_tui import RustTuiGenerator
from codegenerator.rust_sdk import RustSdkGenerator
from codegenerator.types import Metadata
@@ -157,6 +158,7 @@ def main():
"ansible",
"rust-sdk",
"rust-cli",
+ "rust-tui",
"openapi-spec",
"jsonschema",
"metadata",
@@ -196,8 +198,9 @@ def main():
generators = {
# "osc": OSCGenerator(),
# "ansible": AnsibleGenerator(),
- "rust-sdk": RustSdkGenerator(),
"rust-cli": RustCliGenerator(),
+ "rust-tui": RustTuiGenerator(),
+ "rust-sdk": RustSdkGenerator(),
"openapi-spec": OpenApiSchemaGenerator(),
"jsonschema": JsonSchemaGenerator(),
"metadata": MetadataGenerator(),
@@ -269,7 +272,7 @@ def main():
)
)
- if args.target == "rust-sdk" and not args.resource:
+ if args.target in ["rust-sdk", "rust-tui"] and not args.resource:
resource_results: dict[str, dict] = {}
for mod_path, mod_name, path in res_mods:
mn = "/".join(mod_path)
@@ -300,7 +303,7 @@ def main():
x["mods"].add(mod_name)
for path, gen_data in resource_results.items():
- generators["rust-sdk"].generate_mod(
+ generators[args.target].generate_mod(
args.work_dir,
path.split("/"),
gen_data["mods"],
diff --git a/codegenerator/metadata/block_storage.py b/codegenerator/metadata/block_storage.py
index 87de21f..011ae48 100644
--- a/codegenerator/metadata/block_storage.py
+++ b/codegenerator/metadata/block_storage.py
@@ -13,6 +13,7 @@
import typing as ty
from codegenerator.types import OperationModel
+from codegenerator.types import OperationTargetParams
from codegenerator.metadata.base import MetadataBase
@@ -132,4 +133,20 @@ class BlockStorageMetadata(MetadataBase):
"rust-cli"
].cli_full_command.replace("delete-all", "purge")
+ if resource_name in ["backup", "snapshot", "volume"]:
+ if operation_name in ["list", "delete"]:
+ operation.targets.setdefault(
+ "rust-tui",
+ OperationTargetParams(
+ module_name=operation.targets["rust-sdk"].module_name
+ ),
+ )
+ if resource_name == "quota_set" and operation_name == "details":
+ operation.targets.setdefault(
+ "rust-tui",
+ OperationTargetParams(
+ module_name=operation.targets["rust-sdk"].module_name
+ ),
+ )
+
return operation
diff --git a/codegenerator/metadata/compute.py b/codegenerator/metadata/compute.py
index 66ed12e..a2811cd 100644
--- a/codegenerator/metadata/compute.py
+++ b/codegenerator/metadata/compute.py
@@ -14,6 +14,7 @@
import typing as ty
from codegenerator.types import OperationModel
+from codegenerator.types import OperationTargetParams
from codegenerator.metadata.base import MetadataBase
@@ -192,4 +193,27 @@ class ComputeMetadata(MetadataBase):
"rust-cli"
].cli_full_command.replace("delete-all", "purge")
+ if resource_name in [
+ "aggregate",
+ "flavor",
+ "hypervisor",
+ "server",
+ "server/instance_action",
+ ]:
+ if operation_name in ["list", "delete", "show"]:
+ operation.targets.setdefault(
+ "rust-tui",
+ OperationTargetParams(
+ module_name=operation.targets["rust-sdk"].module_name
+ ),
+ )
+
+ if resource_name == "quota_set" and operation_name == "details":
+ operation.targets.setdefault(
+ "rust-tui",
+ OperationTargetParams(
+ module_name=operation.targets["rust-sdk"].module_name
+ ),
+ )
+
return operation
diff --git a/codegenerator/metadata/dns.py b/codegenerator/metadata/dns.py
index 2eaed72..eb279fa 100644
--- a/codegenerator/metadata/dns.py
+++ b/codegenerator/metadata/dns.py
@@ -14,6 +14,7 @@
import typing as ty
from codegenerator.types import OperationModel
+from codegenerator.types import OperationTargetParams
from codegenerator.metadata.base import MetadataBase
@@ -45,4 +46,12 @@ class DnsMetadata(MetadataBase):
def post_process_operation(
resource_name: str, operation_name: str, operation
):
+ if resource_name in ["zone", "recordset", "zone/recordset"]:
+ if operation_name in ["list", "delete"]:
+ operation.targets.setdefault(
+ "rust-tui",
+ OperationTargetParams(
+ module_name=operation.targets["rust-sdk"].module_name
+ ),
+ )
return operation
diff --git a/codegenerator/metadata/identity.py b/codegenerator/metadata/identity.py
index 0771b69..92eb8ba 100644
--- a/codegenerator/metadata/identity.py
+++ b/codegenerator/metadata/identity.py
@@ -14,6 +14,7 @@
import typing as ty
from codegenerator.types import OperationModel
+from codegenerator.types import OperationTargetParams
from codegenerator.metadata.base import MetadataBase
@@ -140,4 +141,19 @@ class IdentityMetadata(MetadataBase):
"rust-cli"
].cli_full_command.replace("delete-all", "purge")
+ if resource_name in [
+ "auth/project",
+ "group",
+ "group/user",
+ "project",
+ "user",
+ "user/application_credential",
+ ]:
+ if operation_name in ["list", "delete"]:
+ operation.targets.setdefault(
+ "rust-tui",
+ OperationTargetParams(
+ module_name=operation.targets["rust-sdk"].module_name
+ ),
+ )
return operation
diff --git a/codegenerator/metadata/image.py b/codegenerator/metadata/image.py
index c5618e3..17e1698 100644
--- a/codegenerator/metadata/image.py
+++ b/codegenerator/metadata/image.py
@@ -14,6 +14,7 @@
import typing as ty
from codegenerator.types import OperationModel
+from codegenerator.types import OperationTargetParams
from codegenerator.metadata.base import MetadataBase
@@ -103,4 +104,12 @@ class ImageMetadata(MetadataBase):
"rust-cli"
].cli_full_command.replace("delete-all", "purge")
+ if resource_name in ["image"]:
+ if operation_name in ["list", "delete"]:
+ operation.targets.setdefault(
+ "rust-tui",
+ OperationTargetParams(
+ module_name=operation.targets["rust-sdk"].module_name
+ ),
+ )
return operation
diff --git a/codegenerator/metadata/load_balancer.py b/codegenerator/metadata/load_balancer.py
index 26df9d6..9ff5f2f 100644
--- a/codegenerator/metadata/load_balancer.py
+++ b/codegenerator/metadata/load_balancer.py
@@ -14,6 +14,7 @@
import typing as ty
from codegenerator.types import OperationModel
+from codegenerator.types import OperationTargetParams
from codegenerator.metadata.base import MetadataBase
@@ -40,4 +41,19 @@ class LoadBalancerMetadata(MetadataBase):
def post_process_operation(
resource_name: str, operation_name: str, operation
):
+ if resource_name in [
+ "healthmonitor",
+ "loadbalancer",
+ "listener",
+ "quota",
+ "pool",
+ "pool/member",
+ ]:
+ if operation_name in ["list", "delete", "show"]:
+ operation.targets.setdefault(
+ "rust-tui",
+ OperationTargetParams(
+ module_name=operation.targets["rust-sdk"].module_name
+ ),
+ )
return operation
diff --git a/codegenerator/metadata/network.py b/codegenerator/metadata/network.py
index 279077b..df0a8ce 100644
--- a/codegenerator/metadata/network.py
+++ b/codegenerator/metadata/network.py
@@ -14,6 +14,7 @@
import typing as ty
from codegenerator.types import OperationModel
+from codegenerator.types import OperationTargetParams
from codegenerator.metadata.base import MetadataBase
@@ -86,4 +87,25 @@ class NetworkMetadata(MetadataBase):
"rust-cli"
].cli_full_command.replace("delete-all", "purge")
+ if resource_name in [
+ "network",
+ "router",
+ "security_group",
+ "security_group_rule",
+ "subnet",
+ ]:
+ if operation_name in ["list", "delete"]:
+ operation.targets.setdefault(
+ "rust-tui",
+ OperationTargetParams(
+ module_name=operation.targets["rust-sdk"].module_name
+ ),
+ )
+ if resource_name == "quota" and operation_name == "details":
+ operation.targets.setdefault(
+ "rust-tui",
+ OperationTargetParams(
+ module_name=operation.targets["rust-sdk"].module_name
+ ),
+ )
return operation
diff --git a/codegenerator/rust_tui.py b/codegenerator/rust_tui.py
new file mode 100644
index 0000000..be0eacd
--- /dev/null
+++ b/codegenerator/rust_tui.py
@@ -0,0 +1,343 @@
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+import logging
+from pathlib import Path
+import re
+import subprocess
+from typing import Type, Any
+
+from codegenerator.base import BaseGenerator
+from codegenerator import common
+from codegenerator import model
+from codegenerator.common import BaseCompoundType
+from codegenerator.common import rust as common_rust
+from codegenerator.rust_sdk import TypeManager as SdkTypeManager
+
+
+class String(common_rust.String):
+ type_hint: str = "String"
+
+
+class TypeManager(common_rust.TypeManager):
+ """Rust SDK type manager
+
+ The class is responsible for converting ADT models into types suitable
+ for Rust (SDK).
+
+ """
+
+ primitive_type_mapping: dict[Type[model.PrimitiveType], Type[Any]] = {
+ model.PrimitiveString: String,
+ model.ConstraintString: String,
+ }
+
+ data_type_mapping = {model.Struct: common_rust.Struct}
+
+ request_parameter_class: Type[common_rust.RequestParameter] = (
+ common_rust.RequestParameter
+ )
+
+ def get_local_attribute_name(self, name: str) -> str:
+ """Get localized attribute name"""
+ name = name.replace(".", "_")
+ attr_name = "_".join(
+ x.lower() for x in re.split(common.SPLIT_NAME_RE, name)
+ )
+ if attr_name in ["type", "self", "enum", "ref", "default"]:
+ attr_name = f"_{attr_name}"
+ return attr_name
+
+ def get_remote_attribute_name(self, name: str) -> str:
+ """Get the attribute name on the SDK side"""
+ return self.get_local_attribute_name(name)
+
+
+class RustTuiGenerator(BaseGenerator):
+ def __init__(self):
+ super().__init__()
+
+ def _format_code(self, *args):
+ """Format code using Rustfmt
+
+ :param *args: Path to the code to format
+ """
+ for path in args:
+ subprocess.run(["rustfmt", "--edition", "2021", path])
+
+ def _render_command(
+ self, context: dict, impl_template: str, impl_dest: Path
+ ):
+ """Render command code"""
+ self._render(impl_template, context, impl_dest.parent, impl_dest.name)
+
+ def generate(
+ self, res, target_dir, openapi_spec=None, operation_id=None, args=None
+ ):
+ """Generate code for the Rust openstack_tui"""
+ logging.debug(
+ "Generating Rust TUI code for %s in %s [%s]",
+ operation_id,
+ target_dir,
+ args,
+ )
+
+ if not openapi_spec:
+ openapi_spec = common.get_openapi_spec(args.openapi_yaml_spec)
+ if not operation_id:
+ operation_id = args.openapi_operation_id
+ (path, method, spec) = common.find_openapi_operation(
+ openapi_spec, operation_id
+ )
+
+ # srv_name, res_name = res.split(".") if res else (None, None)
+ path_resources = common.get_resource_names_from_url(path)
+ res_name = path_resources[-1]
+
+ mime_type = None
+ openapi_parser = model.OpenAPISchemaParser()
+ operation_params: list[model.RequestParameter] = []
+ type_manager: TypeManager | None = None
+ sdk_type_manager: SdkTypeManager | None = None
+ is_json_patch: bool = False
+ # Collect all operation parameters
+ for param in openapi_spec["paths"][path].get(
+ "parameters", []
+ ) + spec.get("parameters", []):
+ if (
+ ("{" + param["name"] + "}") in path and param["in"] == "path"
+ ) or param["in"] != "path":
+ # Respect path params that appear in path and not path params
+ param_ = openapi_parser.parse_parameter(param)
+ if param_.name in [
+ f"{res_name}_id",
+ f"{res_name.replace('_', '')}_id",
+ ]:
+ path = path.replace(param_.name, "id")
+ # for i.e. routers/{router_id} we want local_name to be `id` and not `router_id`
+ param_.name = "id"
+ operation_params.append(param_)
+
+ # Process body information
+ # List of operation variants (based on the body)
+ operation_variants = common.get_operation_variants(
+ spec, args.operation_name
+ )
+
+ api_ver_matches: re.Match | None = None
+ path_elements = path.lstrip("/").split("/")
+ api_ver: dict[str, int] = {}
+ ver_prefix: str | None = None
+ is_list_paginated = False
+ if path_elements:
+ api_ver_matches = re.match(common.VERSION_RE, path_elements[0])
+ if api_ver_matches and api_ver_matches.groups():
+ # 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}")
+ # TODO(gtema): if we are in MV variants filter out unsupported query
+ # parameters
+ # TODO(gtema): previously we were ensuring `router_id` path param
+ # is renamed to `id`
+
+ if api_ver_matches:
+ api_ver = {
+ "major": api_ver_matches.group(1),
+ "minor": api_ver_matches.group(3) or 0,
+ }
+ else:
+ api_ver = {}
+
+ service_name = common.get_rust_service_type_from_str(
+ args.service_type
+ )
+ class_name = f"{service_name}{res_name.title()}{args.operation_type.title()}".replace(
+ "_", ""
+ )
+ operation_body = operation_variant.get("body")
+ type_manager = TypeManager()
+ sdk_type_manager = SdkTypeManager()
+ type_manager.set_parameters(operation_params)
+ sdk_type_manager.set_parameters(operation_params)
+ 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
+ ),
+ )
+ )
+
+ if operation_body:
+ min_ver = operation_body.get("x-openstack", {}).get("min-ver")
+ if min_ver:
+ mod_name += "_" + min_ver.replace(".", "")
+ v = min_ver.split(".")
+ if not len(v) == 2:
+ raise RuntimeError(
+ "Version information is not in format MAJOR.MINOR"
+ )
+ api_ver = {"major": v[0], "minor": v[1]}
+
+ # There is request body. Get the ADT from jsonschema
+ # if args.operation_type != "action":
+ (_, all_types) = openapi_parser.parse(
+ operation_body, ignore_read_only=True
+ )
+ # and feed them into the TypeManager
+ type_manager.set_models(all_types)
+ sdk_type_manager.set_models(all_types)
+ # else:
+ # logging.warn("Ignoring response type of action")
+
+ if method == "patch":
+ # There might be multiple supported mime types. We only select ones we are aware of
+ mime_type = operation_variant.get("mime_type")
+ if not mime_type:
+ raise RuntimeError(
+ "No supported mime types for patch operation found"
+ )
+ if mime_type != "application/json":
+ is_json_patch = True
+
+ mod_path = common.get_rust_sdk_mod_path(
+ args.service_type,
+ args.api_version,
+ args.alternative_module_path or path,
+ )
+
+ response_key: str | None = None
+ if args.response_key:
+ response_key = (
+ args.response_key if args.response_key != "null" else None
+ )
+ 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
+ sdk_mod_path_base = common.get_rust_sdk_mod_path(
+ args.service_type, args.api_version, args.module_path or path
+ )
+ sdk_mod_path: list[str] = sdk_mod_path_base.copy()
+ mod_suffix: str = ""
+ sdk_mod_path.append((args.sdk_mod_name or mod_name) + mod_suffix)
+
+ additional_imports = set()
+ additional_imports.add(
+ "openstack_sdk::api::"
+ + "::".join(sdk_mod_path)
+ + "::RequestBuilder"
+ )
+ additional_imports.add(
+ "openstack_sdk::{AsyncOpenStack, api::QueryAsync}"
+ )
+ if args.operation_type == "list":
+ if "limit" in [
+ k for (k, _) in type_manager.get_parameters("query")
+ ]:
+ is_list_paginated = True
+ additional_imports.add(
+ "openstack_sdk::api::{paged, Pagination}"
+ )
+ elif args.operation_type == "delete":
+ additional_imports.add("openstack_sdk::api::ignore")
+ additional_imports.add(
+ "crate::cloud_worker::ConfirmableRequest"
+ )
+
+ context = {
+ "additional_imports": additional_imports,
+ "operation_id": operation_id,
+ "operation_type": spec.get(
+ "x-openstack-operation-type", args.operation_type
+ ),
+ "command_description": common_rust.sanitize_rust_docstrings(
+ common.make_ascii_string(spec.get("description"))
+ ),
+ "class_name": class_name,
+ "sdk_service_name": service_name,
+ "resource_name": res_name,
+ "url": path.lstrip("/").lstrip(ver_prefix).lstrip("/"),
+ "method": method,
+ "type_manager": type_manager,
+ "sdk_type_manager": sdk_type_manager,
+ "sdk_mod_path": sdk_mod_path,
+ "response_key": response_key,
+ "response_list_item_key": args.response_list_item_key,
+ "mime_type": mime_type,
+ "is_json_patch": is_json_patch,
+ "api_ver": api_ver,
+ "is_list_paginated": is_list_paginated,
+ }
+
+ work_dir = Path(target_dir, "rust", "openstack_tui", "src")
+ impl_path = Path(
+ work_dir, "cloud_worker", "/".join(mod_path), f"{mod_name}.rs"
+ )
+
+ # Generate methods for the GET resource command
+ self._render_command(context, "rust_tui/impl.rs.j2", impl_path)
+
+ self._format_code(impl_path)
+
+ yield (mod_path, mod_name, path)
+
+ def generate_mod(
+ self, target_dir, mod_path, mod_list, url, resource_name, service_name
+ ):
+ """Generate collection module (include individual modules)"""
+ work_dir = Path(target_dir, "rust", "openstack_tui", "src")
+ impl_path = Path(
+ work_dir,
+ "cloud_worker",
+ "/".join(mod_path[0:-1]),
+ f"{mod_path[-1]}.rs",
+ )
+
+ context = {
+ "mod_list": mod_list,
+ "mod_path": mod_path,
+ "url": url,
+ "resource_name": resource_name,
+ "service_name": service_name,
+ }
+
+ # Generate methods for the GET resource command
+ self._render_command(context, "rust_sdk/mod.rs.j2", impl_path)
+
+ self._format_code(impl_path)
diff --git a/codegenerator/templates/rust_macros.j2 b/codegenerator/templates/rust_macros.j2
index a7c5ec1..0697a05 100644
--- a/codegenerator/templates/rust_macros.j2
+++ b/codegenerator/templates/rust_macros.j2
@@ -154,18 +154,18 @@ Some({{ val }})
{%- endmacro %}
{#- Macros to render setting Request data from CLI input #}
-{%- macro set_request_data_from_input(manager, dst_var, param, val_var) %}
+{%- macro set_request_data_from_input(manager, dst_var, param, val_var, by_ref=False) %}
{%- set is_nullable = param.is_nullable if param.is_nullable is defined else False %}
{%- if param.type_hint in ["Option