Repository: ansible/ansible-dev-tools (language: python)

Issue:
Refactor server creator class to V1 and update all the references
JIRA ticket: https://issues.redhat.com/browse/AAP-32022


Discussion / hints:

Fix patch:
diff --git a/src/ansible_dev_tools/resources/server/creator_v2.py b/src/ansible_dev_tools/resources/server/creator_v2.py
new file mode 100644
index 00000000..8f88373a
--- /dev/null
+++ b/src/ansible_dev_tools/resources/server/creator_v2.py
@@ -0,0 +1,192 @@
+"""The creator frontend and backend APIs."""
+
+from __future__ import annotations
+
+import tarfile
+import tempfile
+
+from pathlib import Path
+
+from ansible_creator._version import version as creator_version
+from ansible_creator.config import Config
+from ansible_creator.output import Output
+from ansible_creator.subcommands.init import Init
+from ansible_creator.utils import TermFeatures
+from django.core.files.storage import FileSystemStorage
+from django.http import FileResponse, HttpRequest, HttpResponse
+
+from ansible_dev_tools.server_utils import validate_request, validate_response
+
+
+def create_tar_file(init_path: Path, tar_file: Path) -> None:
+    """Create a tar file from the given directory.
+
+    Args:
+        init_path: The directory path to create the tar file from.
+        tar_file: The output tar file path.
+    """
+    with tarfile.open(tar_file, "w:gz") as tar:
+        tar.add(str(init_path), arcname=".")
+
+
+class CreatorFrontendV2:
+    """The creator frontend, handles requests from users."""
+
+    def _response_from_tar(self: CreatorFrontendV2, 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+gzip",
+            status=201,
+        )
+        response["Content-Disposition"] = f'attachment; filename="{tar_file.name}"'
+        return response
+
+    def playbook(
+        self: CreatorFrontendV2,
+        request: HttpRequest,
+    ) -> FileResponse | HttpResponse:
+        """Create a new playbook project.
+
+        Args:
+            request: HttpRequest object.
+
+        Returns:
+            File or error response.
+        """
+        result = validate_request(request)
+        if isinstance(result, HttpResponse):
+            return result
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            # result.body here is a dict, it appear the type hint is wrong
+            tar_file = CreatorBackend(Path(tmp_dir)).playbook(
+                **result.body,  # type: ignore[arg-type]
+            )
+            response = self._response_from_tar(tar_file)
+
+        return validate_response(
+            request=request,
+            response=response,
+        )
+
+    def collection(
+        self: CreatorFrontendV2,
+        request: HttpRequest,
+    ) -> FileResponse | HttpResponse:
+        """Create a new collection project.
+
+        Args:
+            request: HttpRequest object.
+
+        Returns:
+            File or error response.
+        """
+        result = validate_request(request)
+        if isinstance(result, HttpResponse):
+            return result
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            # result.body here is a dict, it appear the type hint is wrong
+            tar_file = CreatorBackend(Path(tmp_dir)).collection(
+                **result.body,  # type: ignore[arg-type]
+            )
+            response = self._response_from_tar(tar_file)
+
+        return validate_response(
+            request=request,
+            response=response,
+        )
+
+
+class CreatorOutput(Output):
+    """The creator output."""
+
+    def __init__(self: CreatorOutput, log_file: str) -> None:
+        """Initialize the creator output.
+
+        Convenience class to consistently define output with a changing temporary directory.
+
+        Args:
+            log_file: The log file path.
+        """
+        super().__init__(
+            log_file=log_file,
+            log_level="DEBUG",
+            log_append="false",
+            term_features=TermFeatures(color=False, links=False),
+            verbosity=1,
+        )
+
+
+class CreatorBackend:
+    """The creator wrapper, handles interaction with the python creator project."""
+
+    def __init__(self: CreatorBackend, tmp_dir: Path) -> None:
+        """Initialize the creator.
+
+        Args:
+            tmp_dir: The temporary directory.
+        """
+        self.tmp_dir = tmp_dir
+
+    def collection(self: CreatorBackend, collection: str, project: str) -> Path:
+        """Scaffold a collection.
+
+        Args:
+            collection: The collection name.
+            project: The project type.
+
+        Returns:
+            The tar file path.
+        """
+        init_path = self.tmp_dir / collection
+        config = Config(
+            creator_version=creator_version,
+            init_path=str(init_path),
+            output=CreatorOutput(log_file=str(self.tmp_dir / "creator.log")),
+            collection=collection,
+            subcommand="init",
+            project=project,
+        )
+        Init(config).run()
+        tar_file = self.tmp_dir / f"{collection}.tar.gz"
+        create_tar_file(init_path, tar_file)
+        return tar_file
+
+    def playbook(
+        self: CreatorBackend,
+        project: str,
+        namespace: str,
+        collection_name: str,
+    ) -> Path:
+        """Scaffold a playbook project.
+
+        Args:
+            project: The project type.
+            namespace: The collection namespace.
+            collection_name: The collection name.
+
+        Returns:
+            The tar file path.
+        """
+        init_path = self.tmp_dir / f"{namespace}-{collection_name}"
+        config = Config(
+            creator_version=creator_version,
+            init_path=str(init_path),
+            output=CreatorOutput(log_file=str(self.tmp_dir / "creator.log")),
+            project=project,
+            namespace=namespace,
+            collection_name=collection_name,
+            subcommand="init",
+        )
+        Init(config).run()
+        tar_file = self.tmp_dir / f"{namespace}-{collection_name}.tar.gz"
+        create_tar_file(init_path, tar_file)
+        return tar_file
diff --git a/src/ansible_dev_tools/resources/server/data/openapi.yaml b/src/ansible_dev_tools/resources/server/data/openapi.yaml
index e15b839f..1821e610 100644
--- a/src/ansible_dev_tools/resources/server/data/openapi.yaml
+++ b/src/ansible_dev_tools/resources/server/data/openapi.yaml
@@ -48,6 +48,50 @@ paths:
             application/json:
               schema:
                 $ref: "#/components/schemas/Error"
+  /v2/creator/collection:
+    post:
+      summary: Create a new collection project
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/CreatorCollection"
+        required: true
+      responses:
+        "201":
+          description: Created
+          content:
+            application/tar+gzip:
+              schema:
+                AnyValue: {}
+        "400":
+          description: Bad Request
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/Error"
+  /v2/creator/playbook:
+    post:
+      summary: Create a new playbook project
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/CreatorPlaybookNewCLI"
+        required: true
+      responses:
+        "201":
+          description: Created
+          content:
+            application/tar+gzip:
+              schema:
+                AnyValue: {}
+        "400":
+          description: Bad Request
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/Error"
 
 components:
   schemas:
@@ -69,6 +113,16 @@ components:
           type: string
         scm_org:
           type: string
+    CreatorPlaybookNewCLI:
+      type: object
+      additionalProperties: false
+      properties:
+        project:
+          type: string
+        collection_name:
+          type: string
+        namespace:
+          type: string
     Error:
       type: object
       properties:
diff --git a/src/ansible_dev_tools/subcommands/server.py b/src/ansible_dev_tools/subcommands/server.py
index 58520db4..09011d59 100644
--- a/src/ansible_dev_tools/subcommands/server.py
+++ b/src/ansible_dev_tools/subcommands/server.py
@@ -13,6 +13,7 @@
 from gunicorn.app.base import BaseApplication
 
 from ansible_dev_tools.resources.server.creator_v1 import CreatorFrontendV1
+from ansible_dev_tools.resources.server.creator_v2 import CreatorFrontendV2
 
 
 if TYPE_CHECKING:
@@ -22,6 +23,8 @@
 urlpatterns = (
     path(route="v1/creator/playbook", view=CreatorFrontendV1().playbook),
     path(route="v1/creator/collection", view=CreatorFrontendV1().collection),
+    path(route="v2/creator/playbook", view=CreatorFrontendV2().playbook),
+    path(route="v2/creator/collection", view=CreatorFrontendV2().collection),
 )
 
 


Test patch:
diff --git a/tests/integration/test_server_creator_v2.py b/tests/integration/test_server_creator_v2.py
new file mode 100644
index 00000000..bcbc0385
--- /dev/null
+++ b/tests/integration/test_server_creator_v2.py
@@ -0,0 +1,81 @@
+"""Test the dev tools server for creator."""
+
+from __future__ import annotations
+
+import tarfile
+
+from typing import TYPE_CHECKING
+
+import pytest
+import requests
+
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+
+@pytest.mark.parametrize("resource", ("playbook", "collection"))
+def test_error(server_url: str, resource: str) -> None:
+    """Test the error response.
+
+    Args:
+        server_url: The server URL.
+        resource: The resource to test.
+    """
+    response = requests.post(f"{server_url}/v2/creator/{resource}", timeout=10)
+    assert response.status_code == requests.codes.get("bad_request")
+    assert response.text == "Missing required request body"
+
+
+def test_playbook_v2(server_url: str, tmp_path: Path) -> None:
+    """Test the playbook creation.
+
+    Args:
+        server_url: The server URL.
+        tmp_path: Pytest tmp_path fixture.
+    """
+    response = requests.post(
+        f"{server_url}/v2/creator/playbook",
+        json={
+            "project": "ansible-project",
+            "namespace": "ansible",
+            "collection_name": "devops",
+        },
+        timeout=10,
+    )
+    assert response.status_code == requests.codes.get("created")
+    assert response.headers["Content-Disposition"] == 'attachment; filename="ansible-devops.tar.gz"'
+    assert response.headers["Content-Type"] == "application/tar+gzip"
+    dest_file = tmp_path / "ansible-devops.tar.gz"
+    with dest_file.open(mode="wb") as tar_file:
+        tar_file.write(response.content)
+    with tarfile.open(dest_file) as file:
+        assert (
+            "./collections/ansible_collections/ansible/devops/roles/run/README.md"
+            in file.getnames()
+        )
+
+
+def test_collection_v2(server_url: str, tmp_path: Path) -> None:
+    """Test the collection creation.
+
+    Args:
+        server_url: The server URL.
+        tmp_path: Pytest tmp_path fixture.
+    """
+    response = requests.post(
+        f"{server_url}/v2/creator/collection",
+        json={
+            "collection": "namespace.name",
+            "project": "collection",
+        },
+        timeout=10,
+    )
+    assert response.status_code == requests.codes.get("created")
+    assert response.headers["Content-Disposition"] == 'attachment; filename="namespace.name.tar.gz"'
+    assert response.headers["Content-Type"] == "application/tar+gzip"
+    dest_file = tmp_path / "namespace.name.tar.gz"
+    with dest_file.open(mode="wb") as tar_file:
+        tar_file.write(response.content)
+    with tarfile.open(dest_file) as file:
+        assert "./roles/run/tasks/main.yml" in file.getnames()