diff --git a/src/ansible_dev_tools/resources/server/creator_dynamic.py b/src/ansible_dev_tools/resources/server/creator_dynamic.py new file mode 100644 index 00000000..5626845f --- /dev/null +++ b/src/ansible_dev_tools/resources/server/creator_dynamic.py @@ -0,0 +1,163 @@ +"""Dynamic, schema-driven creator API endpoints.""" + +from __future__ import annotations + +import json +import shutil + +from typing import TYPE_CHECKING, Any + +# pylint: disable-next=import-error,no-name-in-module +from ansible_creator.api import V1 # type: ignore[import-not-found] +from django.core.files.storage import FileSystemStorage +from django.http import FileResponse, HttpRequest, HttpResponse, JsonResponse + +from ansible_dev_tools.resources.server.creator_v2 import create_tar_file +from ansible_dev_tools.server_utils import validate_request, validate_response + + +if TYPE_CHECKING: + from pathlib import Path + + +class CreatorDynamic: + """Dynamic creator endpoints driven by ansible-creator's V1 API. + + Provides discovery, schema inspection, and generic scaffolding + without hardcoding individual project types. + """ + + def _response_from_tar(self, tar_file: Path) -> FileResponse: + """Create a FileResponse from a tar file. + + Args: + tar_file: The tar file path. + + Returns: + The file response. + """ + fs = FileSystemStorage(str(tar_file.parent)) + response = FileResponse( + fs.open(tar_file.name, "rb"), + content_type="application/tar", + status=201, + ) + response["Content-Disposition"] = f'attachment; filename="{tar_file.name}"' + return response + + def capabilities(self, request: HttpRequest) -> JsonResponse | HttpResponse: + """Return the full ansible-creator capability tree. + + Args: + request: HttpRequest object. + + Returns: + JSON response with the capability schema. + """ + result = validate_request(request) + if isinstance(result, HttpResponse): + return result + api = V1() + return JsonResponse(api.schema(), status=200) + + def schema(self, request: HttpRequest) -> JsonResponse | HttpResponse: + """Return the parameter schema for a specific command path. + + The command path is provided via repeated ``command_path`` query + parameters, e.g. ``?command_path=init&command_path=collection``. + + Args: + request: HttpRequest object. + + Returns: + JSON response with the command schema, or 400 on error. + """ + result = validate_request(request) + if isinstance(result, HttpResponse): + return result + path_segments = request.GET.getlist("command_path") + if not path_segments: + return HttpResponse( + "Missing required query parameter: command_path", + status=400, + ) + try: + api = V1() + schema_result = api.schema_for(*path_segments) + except KeyError as exc: + return JsonResponse({"error": str(exc)}, status=400) + return JsonResponse(schema_result, status=200) + + def scaffold(self, request: HttpRequest) -> FileResponse | HttpResponse: + """Scaffold an ansible-creator project dynamically. + + Accepts a JSON body with ``command_path`` (list of strings) and + optional ``params`` (dict). Delegates to ``V1().run()`` and returns + the scaffolded content as a tar archive. + + On success, logs are included in ``X-Creator-Logs`` and + ``X-Creator-Message`` response headers. + + On error, returns a JSON body with ``status``, ``message``, + and ``logs``. + + Args: + request: HttpRequest object. + + Returns: + Tar file response on success, or JSON/HTTP error response. + """ + result = validate_request(request) + if isinstance(result, HttpResponse): + return result + + body: dict[str, Any] = result.body # type: ignore[assignment] + command_path: list[str] = body.get("command_path", []) + params: dict[str, Any] = body.get("params", {}) + + if not command_path: + return JsonResponse( + {"status": "error", "message": "Missing command_path", "logs": []}, + status=400, + ) + + api = V1() + creator_result = api.run(*command_path, **params) + + if creator_result.status == "error": + # Clean up the temp directory on error + if creator_result.path: + shutil.rmtree(creator_result.path, ignore_errors=True) + return JsonResponse( + { + "status": "error", + "message": creator_result.message, + "logs": creator_result.logs, + }, + status=400, + ) + + if creator_result.path is None: + return JsonResponse( + { + "status": "error", + "message": "No output path", + "logs": creator_result.logs, + }, + status=400, + ) + + try: + tar_name = f"{'_'.join(command_path)}.tar" + tar_file = creator_result.path.parent / tar_name + create_tar_file(creator_result.path, tar_file) + response = self._response_from_tar(tar_file) + response["X-Creator-Logs"] = json.dumps(creator_result.logs) + response["X-Creator-Message"] = creator_result.message + finally: + shutil.rmtree(creator_result.path, ignore_errors=True) + + return validate_response( + request=request, + response=response, + ) diff --git a/src/ansible_dev_tools/resources/server/data/openapi.yaml b/src/ansible_dev_tools/resources/server/data/openapi.yaml index 3b96be34..f38a5631 100644 --- a/src/ansible_dev_tools/resources/server/data/openapi.yaml +++ b/src/ansible_dev_tools/resources/server/data/openapi.yaml @@ -135,6 +135,71 @@ paths: schema: $ref: "#/components/schemas/Error" + /v2/creator/capabilities: + get: + summary: Retrieve the full ansible-creator capability tree + responses: + "200": + description: The capability schema describing all available commands + content: + application/json: + schema: + AnyValue: {} + "400": + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v2/creator/schema: + get: + summary: Retrieve parameter schema for a specific creator command + parameters: + - name: command_path + in: query + required: true + schema: + type: array + items: + type: string + style: form + explode: true + responses: + "200": + description: The parameter schema for the specified command + content: + application/json: + schema: + AnyValue: {} + "400": + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v2/creator/scaffold: + post: + summary: Scaffold an ansible-creator project dynamically + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreatorScaffold" + required: true + responses: + "201": + description: Created + content: + application/tar: + schema: + AnyValue: {} + "400": + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/CreatorScaffoldError" + components: schemas: Metadata: @@ -185,3 +250,26 @@ components: type: integer message: type: string + CreatorScaffold: + type: object + additionalProperties: false + required: + - command_path + properties: + command_path: + type: array + items: + type: string + params: + type: object + CreatorScaffoldError: + type: object + properties: + status: + type: string + message: + type: string + logs: + type: array + items: + type: string diff --git a/src/ansible_dev_tools/subcommands/server.py b/src/ansible_dev_tools/subcommands/server.py index a9e0f602..c982ebce 100644 --- a/src/ansible_dev_tools/subcommands/server.py +++ b/src/ansible_dev_tools/subcommands/server.py @@ -12,6 +12,7 @@ from django.urls import path from gunicorn.app.base import BaseApplication +from ansible_dev_tools.resources.server.creator_dynamic import CreatorDynamic from ansible_dev_tools.resources.server.creator_v1 import CreatorFrontendV1 from ansible_dev_tools.resources.server.creator_v2 import CreatorFrontendV2 from ansible_dev_tools.resources.server.server_info import GetMetadata @@ -29,6 +30,9 @@ path(route="v2/creator/collection", view=CreatorFrontendV2().collection), path(route="v2/creator/devfile", view=CreatorFrontendV2().devfile), path(route="v2/creator/ee_project", view=CreatorFrontendV2().ee_project), + path(route="v2/creator/capabilities", view=CreatorDynamic().capabilities), + path(route="v2/creator/schema", view=CreatorDynamic().schema), + path(route="v2/creator/scaffold", view=CreatorDynamic().scaffold), ) diff --git a/src/ansible_dev_tools/tests/integration/test_server_creator_dynamic.py b/src/ansible_dev_tools/tests/integration/test_server_creator_dynamic.py new file mode 100644 index 00000000..a3abd89e --- /dev/null +++ b/src/ansible_dev_tools/tests/integration/test_server_creator_dynamic.py @@ -0,0 +1,244 @@ +"""Test the dynamic creator API endpoints.""" + +from __future__ import annotations + +import json +import tarfile + +from typing import TYPE_CHECKING + +import requests + + +if TYPE_CHECKING: + from pathlib import Path + + +# --- Capabilities tests --- + + +def test_capabilities(server_url: str) -> None: + """Test that the capabilities endpoint returns the command tree. + + Args: + server_url: The server URL. + """ + response = requests.get(f"{server_url}/v2/creator/capabilities", timeout=10) + assert response.status_code == requests.codes.get("ok") + data = response.json() + assert data["name"] == "ansible-creator" + assert "subcommands" in data + assert "init" in data["subcommands"] + assert "add" in data["subcommands"] + # Verify init has expected project types + init_cmd = data["subcommands"]["init"] + assert "subcommands" in init_cmd + assert "collection" in init_cmd["subcommands"] + assert "playbook" in init_cmd["subcommands"] + assert "execution_env" in init_cmd["subcommands"] + + +def test_capabilities_wrong_method(server_url: str) -> None: + """Test that POST to capabilities returns 400. + + Args: + server_url: The server URL. + """ + response = requests.post(f"{server_url}/v2/creator/capabilities", timeout=10) + assert response.status_code == requests.codes.get("bad_request") + + +# --- Schema tests --- + + +def test_schema_init_collection(server_url: str) -> None: + """Test schema endpoint for init collection command. + + Args: + server_url: The server URL. + """ + response = requests.get( + f"{server_url}/v2/creator/schema", + params=[("command_path", "init"), ("command_path", "collection")], + timeout=10, + ) + assert response.status_code == requests.codes.get("ok") + data = response.json() + assert data["name"] == "collection" + assert "parameters" in data + assert "collection" in data["parameters"]["properties"] + + +def test_schema_add_resource_devfile(server_url: str) -> None: + """Test schema endpoint for add resource devfile command. + + Args: + server_url: The server URL. + """ + response = requests.get( + f"{server_url}/v2/creator/schema", + params=[ + ("command_path", "add"), + ("command_path", "resource"), + ("command_path", "devfile"), + ], + timeout=10, + ) + assert response.status_code == requests.codes.get("ok") + data = response.json() + assert data["name"] == "devfile" + + +def test_schema_invalid_path(server_url: str) -> None: + """Test schema endpoint with an invalid command path. + + Args: + server_url: The server URL. + """ + response = requests.get( + f"{server_url}/v2/creator/schema", + params=[("command_path", "init"), ("command_path", "nonexistent")], + timeout=10, + ) + assert response.status_code == requests.codes.get("bad_request") + data = response.json() + assert "error" in data + + +def test_schema_missing_param(server_url: str) -> None: + """Test schema endpoint without command_path parameter. + + Args: + server_url: The server URL. + """ + response = requests.get(f"{server_url}/v2/creator/schema", timeout=10) + assert response.status_code == requests.codes.get("bad_request") + assert "command_path" in response.text + + +def test_schema_wrong_method(server_url: str) -> None: + """Test that POST to schema returns 400. + + Args: + server_url: The server URL. + """ + response = requests.post(f"{server_url}/v2/creator/schema", timeout=10) + assert response.status_code == requests.codes.get("bad_request") + + +# --- Scaffold tests --- + + +def test_scaffold_init_ee(server_url: str, tmp_path: Path) -> None: + """Test scaffolding an execution environment project. + + Args: + server_url: The server URL. + tmp_path: Pytest tmp_path fixture. + """ + response = requests.post( + f"{server_url}/v2/creator/scaffold", + json={"command_path": ["init", "execution_env"]}, + timeout=10, + ) + assert response.status_code == requests.codes.get("created") + assert response.headers["Content-Type"] == "application/tar" + assert "X-Creator-Message" in response.headers + dest_file = tmp_path / "ee.tar" + with dest_file.open(mode="wb") as tar_file: + tar_file.write(response.content) + with tarfile.open(dest_file) as file: + assert "./execution-environment.yml" in file.getnames() + + +def test_scaffold_add_devfile(server_url: str, tmp_path: Path) -> None: + """Test scaffolding a devfile resource. + + Args: + server_url: The server URL. + tmp_path: Pytest tmp_path fixture. + """ + response = requests.post( + f"{server_url}/v2/creator/scaffold", + json={"command_path": ["add", "resource", "devfile"]}, + timeout=10, + ) + assert response.status_code == requests.codes.get("created") + assert response.headers["Content-Type"] == "application/tar" + dest_file = tmp_path / "devfile.tar" + with dest_file.open(mode="wb") as tar_file: + tar_file.write(response.content) + with tarfile.open(dest_file) as file: + assert "./devfile.yaml" in file.getnames() + + +def test_scaffold_init_collection(server_url: str, tmp_path: Path) -> None: + """Test scaffolding a collection project with params. + + Args: + server_url: The server URL. + tmp_path: Pytest tmp_path fixture. + """ + response = requests.post( + f"{server_url}/v2/creator/scaffold", + json={ + "command_path": ["init", "collection"], + "params": {"collection": "namespace.name"}, + }, + timeout=10, + ) + assert response.status_code == requests.codes.get("created") + assert response.headers["Content-Type"] == "application/tar" + # Check logs header is present and valid JSON + logs_header = response.headers.get("X-Creator-Logs", "[]") + logs = json.loads(logs_header) + assert isinstance(logs, list) + dest_file = tmp_path / "collection.tar" + with dest_file.open(mode="wb") as tar_file: + tar_file.write(response.content) + with tarfile.open(dest_file) as file: + assert "./galaxy.yml" in file.getnames() + + +def test_scaffold_invalid_command(server_url: str) -> None: + """Test scaffold with an invalid command path returns error JSON. + + Args: + server_url: The server URL. + """ + response = requests.post( + f"{server_url}/v2/creator/scaffold", + json={"command_path": ["init", "nonexistent"]}, + timeout=10, + ) + assert response.status_code == requests.codes.get("bad_request") + data = response.json() + assert data["status"] == "error" + assert "logs" in data + + +def test_scaffold_missing_command_path(server_url: str) -> None: + """Test scaffold without command_path returns 400. + + The OpenAPI schema requires ``command_path``, so the request is + rejected at the validation layer before reaching the handler. + + Args: + server_url: The server URL. + """ + response = requests.post( + f"{server_url}/v2/creator/scaffold", + json={}, + timeout=10, + ) + assert response.status_code == requests.codes.get("bad_request") + + +def test_scaffold_wrong_method(server_url: str) -> None: + """Test that GET to scaffold returns 400. + + Args: + server_url: The server URL. + """ + response = requests.get(f"{server_url}/v2/creator/scaffold", timeout=10) + assert response.status_code == requests.codes.get("bad_request")