【Django Ninja】定義されているパスを全取得

# はじめに

「[Django](https://www.djangoproject.com/)と[Django REST framework](https://www.django-rest-framework.org/)で定義されているエンドポイントを全て取得する」という内容で記事を書こうと考えていたところ、[Django Ninja](https://django-ninja.rest-framework.com/)を発見してしまいました。
Django Ninjaは2023/06現在、まだメジャーバージョンが出ておりません。
ただ、結構イケていそうで触ってみたい気持ちが勝ったのと、Django NinjaでもDRFでも記事にしたかった部分は変わらないので、「Django Ninjaで定義されているエンドポイントを全て取得する」方法について書いていきます。
よろしくお願いします。

# ディレクトリ構成

基本的には[Django Ninjaのチュートリアル](https://django-ninja.rest-framework.com/tutorial/)に沿って定義します。
今回は `account` と `item` という抽象的なアプリケーション2つと、エンドポイント全てを取得するアプリケーション `all_url` を定義しています。

環境は[poetry](https://python-poetry.org/)で作っちゃいました。

```
.
├── django_project
│   ├── apps
│   │   ├── __init__.py
│   │   ├── account
│   │   │   ├── __init__.py
│   │   │   ├── apps.py
│   │   │   ├── schema.py
│   │   │   └── views.py
│   │   ├── all_url
│   │   │   ├── __init__.py
│   │   │   ├── apps.py
│   │   │   └── views.py
│   │   └── item
│   │       ├── __init__.py
│   │       ├── apps.py
│   │       ├── schema.py
│   │       └── views.py
│   ├── django_project
│   │   ├── __init__.py
│   │   ├── asgi.py
│   │   ├── settings.py
│   │   ├── urls.py
│   │   └── wsgi.py
│   └── manage.py
├── poetry.lock
└── pyproject.toml
```

# エンドポイント定義

それぞれviewsを定義します。
返り値とかリクエストボディとかは今回は何でもいいので深掘りません。

全てのエンドポイントを取得するにあたって、url名がほしいので `url_name` を定義しています。([参考](https://django-ninja.rest-framework.com/guides/urls/))
Djangoでは[django.urls.path](https://docs.djangoproject.com/en/4.2/ref/urls/)の引数に `name` があるので、ここで定義します。

- account.views

```py
from ninja import Router

from .schema import Account

router = Router()

@router.get('/', url_name='account_list')
def account_list(request):
    return [
        'account_1',
        'account_2',
        'account_3'
    ]

@router.get('/{int:account_id}', url_name='account_detail')
def account_detail(request, account_id: int):
    return {'item_id': account_id}

@router.post('/', url_name='account_create')
def create(request, item: Account):
    return item
```

- item.views

```py
from ninja import Router

from .schema import Item

router = Router()

@router.get('/', url_name='item_list')
def item_list(request):
    return [
        'item_1',
        'item_2',
        'item_3'
    ]

@router.get('/{int:item_id}', url_name='item_detail')
def item_detail(request, item_id: int):
    return {'item_id': item_id}

@router.post('/', url_name='item_create')
def create(request, item: Item):
    return item
```

そして今回の肝となるエンドポイント全取得APIです。
responseにオブジェクトは含められないので、 `url_name` を返却しています。

- all_url.view

```py
from ninja import Router
from django.urls import get_resolver, URLResolver, URLPattern

router = Router()

@router.get('/', url_name='all_url_list')
def all_url_list(request):
    targets = [
        url
        for url in get_resolver().url_patterns
    ]

    all_url = list()

    def dfs_urls(url_patterns):
        for url in url_patterns:
            if isinstance(url, URLResolver):
                dfs_urls(url.url_patterns)
            elif isinstance(url, URLPattern):
                all_url.append(url.name)

    dfs_urls(targets)

    return all_url
```

`get_resolver()` でエンドポイント解決周りのあれこれが取ってこれるので、 `url_patterns` で一覧を取得。`URLResolver` または `URLPattern` のユニオン型リストとなっています。
`URLResolver`は[include](https://docs.djangoproject.com/en/4.2/ref/urls/#include)で定義されたもの、`URLPattern`は[path](https://docs.djangoproject.com/en/4.2/ref/urls/#path)で定義されたものを指します。
[深さ優先探索](https://qiita.com/drken/items/4a7869c5e304883f539b)を用いて再帰的に潜ってやることでエンドポイントを全て取得します。


これでAPIはすべて定義しました。
今度はこれらを `urlpatterns` に登録していきます。

# urlpatterns

`django_project.urls.py` に記載します。
[Reverse Resolution of URLS](https://django-ninja.rest-framework.com/guides/urls/)に従うがまま書きました。

```py
from django.contrib import admin
from django.urls import path
from ninja import NinjaAPI
from apps.item.views import router as item_router
from apps.account.views import router as account_router
from apps.all_url.views import router as all_url_router

api = NinjaAPI()

api.add_router('/items/', item_router)
api.add_router('/accounts/', account_router)
api.add_router('/endpoint/', all_url_router)

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', api.urls),
]
```

# 動かしてみる

```sh
poetry shell
cd path/to/dir
python manage.py runserver 8080
```

- response

```json
[
	"index",
	"login",
	"logout",
	"password_change",
	"password_change_done",
	"autocomplete",
	"jsi18n",
	"view_on_site",
	"auth_group_changelist",
	"auth_group_add",
	"auth_group_history",
	"auth_group_delete",
	"auth_group_change",
	null,
	"auth_user_password_change",
	"auth_user_changelist",
	"auth_user_add",
	"auth_user_history",
	"auth_user_delete",
	"auth_user_change",
	null,
	"app_list",
	null,
	"openapi-json",
	"openapi-view",
	"item_list",
	"item_create",
	"item_detail",
	"account_list",
	"account_create",
	"account_detail",
	"all_url_list",
	"api-root"
]
```

下の方にviewで定義した `url_name` が見受けられます。
でも余計なのも多い。

# よけいなのを消す

`if url.app_name not in ['admin']` を `all_url.view` に追加します。

- all_url.view

```diff
from ninja import Router
from django.urls import get_resolver, URLResolver, URLPattern

router = Router()

@router.get('/', url_name='all_url_list')
def all_url_list(request):
    targets = [
        url
        for url in get_resolver().url_patterns
+       if url.app_name not in ['admin']
    ]

    all_url = list()

    def dfs_urls(url_patterns):
        for url in url_patterns:
            if isinstance(url, URLResolver):
                dfs_urls(url.url_patterns)
            elif isinstance(url, URLPattern):
                all_url.append(url.name)

    dfs_urls(targets)

    return all_url
```

かなりすっきり
`url.app_name` というのはDjangoの[URL名の名前空間](https://docs.djangoproject.com/ja/4.1/intro/tutorial03/#namespacing-url-names)で定義されたものになります。
`urlpatterns` の最初にある `path('admin/', admin.site.urls),` が名前空間 `admin` で定義されているため、リスト内包表記内で弾くことができました。

```json
[
	"openapi-json",
	"openapi-view",
	"item_list",
	"item_create",
	"item_detail",
	"account_list",
	"account_create",
	"account_detail",
	"all_url_list",
	"api-root"
]
```

ただまだ自分で定義していないのがいます。
Django Ninjaでは[Open API](https://django-ninja.rest-framework.com/guides/api-docs/)の生成もよしなにやってくれているのでそれっぽいですね。

ちなみにDjango Ninjaみたいなのを噛ませず、pathとincludeでやる分にはこれで自分が定義したpath_nameのみ全て取ってこれます。
ルーティングがネストしていても問題ありません。

ここまででninjaもninjaでURLの名前空間が定義されているのでは？　と考えられます。
確認してみましょう。

- all_url.view

```diff
from ninja import Router
from django.urls import get_resolver, URLResolver, URLPattern

router = Router()

@router.get('/', url_name='all_url_list')
def all_url_list(request):
    targets = [
        url
        for url in get_resolver().url_patterns
        if url.app_name not in ['admin']
    ]

    all_url = list()

+   print([t.app_name for t in targets])

    def dfs_urls(url_patterns):
        for url in url_patterns:
            if isinstance(url, URLResolver):
                dfs_urls(url.url_patterns)
            elif isinstance(url, URLPattern):
                all_url.append(url.name)

    dfs_urls(targets)

    return all_url
```

- 実行結果

```json
["ninja"]
```

なるほど。ninjaを弾いてみます。

- all_url.view

```diff
from ninja import Router
from django.urls import get_resolver, URLResolver, URLPattern

router = Router()

@router.get('/', url_name='all_url_list')
def all_url_list(request):
    targets = [
        url
        for url in get_resolver().url_patterns
+       if url.app_name not in ['admin', 'ninja']
    ]

    all_url = list()

    def dfs_urls(url_patterns):
        for url in url_patterns:
            if isinstance(url, URLResolver):
                dfs_urls(url.url_patterns)
            elif isinstance(url, URLPattern):
                all_url.append(url.name)

    dfs_urls(targets)

    return all_url
```

- 実行結果

```json
[]
```

なにもなくなりました。
routerのパス定義がDjango Ninjaに乗っかっているので当然っちゃ当然ですね。

なんとか完璧に取り出せないか試してみたのですが、ソースコードを読みに行ったら無理そうでした。

- [NinjaAPI.urls](https://github.com/vitalik/django-ninja/blob/master/ninja/main.py#L373-L388)
- [django.contrib.admin(比較用)](https://github.com/django/django/blob/main/django/contrib/admin/sites.py#L315-L317)

ちなみにどうしても出てきてしまう `openapi-***` はopenAPIのパスで、デフォルトで生成されますがオフにすることもできます。

- [api-docs](https://django-ninja.rest-framework.com/guides/api-docs/)

# おわりに

Django Ninjaみたいなのを噛ませず、pathとincludeでやる分にはここで紹介した方法でキレイに取れます。
それでじゃあこれの使いどころは何処かと言うと……テストとかですかね？
終わります。