import json import os from copy import deepcopy from pathlib import Path from typing import Any # !!! IMPORTANT !!! # AVOID VERBS NOT IN THIS LIST # IF YOU WANT TO ADD TO THIS LIST CREATE AN ISSUE/DISCUSS FIRST # # Verbs are restricted to make API usage intuitive and consistent. # # Discouraged verbs: # do Too vague # process Sounds generic; lacks clarity. # generate Ambiguous: does it mutate state or not? Prefer 'run' # handle Abstract and fuzzy # show overlaps with get or list # describe overlap with get or list # can, is often used for helpers, use check instead for structure responses COMMON_VERBS = { "get", # fetch single item "list", # fetch collection "create", # instantiate resource "set", # update or configure "delete", # remove resource "check", # validate, probe, or assert "run", # start imperative task or action; machine-deploy etc. } # !!! IMPORTANT !!! # AVOID ADDING RESOUCRCES IN THIS LIST # Think twice before adding a new resource. # If you need a new resource, consider if it can be a sub-resource of an existing # resource instead. # If you need a new top-level resource, create an issue to discuss it first. TOP_LEVEL_RESOURCES = { "clan", # clan management "tag", # Tags "machine", # machine management "task", # task management "secret", # sops & key operations "log", # log operations "generator", # vars generators operations "service", # clan.service management "system", # system operations "llm", # llm operations } def is_verb(word: str) -> bool: return word in COMMON_VERBS def singular(word: str) -> str: if word.endswith("ies"): return word[:-3] + "y" if word.endswith("ses"): return word[:-2] if word.endswith("s") and not word.endswith("ss"): return word[:-1] return word def normalize_op_name(op_name: str) -> list[str]: parts = op_name.lower().split("_") # parts contains [ VERB NOUN NOUN ... ] # Where each NOUN is a SUB-RESOURCE verb = parts[0] nouns = parts[1:] if not nouns: nouns = ["misc"] # msg = "Operation names MUST have at least one NOUN" # raise Error(msg) nouns = [singular(p).capitalize() for p in nouns] return [verb, *nouns] def check_operation_name(op_name: str, normalized: list[str]) -> list[str]: verb = normalized[0] nouns = normalized[1:] warnings = [] if not is_verb(verb): warnings.append( f"""Verb '{verb}' of API operation {op_name} is not allowed. Use one of: {", ".join(COMMON_VERBS)} """, ) top_level_noun = nouns[0] if nouns else None if top_level_noun is None or top_level_noun.lower() not in TOP_LEVEL_RESOURCES: warnings.append( f"""Top-level resource '{top_level_noun}' of API operation {op_name} is not allowed. Use one of: {", ".join(TOP_LEVEL_RESOURCES)} """, ) return warnings def operation_to_tag(op_name: str) -> str: normalized = normalize_op_name(op_name) return " / ".join(normalized[1:]) def fix_nullables(schema: dict) -> dict: if isinstance(schema, dict): if "type" in schema and schema["type"] == "null": # Convert 'type: null' to 'nullable: true' # Merge any other keys from original schema except type new_schema = {"nullable": True} | { k: v for k, v in schema.items() if k != "type" } return fix_nullables(new_schema) # If 'oneOf' present if "oneOf" in schema and isinstance(schema["oneOf"], list): # Filter out 'type:null' schemas non_nulls = [s for s in schema["oneOf"] if s.get("type") != "null"] if len(non_nulls) == 1: # Only one non-null schema remains - convert to that + nullable:true new_schema = deepcopy(non_nulls[0]) new_schema["nullable"] = True # Merge any other keys from original schema except oneOf for k, v in schema.items(): if k != "oneOf": new_schema[k] = v return fix_nullables(new_schema) # More than one non-null, keep oneOf without nulls schema["oneOf"] = non_nulls return {k: fix_nullables(v) for k, v in schema.items()} # Recursively fix nested schemas return {k: fix_nullables(v) for k, v in schema.items()} if isinstance(schema, list): return [fix_nullables(i) for i in schema] return schema def fix_empty_required(schema: dict) -> dict: """Recursively remove "required: []" from schemas This is valid in json schema, but leads to errors in some OpenAPI 3.0 renderers. """ if isinstance(schema, dict): if "required" in schema and schema["required"] == []: # Remove empty required list new_schema = {k: v for k, v in schema.items() if k != "required"} return fix_empty_required(new_schema) # If 'oneOf' present if "oneOf" in schema and isinstance(schema["oneOf"], list): # Recursively fix each schema in oneOf schema["oneOf"] = [fix_empty_required(s) for s in schema["oneOf"]] # Remove empty required from each oneOf schema schema["oneOf"] = [ s for s in schema["oneOf"] if not (isinstance(s, dict) and s.get("required") == []) ] return schema return {k: fix_empty_required(v) for k, v in schema.items()} if isinstance(schema, list): return schema # ignore lists return schema def fix_error_refs(schema: dict) -> None: if isinstance(schema, dict): for key, value in schema.items(): if key == "$ref" and value == "#/$defs/error": schema[key] = "#/components/schemas/error" else: fix_error_refs(value) elif isinstance(schema, list): for item in schema: fix_error_refs(item) # === Helper to make reusable schema names === def make_schema_name(func_name: str, part: str) -> str: return f"{func_name}_{part}" def get_tag_key(tags: list[str]) -> tuple: """Convert list of tags to a tuple key for sorting.""" return tuple(tags) def sort_openapi_paths_by_tag_tree(openapi: dict[str, Any]) -> None: # Extract (tags, path, method, operation) tuples operations = [] for path, methods in openapi["paths"].items(): for method, operation in methods.items(): tag_path = operation.get("tags", []) operations.append((tag_path, path, method, operation)) # Sort by the tag hierarchy operations.sort(key=lambda x: get_tag_key(x[0])) # Rebuild sorted openapi["paths"] sorted_paths: dict = {} for _tag_path, path, method, operation in operations: sorted_paths[path] = sorted_paths.get(path, {}) sorted_paths[path][method] = operation openapi["paths"] = dict(sorted_paths) # Ensure it's a plain dict def main() -> None: input_path = Path(os.environ["INPUT_PATH"]) # === Load input JSON Schema === with input_path.open() as f: schema = json.load(f) defs = schema.get("$defs", {}) functions = schema["properties"] # === Start OpenAPI 3.0 spec in JSON === openapi: dict[str, Any] = { "openapi": "3.0.3", "info": { "title": "Function-Based Python API", "version": "1.0.0", "description": "!!! INTERNAL USE ONLY !!! We don't provide a world usable API yet.\nThis prototype maps python function calls to POST Requests because we are planning towards RESTfull API in the future.", }, "paths": {}, "components": {"schemas": {}}, } # === Check all functions === warnings: list[str] = [] errors: list[str] = [] for func_name, func_schema in functions.items(): normalized = normalize_op_name(func_name) check_res = check_operation_name(func_name, normalized) if check_res: errors.extend(check_res) if not func_schema.get("description"): errors.append( f"{func_name} doesn't have a description. Python docstring is required for an API function.", ) if warnings: for message in warnings: print(f"⚠️ Warn: {message}") if errors: for m in errors: print(f"❌ Error: {m}") os.abort() # === Convert each function === for func_name, func_schema in functions.items(): args_schema = fix_nullables(deepcopy(func_schema["properties"]["arguments"])) return_schema = fix_nullables(deepcopy(func_schema["properties"]["return"])) fix_error_refs(return_schema) args_schema = fix_empty_required(args_schema) return_schema = fix_empty_required(return_schema) # Register schemas under components args_name = make_schema_name(func_name, "args") return_name = make_schema_name(func_name, "return") openapi["components"]["schemas"][args_name] = args_schema # type: ignore[misc] openapi["components"]["schemas"][return_name] = return_schema # type: ignore[misc] tag = operation_to_tag(func_name) # Create a POST endpoint for the function openapi["paths"][f"/{func_name}"] = { # type: ignore[misc] "post": { "summary": func_name, "operationId": func_name, "description": func_schema.get("description"), "tags": [tag], "requestBody": { "required": True, "content": { "application/json": { "schema": {"$ref": f"#/components/schemas/{args_name}"}, }, }, }, "responses": { "200": { "description": "Successful response", "content": { "application/json": { "schema": { "$ref": f"#/components/schemas/{return_name}", }, }, }, }, }, }, } sort_openapi_paths_by_tag_tree(openapi) # === Add global definitions from $defs === for def_name, def_schema in defs.items(): fixed_schema = fix_nullables(deepcopy(def_schema)) fix_error_refs(fixed_schema) openapi["components"]["schemas"][def_name] = fixed_schema # type: ignore[misc] # === Write to output JSON === with Path("openapi.json").open("w") as f: json.dump(openapi, f, indent=2) print("✅ OpenAPI 3.0 JSON written to openapi.json") if __name__ == "__main__": main()