vitalik / django-ninja

💨 Fast, Async-ready, Openapi, type hints based framework for building APIs
https://django-ninja.dev
MIT License
7.23k stars 429 forks source link

Add routers to multiple NinjaAPI instances #460

Open M3te0r opened 2 years ago

M3te0r commented 2 years ago

Is your feature request related to a problem? Please describe. A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

Hi Vitaliy, first thank you for this really great project !

I would like to reuse some of my routers with multiple NinjaAPI instances. Sharing some common routers (and paths) with the end user API and the Admin API instances; but having different urls_namespace, docs, auth etc In my case the first attempt was to share the same login router but it resulted in an error

Here is a reproductible minimal example :

from ninja import Router
from ninja import NinjaAPI

api = NinjaAPI(
    #auth=JWTUserAuth(),
    version="1.0.0"
)

admin_api = NinjaAPI(
    #auth=JWTAdminAuth(),
    urls_namespace="admin_api",
    version="Admin-1.0.0"
)

router = Router()

@router.get("/foo")
def get_foo(request):
    return 200

api.add_router("/bar", router)
admin_api.add_router("/bar", router)
Exception in thread django-main-thread:
Traceback (most recent call last):
  File "/home/mete0r/.pyenv/versions/3.10.4/lib/python3.10/threading.py", line 1009, in _bootstrap_inner
    self.run()
  File "/home/mete0r/.pyenv/versions/3.10.4/lib/python3.10/threading.py", line 946, in run
    self._target(*self._args, **self._kwargs)
  File "/home/mete0r/PycharmProjects/bepatient-server/.venv/lib/python3.10/site-packages/django/utils/autoreload.py", line 64, in wrapper
    fn(*args, **kwargs)
  File "/home/mete0r/PycharmProjects/bepatient-server/.venv/lib/python3.10/site-packages/django/core/management/commands/runserver.py", line 134, in inner_run
    self.check(display_num_errors=True)
  File "/home/mete0r/PycharmProjects/bepatient-server/.venv/lib/python3.10/site-packages/django/core/management/base.py", line 487, in check
    all_issues = checks.run_checks(
  File "/home/mete0r/PycharmProjects/bepatient-server/.venv/lib/python3.10/site-packages/django/core/checks/registry.py", line 88, in run_checks
    new_errors = check(app_configs=app_configs, databases=databases)
  File "/home/mete0r/PycharmProjects/bepatient-server/.venv/lib/python3.10/site-packages/django/core/checks/urls.py", line 14, in check_url_config
    return check_resolver(resolver)
  File "/home/mete0r/PycharmProjects/bepatient-server/.venv/lib/python3.10/site-packages/django/core/checks/urls.py", line 24, in check_resolver
    return check_method()
  File "/home/mete0r/PycharmProjects/bepatient-server/.venv/lib/python3.10/site-packages/django/urls/resolvers.py", line 480, in check
    for pattern in self.url_patterns:
  File "/home/mete0r/PycharmProjects/bepatient-server/.venv/lib/python3.10/site-packages/django/utils/functional.py", line 49, in __get__
    res = instance.__dict__[self.name] = self.func(instance)
  File "/home/mete0r/PycharmProjects/bepatient-server/.venv/lib/python3.10/site-packages/django/urls/resolvers.py", line 696, in url_patterns
    patterns = getattr(self.urlconf_module, "urlpatterns", self.urlconf_module)
  File "/home/mete0r/PycharmProjects/bepatient-server/.venv/lib/python3.10/site-packages/django/utils/functional.py", line 49, in __get__
    res = instance.__dict__[self.name] = self.func(instance)
  File "/home/mete0r/PycharmProjects/bepatient-server/.venv/lib/python3.10/site-packages/django/urls/resolvers.py", line 689, in urlconf_module
    return import_module(self.urlconf_name)
  File "/home/mete0r/.pyenv/versions/3.10.4/lib/python3.10/importlib/__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1050, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1027, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1006, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 688, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 883, in exec_module
  File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
  File "/home/mete0r/PycharmProjects/bepatient-server/config/urls.py", line 14, in <module>
    path("api/", include("nova.api.urls")),
  File "/home/mete0r/PycharmProjects/bepatient-server/.venv/lib/python3.10/site-packages/django/urls/conf.py", line 38, in include
    urlconf_module = import_module(urlconf_module)
  File "/home/mete0r/.pyenv/versions/3.10.4/lib/python3.10/importlib/__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1050, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1027, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1006, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 688, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 883, in exec_module
  File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
  File "/home/mete0r/PycharmProjects/bepatient-server/nova/api/urls.py", line 3, in <module>
    from nova.api.endpoints import api
  File "/home/mete0r/PycharmProjects/bepatient-server/nova/api/endpoints.py", line 20, in <module>
    admin_api.add_router("/bar", router)
  File "/home/mete0r/PycharmProjects/bepatient-server/.venv/lib/python3.10/site-packages/ninja/main.py", line 310, in add_router
    self._routers.extend(router.build_routers(prefix))
  File "/home/mete0r/PycharmProjects/bepatient-server/.venv/lib/python3.10/site-packages/ninja/router.py", line 356, in build_routers
    raise ConfigError(
ninja.errors.ConfigError: Router@'/bar' has already been attached to API NinjaAPI:1.0.0 

Describe the solution you'd like A clear and concise description of what you want to happen.

I don't know if this is something achievable, I saw that the api is attached to the router with router.set_api_instance(self)

selcuk commented 1 year ago

This is a much needed feature for DRY. Currently one router can only be attached to one API instance. For context, FastAPI supports this feature:

https://fastapi.tiangolo.com/tutorial/bigger-applications/#include-the-same-router-multiple-times-with-different-prefix

You can also use .include_router() multiple times with the same router using different prefixes.

This could be useful, for example, to expose the same API under different prefixes, e.g. /api/v1 and /api/latest.

I used the following as a workaround. It is not the cleanest solution but seems to be working (not thoroughly tested):

from copy import copy

api_latest = NinjaAPI(version="v1", urls_namespace="latest")
api_latest.add_router("/bar/", copy(router))

api_v1 = NinjaAPI(version="v1")
api_v1.add_router("/bar/", router)

Make sure that you attach the copy() of the router before you attach the original one.

kidswong999 commented 6 months ago

This is a much needed feature for DRY. Currently one router can only be attached to one API instance. For context, FastAPI supports this feature:

https://fastapi.tiangolo.com/tutorial/bigger-applications/#include-the-same-router-multiple-times-with-different-prefix

You can also use .include_router() multiple times with the same router using different prefixes. This could be useful, for example, to expose the same API under different prefixes, e.g. /api/v1 and /api/latest.

I used the following as a workaround. It is not the cleanest solution but seems to be working (not thoroughly tested):

from copy import copy

api_latest = NinjaAPI(version="v1", urls_namespace="latest")
api_latest.add_router("/bar/", copy(router))

api_v1 = NinjaAPI(version="v1")
api_v1.add_router("/bar/", router)

Make sure that you attach the copy() of the router before you attach the original one.

When using copy, the router auth will be overwritten, and it is better to use deepcopy.

dvf commented 1 month ago

This also creates an issue with testing if you'd like to test a specific router without importing the whole API to your tests. This is to prevent cross-dependencies between Django apps:

@pytest.fixture(scope="session")
def api_mock() -> NinjaAPI:
    api = NinjaAPI(urls_namespace="tests", version="vt")

    def sample_view(request: AuthedRequest):
        return {"message": "secret_things"}

    # Add a protected view
    auth_router.api_operation("GET", "protected/", auth=JWTAuth())(sample_view)

    # Add the views to the API (make sure it's the same as the real API)
    api.add_router("/auth", auth_router)

    # Use the same exception handler as the real API so that errors are formatted the same
    api.add_exception_handler(APIError, handler=api_error)

    return api