Repository: vitalik/django-ninja
Pull request #288 (2021-11-24 11:45:41)

Export OpenAPI schema management cmd
Hi!

I want to add a basic management command to export OpenAPI schema.


Patch:
diff --git a/docs/docs/tutorial/management-commands.md b/docs/docs/tutorial/management-commands.md
new file mode 100644
index 0000000..6d74f99
--- /dev/null
+++ b/docs/docs/tutorial/management-commands.md
@@ -0,0 +1,21 @@
+# Management Commands
+
+## Export OpenAPI scheme
+
+Add ninja to `INSTALLED_APPS`
+
+```python
+INSTALLED_APPS = [
+    ...
+    'ninja',
+    ...
+]
+```
+
+Run `python manage.py export_openapi_schema --api project.urls.api`
+
+## Options
+
+- `--help` show command help
+- `--api` specify api instance module (optional)
+- `--output` specify output file path (optional). You can omit this option to output in stdout.
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 7cbeb2c..00dbaad 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -5,8 +5,8 @@ repo_name: vitalik/django-ninja
 repo_url: https://github.com/vitalik/django-ninja
 edit_uri: ''
 google_analytics:
-- UA-170474207-1
-- auto
+  - UA-170474207-1
+  - auto
 theme:
   name: material
   palette:
@@ -17,44 +17,45 @@ theme:
   icon:
     repo: fontawesome/brands/github-alt
 nav:
-- Intro: index.md
-- motivation.md
-- Tutorial / Reference:
-  - tutorial/index.md
-  - tutorial/path-params.md
-  - tutorial/query-params.md
-  - tutorial/body.md
-  - tutorial/form-params.md
-  - tutorial/file-params.md
-  - tutorial/response-schema.md
-  - tutorial/crud.md
-  - tutorial/django-pydantic.md
-  - Advanced Schema:
-    - tutorial/django-pydantic-create-schema.md
-    - tutorial/config-pydantic.md
-  - tutorial/authentication.md
-  - tutorial/routers.md
-  - tutorial/operation_params.md
-  - tutorial/versioning.md
-  - tutorial/request-parsers.md
-  - tutorial/response-renderers.md
-  - tutorial/errors.md
-  - tutorial/csrf.md
-- async-support.md
-- video.md
-- Enhancement Proposals:
-  - Intro: proposals/index.md
-  - proposals/cbv.md
-  - proposals/v1.md
-- help.md
-- releases.md
+  - Intro: index.md
+  - motivation.md
+  - Tutorial / Reference:
+      - tutorial/index.md
+      - tutorial/path-params.md
+      - tutorial/query-params.md
+      - tutorial/body.md
+      - tutorial/form-params.md
+      - tutorial/file-params.md
+      - tutorial/response-schema.md
+      - tutorial/crud.md
+      - tutorial/django-pydantic.md
+      - Advanced Schema:
+          - tutorial/django-pydantic-create-schema.md
+          - tutorial/config-pydantic.md
+      - tutorial/authentication.md
+      - tutorial/routers.md
+      - tutorial/operation_params.md
+      - tutorial/versioning.md
+      - tutorial/request-parsers.md
+      - tutorial/response-renderers.md
+      - tutorial/errors.md
+      - tutorial/csrf.md
+      - tutorial/management-commands.md
+  - async-support.md
+  - video.md
+  - Enhancement Proposals:
+      - Intro: proposals/index.md
+      - proposals/cbv.md
+      - proposals/v1.md
+  - help.md
+  - releases.md
 markdown_extensions:
-- markdown_include.include
-- markdown.extensions.codehilite:
-    guess_lang: false
-# Uncomment these 2 lines during development to more easily add highlights
-#- pymdownx.highlight:
-#    linenums: true
-- codehilite
-- admonition
-- pymdownx.superfences
+  - markdown_include.include
+  - markdown.extensions.codehilite:
+      guess_lang: false
+  # Uncomment these 2 lines during development to more easily add highlights
+  #- pymdownx.highlight:
+  #    linenums: true
+  - codehilite
+  - admonition
+  - pymdownx.superfences
diff --git a/ninja/management/__init__.py b/ninja/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/ninja/management/commands/__init__.py b/ninja/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/ninja/management/commands/export_openapi_schema.py b/ninja/management/commands/export_openapi_schema.py
new file mode 100644
index 0000000..6bc6f32
--- /dev/null
+++ b/ninja/management/commands/export_openapi_schema.py
@@ -0,0 +1,50 @@
+import json
+from typing import Any, Optional
+
+from django.core.management.base import BaseCommand, CommandError, CommandParser
+from django.urls.base import resolve
+from django.utils.module_loading import import_string
+
+from ninja.main import NinjaAPI
+from ninja.responses import NinjaJSONEncoder
+
+
+class Command(BaseCommand):
+    help = "Exports Open API schema"
+
+    def _get_api_instance(self, api_path: Optional[str] = None) -> NinjaAPI:
+        if not api_path:
+            return resolve("/api/").func.keywords["api"]  # type: ignore
+
+        try:
+            api = import_string(api_path)
+        except ImportError:
+            raise CommandError(f"Module or attribute for {api_path} not found!")
+
+        if not isinstance(api, NinjaAPI):
+            raise CommandError(f"{api_path} is not instance of NinjaAPI!")
+
+        return api
+
+    def add_arguments(self, parser: CommandParser) -> None:
+        parser.add_argument(
+            "--api",
+            dest="api",
+            default=None,
+            type=str,
+            help="Specify api instance module",
+        )
+        parser.add_argument(
+            "--output", dest="output", default=None, type=str, help="Output schema path"
+        )
+
+    def handle(self, *args: Any, **options: Any) -> None:
+        api = self._get_api_instance(options["api"])
+        schema = api.get_openapi_schema()
+        output = json.dumps(schema, cls=NinjaJSONEncoder)
+
+        if options["output"]:
+            with open(options["output"], "wb") as f:
+                f.write(output.encode())
+        else:
+            self.stdout.write(output)


Test patch:
diff --git a/tests/test_export_openapi_schema.py b/tests/test_export_openapi_schema.py
new file mode 100644
index 0000000..20be17f
--- /dev/null
+++ b/tests/test_export_openapi_schema.py
@@ -0,0 +1,117 @@
+import json
+from io import StringIO
+
+import pytest
+from demo_project.demo.urls import api_v1
+from django.core.management import call_command
+from django.core.management.base import CommandError
+
+from ninja import NinjaAPI, Schema
+from ninja.management.commands import export_openapi_schema
+from ninja.responses import NinjaJSONEncoder
+
+api = NinjaAPI()
+
+
+class CustomAPI(NinjaAPI):
+    pass
+
+
+custom_api = CustomAPI()
+
+non_ninja_api_attr = object()
+
+
+class VersionSchema(Schema):
+    version: str
+
+
+@custom_api.get(
+    "/version",
+    response=VersionSchema,
+    operation_id="test_export_openapi_schema",
+)
+@api.get(
+    "/version",
+    response=VersionSchema,
+    operation_id="test_export_openapi_schema",
+)
+def get_version(request):
+    return {"version": "1.0.0"}
+
+
+@pytest.mark.parametrize("api_attr", (("api"), ("custom_api")))
+def test_export_openapi_schema_stdout(api_attr):
+    out = StringIO()
+
+    call_command(
+        export_openapi_schema.Command(),
+        api=f"tests.test_export_openapi_schema.{api_attr}",
+        stdout=out,
+    )
+
+    schema = json.dumps(api.get_openapi_schema(), cls=NinjaJSONEncoder) + "\n"
+
+    assert out.getvalue() == schema
+
+
+def test_export_openapi_schema_file(tmp_path):
+    output_file = tmp_path / "openapi.json"
+
+    call_command(
+        export_openapi_schema.Command(),
+        api="tests.test_export_openapi_schema.api",
+        output=str(output_file),
+    )
+
+    schema = json.dumps(api.get_openapi_schema(), cls=NinjaJSONEncoder)
+
+    assert output_file.read_text() == schema
+
+
+def test_no_module():
+    with pytest.raises(CommandError) as e:
+        call_command(
+            export_openapi_schema.Command(),
+            api="fake_module_123.api",
+        )
+
+    assert str(e.value) == "Module or attribute for fake_module_123.api not found!"
+
+
+def test_no_app_in_module():
+    with pytest.raises(CommandError) as e:
+        call_command(
+            export_openapi_schema.Command(),
+            api="tests.test_export_openapi_schema.api_123",
+        )
+
+    assert (
+        str(e.value)
+        == "Module or attribute for tests.test_export_openapi_schema.api_123 not found!"
+    )
+
+
+def test_non_ninja_api_app():
+    module_path = "tests.test_export_openapi_schema.non_ninja_api_attr"
+
+    with pytest.raises(CommandError) as e:
+        call_command(
+            export_openapi_schema.Command(),
+            api=module_path,
+        )
+
+    assert str(e.value) == f"{module_path} is not instance of NinjaAPI!"
+
+
+def test_export_with_default_options():
+    out = StringIO()
+
+    call_command(export_openapi_schema.Command(), stdout=out)
+
+    schema = json.dumps(api_v1.get_openapi_schema(), cls=NinjaJSONEncoder) + "\n"
+
+    assert out.getvalue() == schema
+
+
+# TODO: YML parser and schema export?