diff --git a/django_api_decorator/openapi.py b/django_api_decorator/openapi.py index 9bfab99..d99a58a 100644 --- a/django_api_decorator/openapi.py +++ b/django_api_decorator/openapi.py @@ -19,20 +19,28 @@ def get_resolved_url_patterns( base_patterns: Sequence[URLResolver | URLPattern], -) -> list[tuple[URLPattern, str]]: +) -> list[tuple[URLPattern, str, str]]: """ Given a list of base URL patterns, this function digs down into the URL hierarchy and evaluates the full URLs of all django views that have a simple path (e.g. no regex). Returns a list of tuples with each RoutePattern and its full URL. """ - unresolved_patterns: list[tuple[URLResolver | URLPattern, str]] = [ - (url_pattern, "/") for url_pattern in base_patterns + def combine_path(existing: str, append: str | None) -> str: + if not append: + return existing + elif existing == "": + return append + else: + return existing + ":" + append + + unresolved_patterns: list[tuple[URLResolver | URLPattern, str, str]] = [ + (url_pattern, "/", "") for url_pattern in base_patterns ] - resolved_urls: list[tuple[URLPattern, str]] = [] + resolved_urls: list[tuple[URLPattern, str, str]] = [] while len(unresolved_patterns): - url_pattern, url_prefix = unresolved_patterns.pop() + url_pattern, url_prefix, reverse_path = unresolved_patterns.pop() if not isinstance(url_pattern.pattern, RoutePattern): logger.debug("Skipping URL that is not simple (e.g. regex or locale url)") @@ -44,10 +52,13 @@ def get_resolved_url_patterns( # If we are dealing with a URL Resolver we should dig further down. if isinstance(url_pattern, URLResolver): unresolved_patterns += [ - (child_pattern, url) for child_pattern in url_pattern.url_patterns + (child_pattern, url, combine_path(reverse_path, url_pattern.namespace)) + for child_pattern in url_pattern.url_patterns ] else: - resolved_urls.append((url_pattern, url)) + resolved_urls.append( + (url_pattern, url, combine_path(reverse_path, url_pattern.name)) + ) return resolved_urls @@ -89,7 +100,11 @@ def replacer(match: re.Match[str]) -> str: def paths_and_types_for_view( - *, view_name: str, callback: Callable[..., HttpResponse], resolved_url: str + *, + view_name: str, + callback: Callable[..., HttpResponse], + resolved_url: str, + reverse_path: str, ) -> tuple[dict[str, Any], dict[str, Any]]: api_meta: ApiMeta | None = getattr(callback, "_api_meta", None) @@ -162,6 +177,7 @@ def to_ref_if_object(schema: dict[str, Any]) -> dict[str, Any]: "description": textwrap.dedent(callback.__doc__ or "").strip(), # Tags are useful for grouping operations in codegen "tags": [app_name], + "x-reverse-path": reverse_path, "parameters": parameters, **request_body, "responses": { @@ -189,6 +205,7 @@ class OpenApiOperation: callback: Callable[..., HttpResponse] name: str url: str + reverse_path: str all_urls = get_resolved_url_patterns(urlpatterns) @@ -196,7 +213,7 @@ class OpenApiOperation: # Iterate through all django views within the url patterns and generate specs for # them - for pattern, resolved_url in all_urls: + for pattern, resolved_url, reverse_path in all_urls: if pattern.callback is None: continue @@ -226,6 +243,7 @@ class OpenApiOperation: f"{pattern.name or pattern.callback.__name__}" ), url=resolved_url, + reverse_path=reverse_path, ) ) @@ -235,6 +253,7 @@ class OpenApiOperation: callback=pattern.callback, name=pattern.name or pattern.callback.__name__, url=resolved_url, + reverse_path=reverse_path, ) ) @@ -251,6 +270,7 @@ class OpenApiOperation: view_name=operation.name, callback=operation.callback, resolved_url=operation.url, + reverse_path=operation.reverse_path, ) api_components.update(components) diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 8ad0543..d0a3e74 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -4,11 +4,12 @@ from django.http import HttpRequest from django.test.client import Client from django.test.utils import override_settings -from django.urls import path +from django.urls import URLPattern, URLResolver, path +from django.urls.resolvers import RoutePattern from pydantic import BaseModel from django_api_decorator.decorators import api -from django_api_decorator.openapi import generate_api_spec +from django_api_decorator.openapi import generate_api_spec, get_resolved_url_patterns urlpatterns = None @@ -61,7 +62,7 @@ def view( return Response(state=State.OK, num=3) urls = [ - path("/", view), + path("/", view, name="view_name"), ] assert generate_api_spec(urls) == { @@ -70,9 +71,10 @@ def view( "paths": { "/{path_str}/{path_int}": { "post": { - "operationId": "view", + "operationId": "view_name", "description": "", "tags": ["test_openapi"], + "x-reverse-path": "view_name", "parameters": [ { "name": "path_str", @@ -239,6 +241,7 @@ def view(request: HttpRequest) -> A | B | C: "operationId": "view", "description": "", "tags": ["test_openapi"], + "x-reverse-path": "", "parameters": [], "responses": { 200: { @@ -282,3 +285,28 @@ def view(request: HttpRequest) -> A | B | C: } }, } + + +def test_get_resolved_url_patterns() -> None: + child_pattern = URLPattern( + RoutePattern("child_pattern/nested/deep/"), lambda x: x, name="child_view" + ) + + base_patterns = [ + URLResolver( + pattern=RoutePattern("toplevel_pattern/"), + urlconf_name=[ + child_pattern, + ], + namespace="top_namespace", + ) + ] + + result = get_resolved_url_patterns(base_patterns) + assert result == [ + ( + child_pattern, + "/toplevel_pattern/child_pattern/nested/deep/", + "top_namespace:child_view", + ) + ] diff --git a/tests/test_response_encoding.py b/tests/test_response_encoding.py index ae1d5a1..841698e 100644 --- a/tests/test_response_encoding.py +++ b/tests/test_response_encoding.py @@ -113,6 +113,7 @@ def test_schema() -> None: "operationId": "view_union", "description": "", "tags": ["test_response_encoding"], + "x-reverse-path": "", "parameters": [], "responses": { 200: { @@ -136,6 +137,7 @@ def test_schema() -> None: "operationId": "view_camel_case_pydantic_model", "description": "", "tags": ["test_response_encoding"], + "x-reverse-path": "", "parameters": [], "responses": { 200: { @@ -156,6 +158,7 @@ def test_schema() -> None: "operationId": "view_pydantic_model", "description": "", "tags": ["test_response_encoding"], + "x-reverse-path": "", "parameters": [], "responses": { 200: { @@ -176,6 +179,7 @@ def test_schema() -> None: "operationId": "view_bool", "description": "", "tags": ["test_response_encoding"], + "x-reverse-path": "", "parameters": [], "responses": { 200: { @@ -192,6 +196,7 @@ def test_schema() -> None: "operationId": "view_int", "description": "", "tags": ["test_response_encoding"], + "x-reverse-path": "", "parameters": [], "responses": { 200: { @@ -208,6 +213,7 @@ def test_schema() -> None: "operationId": "view_typed_dict", "description": "", "tags": ["test_response_encoding"], + "x-reverse-path": "", "parameters": [], "responses": { 200: { @@ -228,6 +234,7 @@ def test_schema() -> None: "operationId": "view_json_response", "description": "", "tags": ["test_response_encoding"], + "x-reverse-path": "", "parameters": [], "responses": {200: {"description": ""}}, }