diff --git a/codegenerator/openapi/base.py b/codegenerator/openapi/base.py index c2e9cbc..3052178 100644 --- a/codegenerator/openapi/base.py +++ b/codegenerator/openapi/base.py @@ -1309,6 +1309,48 @@ class OpenStackServerSourceBase: getattr(f, "_request_query_schema", {}), ) query_params_versions.append((obj, min_ver, max_ver)) + if "validators" in closure_locals: + validators = closure_locals.get("validators") + body_schemas = [] + if isinstance(validators, dict): + for k, v in validators.items(): + sig = inspect.signature(v) + vals = sig.parameters.get("validators", None) + if vals: + print(vals) + sig2 = inspect.signature(vals.default[0]) + schema_param = sig2.parameters.get("schema", None) + if schema_param: + schema = schema_param.default + + 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(schema), + start_version=None, + end_version=None, + ), + ) + ) + + ref_name = f"#/components/schemas/{typ_name}" + if isinstance(body_schemas, list): + body_schemas.append(ref_name) f = f.__wrapped__ diff --git a/codegenerator/openapi/cinder.py b/codegenerator/openapi/cinder.py index 643964a..5716ed2 100644 --- a/codegenerator/openapi/cinder.py +++ b/codegenerator/openapi/cinder.py @@ -159,7 +159,7 @@ class CinderV3Generator(OpenStackServerSourceBase): if route.routepath.startswith( "/extensions" ) or route.routepath.startswith( - "/{project_id:[0-9a-f\-]+}/extensions" + "/{project_id:[0-9a-f\\-]+}/extensions" ): if route.defaults.get("action") != "index": # Extensions controller is broken as one exposing CRUD diff --git a/codegenerator/openapi/ironic.py b/codegenerator/openapi/ironic.py new file mode 100644 index 0000000..1294fd8 --- /dev/null +++ b/codegenerator/openapi/ironic.py @@ -0,0 +1,201 @@ +# 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 inspect +from multiprocessing import Process +from pathlib import Path +from unittest import mock + +import fixtures + +from codegenerator.common.schema import SpecSchema +from codegenerator.openapi.base import OpenStackServerSourceBase +from codegenerator.openapi.utils import merge_api_ref_doc + +from ruamel.yaml.scalarstring import LiteralScalarString + + +class IronicGenerator(OpenStackServerSourceBase): + URL_TAG_MAP = {} + + def __init__(self): + pass + + def _api_ver_major(self, ver): + return ver.ver_major + + def _api_ver_minor(self, ver): + return ver.ver_minor + + def _api_ver(self, ver): + return (ver.ver_major, ver.ver_minor) + + def _build_routes(self, mapper, node, path=""): + resource: str | None = None + # Construct resource name from the path + parent = path.split("/")[-1] + if parent == "v1": + resource = "" + elif parent.endswith("ies"): + resource = parent[0 : len(parent) - 3] + "y" + elif parent in ["allocation", "history", "vmedia", "chassis", "bios"]: + resource = parent + else: + resource = parent[0:-1] + + for part in [x for x in dir(node) if callable(getattr(node, x))]: + # Iterate over functions to find what is exposed on the current + # level + obj = getattr(node, part) + _pecan = getattr(obj, "_pecan", None) + exposed = getattr(obj, "exposed", None) + if _pecan and exposed: + # Only whatever is pecan exposed is of interest + conditions = {} + action = None + url = path + # resource = None + # parent = url.split("/")[-1] + # if path.startswith("/v2/lbaas/quotas"): + # # Hack path parameter name for quotas + # resource = "project" + # Identify the action from function name + # https://pecan.readthedocs.io/en/latest/rest.html#url-mapping + if part == "get_one": + conditions["method"] = ["GET"] + action = "show" + url += f"/{{{resource}_id}}" + elif part == "get_all": + conditions["method"] = ["GET"] + action = "list" + elif part == "get": + conditions["method"] = ["GET"] + action = "get" + # "Get" is tricky, it can be normal and root, so need to inspect params + sig = inspect.signature(obj) + for pname, pval in sig.parameters.items(): + if "id" in pname and pval.default == pval.empty: + url += f"/{{{resource}_id}}" + elif part == "post": + conditions["method"] = ["POST"] + action = "create" + # url += f"/{{{resource}_id}}" + elif part == "put": + conditions["method"] = ["PUT"] + action = "update" + url += f"/{{{resource}_id}}" + elif part == "patch": + conditions["method"] = ["PATCH"] + action = "update" + url += f"/{{{resource}_id}}" + elif part == "delete": + conditions["method"] = ["DELETE"] + action = "delete" + url += f"/{{{resource}_id}}" + elif part in getattr(node, "_custom_actions", {}): + conditions["method"] = getattr( + node, "_custom_actions", {} + )[part] + action = part + url += f"/{part}" + + if action: + # If we identified method as "interesting" register it into + # the routes mapper + mapper.connect( + None, + url, + controller=obj, + action=action, + conditions=conditions, + ) + + for subcontroller, v in getattr( + node, "_subcontroller_map", {} + ).items(): + if resource: + subpath = f"{path}/{{{resource}_id}}/{subcontroller}" + else: + subpath = f"{path}/{subcontroller}" + + self._build_routes(mapper, v, subpath) + + return + + def generate(self, target_dir, args): + proc = Process(target=self._generate, args=[target_dir, args]) + proc.start() + proc.join() + if proc.exitcode != 0: + raise RuntimeError("Error generating Octavia OpenAPI schema") + + def _generate(self, target_dir, args): + from ironic.api.controllers.v1 import versions + from ironic.api.controllers import root as root_controller + from ironic.api.controllers import v1 + + from pecan import make_app as pecan_make_app + from routes import Mapper + + self.api_version = versions.max_version_string() + self.min_api_version = versions.min_version_string() + + work_dir = Path(target_dir) + work_dir.mkdir(parents=True, exist_ok=True) + + impl_path = Path( + work_dir, "openapi_specs", "baremetal", f"v{self.api_version}.yaml" + ) + impl_path.parent.mkdir(parents=True, exist_ok=True) + openapi_spec = self.load_openapi(Path(impl_path)) + if not openapi_spec: + openapi_spec = SpecSchema( + info={ + "title": "OpenStack Baremetal API", + "description": LiteralScalarString( + "Baremetal API provided by Ironic service" + ), + "version": self.api_version, + }, + openapi="3.1.0", + security=[{"ApiKeyAuth": []}], + components={ + "securitySchemes": { + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-Auth-Token", + } + } + }, + ) + + self.app = pecan_make_app(root_controller.RootController()) + self.root = self.app.application.root + mapper = Mapper() + self._build_routes(mapper, v1.Controller, "/v1") + + for route in mapper.matchlist: + self._process_route(route, openapi_spec, framework="pecan") + + if args.api_ref_src: + merge_api_ref_doc( + openapi_spec, args.api_ref_src, allow_strip_version=False + ) + + self.dump_openapi(openapi_spec, Path(impl_path), args.validate) + + lnk = Path(impl_path.parent, "v1.yaml") + lnk.unlink(missing_ok=True) + lnk.symlink_to(impl_path.name) + + return impl_path diff --git a/codegenerator/openapi_spec.py b/codegenerator/openapi_spec.py index 49d84a3..69e4789 100644 --- a/codegenerator/openapi_spec.py +++ b/codegenerator/openapi_spec.py @@ -73,6 +73,11 @@ class OpenApiSchemaGenerator(BaseGenerator): DesignateGenerator().generate(target_dir, args) + def generate_ironic(self, target_dir, args): + from codegenerator.openapi.ironic import IronicGenerator + + IronicGenerator().generate(target_dir, args) + def generate( self, res, target_dir, openapi_spec=None, operation_id=None, args=None ): @@ -83,6 +88,8 @@ class OpenApiSchemaGenerator(BaseGenerator): # dramatically if args.service_type == "compute": self.generate_nova(target_dir, args) + elif args.service_type == "baremetal": + self.generate_ironic(target_dir, args) elif args.service_type in ["block-storage", "volume"]: self.generate_cinder(target_dir, args) elif args.service_type == "dns": diff --git a/setup.cfg b/setup.cfg index f125e29..e0c326a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -45,6 +45,8 @@ placement = openstack-placement>=10.0 shared-file-system = manila>=18.0 +baremetal = + ironic>=26.0 [mypy] show_column_numbers = true diff --git a/tools/generate_openapi_specs.sh b/tools/generate_openapi_specs.sh index a9b7ff9..b7f298e 100755 --- a/tools/generate_openapi_specs.sh +++ b/tools/generate_openapi_specs.sh @@ -38,3 +38,7 @@ fi if [ -z "$1" -o "$1" = "dns" ]; then openstack-codegenerator --work-dir wrk --target openapi-spec --service-type dns --api-ref-src ${API_REF_BUILD_ROOT}/designate/api-ref/build/html/dns-api-v2-index.html --validate fi + +if [ -z "$1" -o "$1" = "baremetal" ]; then + openstack-codegenerator --work-dir wrk --target openapi-spec --service-type baremetal --api-ref-src ${API_REF_BUILD_ROOT}/ironic/api-ref/build/html/index.html --validate +fi diff --git a/zuul.d/openapi.yaml b/zuul.d/openapi.yaml index 5888ef9..7bbd580 100644 --- a/zuul.d/openapi.yaml +++ b/zuul.d/openapi.yaml @@ -33,6 +33,35 @@ codegenerator_work_dir: "wrk" install_additional_projects: [] +- job: + name: codegenerator-openapi-baremetal-tips + parent: codegenerator-openapi-tips-base + description: | + Generate OpenAPI spec for Ironic + required-projects: + - name: openstack/ironic + + vars: + openapi_service: baremetal + install_additional_projects: + - project: "opendev.org/openstack/ironic" + name: "." + +- job: + name: codegenerator-openapi-baremetal-tips-with-api-ref + parent: codegenerator-openapi-baremetal-tips + description: | + Generate OpenAPI spec for Ironic consuming API-REF + required-projects: + - name: openstack/ironic + + pre-run: + - playbooks/openapi/pre-api-ref.yaml + vars: + codegenerator_api_ref: + project: "opendev.org/openstack/ironic" + path: "/api-ref/build/html/index.html" + - job: name: codegenerator-openapi-block-storage-tips parent: codegenerator-openapi-tips-base @@ -315,6 +344,8 @@ description: | Published OpenAPI specs dependencies: + - name: codegenerator-openapi-baremetal-tips-with-api-ref + soft: true - name: codegenerator-openapi-block-storage-tips-with-api-ref soft: true - name: codegenerator-openapi-compute-tips-with-api-ref diff --git a/zuul.d/project.yaml b/zuul.d/project.yaml index 6da8431..69b9574 100644 --- a/zuul.d/project.yaml +++ b/zuul.d/project.yaml @@ -6,6 +6,7 @@ jobs: - openstack-tox-pep8 - openstack-tox-py311 + - codegenerator-openapi-baremetal-tips-with-api-ref - codegenerator-openapi-block-storage-tips-with-api-ref - codegenerator-openapi-compute-tips-with-api-ref - codegenerator-openapi-dns-tips-with-api-ref @@ -22,6 +23,7 @@ jobs: - openstack-tox-pep8 - openstack-tox-py311 + - codegenerator-openapi-baremetal-tips-with-api-ref - codegenerator-openapi-block-storage-tips-with-api-ref - codegenerator-openapi-compute-tips-with-api-ref - codegenerator-openapi-dns-tips-with-api-ref