diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 73afd47..cb65e6f 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -60,17 +60,16 @@ jobs: - flask - flask==2.3.* - flask==2.0.3 Werkzeug==2.* - - djangorestframework django - - djangorestframework django==4.2.* - - djangorestframework==3.12.* django==3.2.* - - djangorestframework==3.10.* django==2.2.* + - djangorestframework django uritemplate inflection + - djangorestframework django==4.2.* uritemplate inflection + - djangorestframework==3.12.* django==3.2.* uritemplate + - djangorestframework==3.10.* django==2.2.* uritemplate - django-ninja django - django-ninja==0.22.* django - django-ninja==0.18.0 django - litestar - litestar==2.6.1 - litestar==2.0.1 - steps: - uses: actions/checkout@v4 - name: Load cached Poetry installation diff --git a/apitally/django.py b/apitally/django.py index dd76de2..0137ec9 100644 --- a/apitally/django.py +++ b/apitally/django.py @@ -1,25 +1,29 @@ from __future__ import annotations +import contextlib import json +import re import time from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional +from importlib import import_module +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Union from django.conf import settings -from django.core.exceptions import ViewDoesNotExist -from django.test import RequestFactory -from django.urls import Resolver404, URLPattern, URLResolver, get_resolver, resolve +from django.urls import URLPattern, URLResolver, get_resolver from django.utils.module_loading import import_string +from apitally.client.logging import get_logger from apitally.client.threading import ApitallyClient from apitally.common import get_versions if TYPE_CHECKING: from django.http import HttpRequest, HttpResponse + from ninja import NinjaAPI __all__ = ["ApitallyMiddleware"] +logger = get_logger(__name__) @dataclass @@ -27,8 +31,8 @@ class ApitallyMiddlewareConfig: client_id: str env: str app_version: Optional[str] - openapi_url: Optional[str] identify_consumer_callback: Optional[Callable[[HttpRequest], Optional[str]]] + urlconfs: List[Optional[str]] class ApitallyMiddleware: @@ -36,21 +40,24 @@ class ApitallyMiddleware: def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: self.get_response = get_response + self.ninja_available = _check_import("ninja") + self.drf_endpoint_enumerator = None + if _check_import("rest_framework"): + from rest_framework.schemas.generators import EndpointEnumerator + + self.drf_endpoint_enumerator = EndpointEnumerator() + if self.config is None: config = getattr(settings, "APITALLY_MIDDLEWARE", {}) self.configure(**config) assert self.config is not None - views = _extract_views_from_url_patterns(get_resolver().url_patterns) - self.view_lookup = { - view.pattern: view for view in reversed(_extract_views_from_url_patterns(get_resolver().url_patterns)) - } + self.client = ApitallyClient(client_id=self.config.client_id, env=self.config.env) self.client.start_sync_loop() self.client.set_app_info( app_info=_get_app_info( - views=views, app_version=self.config.app_version, - openapi_url=self.config.openapi_url, + urlconfs=self.config.urlconfs, ) ) @@ -60,193 +67,218 @@ def configure( client_id: str, env: str = "dev", app_version: Optional[str] = None, - openapi_url: Optional[str] = None, identify_consumer_callback: Optional[str] = None, + urlconf: Optional[Union[List[Optional[str]], str]] = None, ) -> None: cls.config = ApitallyMiddlewareConfig( client_id=client_id, env=env, app_version=app_version, - openapi_url=openapi_url, identify_consumer_callback=import_string(identify_consumer_callback) if identify_consumer_callback else None, + urlconfs=[urlconf] if urlconf is None or isinstance(urlconf, str) else urlconf, ) def __call__(self, request: HttpRequest) -> HttpResponse: - view = self.get_view(request) start_time = time.perf_counter() response = self.get_response(request) - if request.method is not None and view is not None and view.is_api_view: + response_time = time.perf_counter() - start_time + path = self.get_path(request) + if request.method is not None and path is not None: consumer = self.get_consumer(request) - self.client.request_counter.add_request( - consumer=consumer, - method=request.method, - path=view.pattern, - status_code=response.status_code, - response_time=time.perf_counter() - start_time, - request_size=request.headers.get("Content-Length"), - response_size=response["Content-Length"] - if response.has_header("Content-Length") - else (len(response.content) if not response.streaming else None), - ) + try: + self.client.request_counter.add_request( + consumer=consumer, + method=request.method, + path=path, + status_code=response.status_code, + response_time=response_time, + request_size=request.headers.get("Content-Length"), + response_size=response["Content-Length"] + if response.has_header("Content-Length") + else (len(response.content) if not response.streaming else None), + ) + except Exception: # pragma: no cover + logger.exception("Failed to log request metadata") if ( response.status_code == 422 and (content_type := response.get("Content-Type")) is not None and content_type.startswith("application/json") ): try: - body = json.loads(response.content) - if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list): - # Log Django Ninja / Pydantic validation errors - self.client.validation_error_counter.add_validation_errors( - consumer=consumer, - method=request.method, - path=view.pattern, - detail=body["detail"], - ) - except json.JSONDecodeError: # pragma: no cover - pass + with contextlib.suppress(json.JSONDecodeError): + body = json.loads(response.content) + if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list): + # Log Django Ninja / Pydantic validation errors + self.client.validation_error_counter.add_validation_errors( + consumer=consumer, + method=request.method, + path=path, + detail=body["detail"], + ) + except Exception: # pragma: no cover + logger.exception("Failed to log validation errors") return response - def get_view(self, request: HttpRequest) -> Optional[DjangoViewInfo]: - try: - resolver_match = resolve(request.path_info) - return self.view_lookup.get(resolver_match.route) - except Resolver404: # pragma: no cover - return None + def get_path(self, request: HttpRequest) -> Optional[str]: + if (match := request.resolver_match) is not None: + try: + if self.drf_endpoint_enumerator is not None: + from rest_framework.schemas.generators import is_api_view + + if is_api_view(match.func): + return self.drf_endpoint_enumerator.get_path_from_regex(match.route) + if self.ninja_available: + from ninja.operation import PathView + + if hasattr(match.func, "__self__") and isinstance(match.func.__self__, PathView): + path = "/" + match.route.lstrip("/") + return re.sub(r"<(?:[^:]+:)?([^>:]+)>", r"{\1}", path) + except Exception: # pragma: no cover + logger.exception("Failed to get path for request") + return None def get_consumer(self, request: HttpRequest) -> Optional[str]: - if hasattr(request, "consumer_identifier"): - return str(request.consumer_identifier) - if self.config is not None and self.config.identify_consumer_callback is not None: - consumer_identifier = self.config.identify_consumer_callback(request) - if consumer_identifier is not None: - return str(consumer_identifier) + try: + if hasattr(request, "consumer_identifier"): + return str(request.consumer_identifier) + if self.config is not None and self.config.identify_consumer_callback is not None: + consumer_identifier = self.config.identify_consumer_callback(request) + if consumer_identifier is not None: + return str(consumer_identifier) + except Exception: # pragma: no cover + logger.exception("Failed to get consumer identifier for request") return None -@dataclass -class DjangoViewInfo: - func: Callable - pattern: str - name: Optional[str] = None - - @property - def is_api_view(self) -> bool: - return self.is_rest_framework_api_view or self.is_ninja_path_view - - @property - def is_rest_framework_api_view(self) -> bool: - try: - from rest_framework.views import APIView - - return hasattr(self.func, "view_class") and issubclass(self.func.view_class, APIView) - except ImportError: # pragma: no cover - return False +def _get_app_info(app_version: Optional[str], urlconfs: List[Optional[str]]) -> Dict[str, Any]: + app_info: Dict[str, Any] = {} + try: + app_info["paths"] = _get_paths(urlconfs) + except Exception: # pragma: no cover + app_info["paths"] = [] + logger.exception("Failed to get paths") + try: + app_info["openapi"] = _get_openapi(urlconfs) + except Exception: # pragma: no cover + logger.exception("Failed to get OpenAPI schema") + app_info["versions"] = get_versions("django", "djangorestframework", "django-ninja", app_version=app_version) + app_info["client"] = "python:django" + return app_info - @property - def is_ninja_path_view(self) -> bool: - try: - from ninja.operation import PathView - return hasattr(self.func, "__self__") and isinstance(self.func.__self__, PathView) - except ImportError: # pragma: no cover - return False +def _get_openapi(urlconfs: List[Optional[str]]) -> Optional[str]: + drf_schema = None + ninja_schema = None + with contextlib.suppress(ImportError): + drf_schema = _get_drf_schema(urlconfs) + with contextlib.suppress(ImportError): + ninja_schema = _get_ninja_schema(urlconfs) + if drf_schema is not None and ninja_schema is None: + return json.dumps(drf_schema) + elif ninja_schema is not None and drf_schema is None: + return json.dumps(ninja_schema) + return None # pragma: no cover - @property - def allowed_methods(self) -> List[str]: - if hasattr(self.func, "view_class"): - return [method.upper() for method in self.func.view_class().allowed_methods] - if self.is_ninja_path_view: - assert hasattr(self.func, "__self__") - return [method.upper() for operation in self.func.__self__.operations for method in operation.methods] - return [] # pragma: no cover +def _get_paths(urlconfs: List[Optional[str]]) -> List[Dict[str, str]]: + paths = [] + with contextlib.suppress(ImportError): + paths.extend(_get_drf_paths(urlconfs)) + with contextlib.suppress(ImportError): + paths.extend(_get_ninja_paths(urlconfs)) + return paths -def _get_app_info( - views: List[DjangoViewInfo], app_version: Optional[str] = None, openapi_url: Optional[str] = None -) -> Dict[str, Any]: - app_info: Dict[str, Any] = {} - if openapi := _get_openapi(views, openapi_url): - app_info["openapi"] = openapi - app_info["paths"] = _get_paths(views) - app_info["versions"] = get_versions("django", "djangorestframework", "django-ninja", app_version=app_version) - app_info["client"] = "python:django" - return app_info +def _get_drf_paths(urlconfs: List[Optional[str]]) -> List[Dict[str, str]]: + from rest_framework.schemas.generators import EndpointEnumerator -def _get_paths(views: List[DjangoViewInfo]) -> List[Dict[str, str]]: + enumerators = [EndpointEnumerator(urlconf=urlconf) for urlconf in urlconfs] return [ - {"method": method, "path": view.pattern} - for view in views - if view.is_api_view - for method in view.allowed_methods + { + "method": method.upper(), + "path": path, + } + for enumerator in enumerators + for path, method, _ in enumerator.get_api_endpoints() if method not in ["HEAD", "OPTIONS"] ] -def _get_openapi(views: List[DjangoViewInfo], openapi_url: Optional[str] = None) -> Optional[str]: - openapi_views = [ - view - for view in views - if (openapi_url is not None and view.pattern == openapi_url.removeprefix("/")) - or (openapi_url is None and view.pattern.endswith("openapi.json") and "<" not in view.pattern) - ] - if len(openapi_views) == 1: - rf = RequestFactory() - request = rf.get(openapi_views[0].pattern) - response = openapi_views[0].func(request) - if response.status_code == 200: - return response.content.decode() - return None - - -def _extract_views_from_url_patterns( - url_patterns: List[Any], base: str = "", namespace: Optional[str] = None -) -> List[DjangoViewInfo]: - # Copied and adapted from django-extensions. - # See https://github.com/django-extensions/django-extensions/blob/dd794f1b239d657f62d40f2c3178200978328ed7/django_extensions/management/commands/show_urls.py#L190C34-L190C34 - views = [] - for p in url_patterns: - if isinstance(p, URLPattern): - try: - if not p.name: - name = p.name - elif namespace: - name = f"{namespace}:{p.name}" - else: - name = p.name - views.append(DjangoViewInfo(func=p.callback, pattern=base + str(p.pattern), name=name)) - except ViewDoesNotExist: - continue - elif isinstance(p, URLResolver): - try: - patterns = p.url_patterns - except ImportError: - continue - views.extend( - _extract_views_from_url_patterns( - patterns, - base + str(p.pattern), - namespace=f"{namespace}:{p.namespace}" if namespace and p.namespace else p.namespace or namespace, - ) - ) - elif hasattr(p, "_get_callback"): - try: - views.append(DjangoViewInfo(func=p._get_callback(), pattern=base + str(p.pattern), name=p.name)) - except ViewDoesNotExist: - continue - elif hasattr(p, "url_patterns"): - try: - patterns = p.url_patterns - except ImportError: - continue - views.extend( - _extract_views_from_url_patterns( - patterns, - base + str(p.pattern), - namespace=namespace, - ) - ) - return views +def _get_drf_schema(urlconfs: List[Optional[str]]) -> Optional[Dict[str, Any]]: + from rest_framework.schemas.openapi import SchemaGenerator + + schemas = [] + with contextlib.suppress(AssertionError): # uritemplate and inflection must be installed for OpenAPI schema support + for urlconf in urlconfs: + generator = SchemaGenerator(urlconf=urlconf) + schema = generator.get_schema() + if schema is not None and len(schema["paths"]) > 0: + schemas.append(schema) + return None if len(schemas) != 1 else schemas[0] # type: ignore[return-value] + + +def _get_ninja_paths(urlconfs: List[Optional[str]]) -> List[Dict[str, str]]: + endpoints = [] + for api in _get_ninja_api_instances(urlconfs=urlconfs): + schema = api.get_openapi_schema() + for path, operations in schema["paths"].items(): + for method, operation in operations.items(): + if method not in ["HEAD", "OPTIONS"]: + endpoints.append( + { + "method": method, + "path": path, + "summary": operation.get("summary"), + "description": operation.get("description"), + } + ) + return endpoints + + +def _get_ninja_schema(urlconfs: List[Optional[str]]) -> Optional[Dict[str, Any]]: + schemas = [] + for api in _get_ninja_api_instances(urlconfs=urlconfs): + schema = api.get_openapi_schema() + if len(schema["paths"]) > 0: + schemas.append(schema) + return None if len(schemas) != 1 else schemas[0] + + +def _get_ninja_api_instances( + urlconfs: Optional[List[Optional[str]]] = None, + patterns: Optional[List[Any]] = None, +) -> Set[NinjaAPI]: + from ninja import NinjaAPI + + if urlconfs is None: + urlconfs = [None] + if patterns is None: + patterns = [] + for urlconf in urlconfs: + patterns.extend(get_resolver(urlconf).url_patterns) + + apis: Set[NinjaAPI] = set() + for p in patterns: + if isinstance(p, URLResolver): + if p.app_name != "ninja": + apis.update(_get_ninja_api_instances(patterns=p.url_patterns)) + else: + for pattern in p.url_patterns: + if isinstance(pattern, URLPattern) and pattern.lookup_str.startswith("ninja."): + callback_keywords = getattr(pattern.callback, "keywords", {}) + if isinstance(callback_keywords, dict): + api = callback_keywords.get("api") + if isinstance(api, NinjaAPI): + apis.add(api) + break + return apis + + +def _check_import(name: str) -> bool: + try: + import_module(name) + return True + except ImportError: + return False diff --git a/poetry.lock b/poetry.lock index 4492619..99a3834 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "annotated-types" @@ -492,13 +492,13 @@ files = [ [[package]] name = "django" -version = "4.2.10" +version = "4.2.11" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = true python-versions = ">=3.8" files = [ - {file = "Django-4.2.10-py3-none-any.whl", hash = "sha256:a2d4c4d4ea0b6f0895acde632071aff6400bfc331228fc978b05452a0ff3e9f1"}, - {file = "Django-4.2.10.tar.gz", hash = "sha256:b1260ed381b10a11753c73444408e19869f3241fc45c985cd55a30177c789d13"}, + {file = "Django-4.2.11-py3-none-any.whl", hash = "sha256:ddc24a0a8280a0430baa37aff11f28574720af05888c62b7cfe71d219f4599d3"}, + {file = "Django-4.2.11.tar.gz", hash = "sha256:6e6ff3db2d8dd0c986b4eec8554c8e4f919b5c1ff62a5b4390c17aff2ed6e5c4"}, ] [package.dependencies] @@ -547,18 +547,18 @@ types-psycopg2 = ">=2.9.21.13" [[package]] name = "djangorestframework" -version = "3.14.0" +version = "3.15.0" description = "Web APIs for Django, made easy." optional = true python-versions = ">=3.6" files = [ - {file = "djangorestframework-3.14.0-py3-none-any.whl", hash = "sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08"}, - {file = "djangorestframework-3.14.0.tar.gz", hash = "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8"}, + {file = "djangorestframework-3.15.0-py3-none-any.whl", hash = "sha256:5fa616048a7ec287fdaab3148aa5151efb73f7f8be1e23a9d18484e61e672695"}, + {file = "djangorestframework-3.15.0.tar.gz", hash = "sha256:3f4a263012e1b263bf49a4907eb4cfe14de840a09b1ba64596d01a9c54835919"}, ] [package.dependencies] +"backports.zoneinfo" = {version = "*", markers = "python_version < \"3.9\""} django = ">=3.0" -pytz = "*" [[package]] name = "djangorestframework-types" @@ -789,6 +789,17 @@ zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)", "zipp (>=3.17)"] +[[package]] +name = "inflection" +version = "0.5.1" +description = "A port of Ruby on Rails inflector to Python" +optional = true +python-versions = ">=3.5" +files = [ + {file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"}, + {file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"}, +] + [[package]] name = "iniconfig" version = "2.0.0" @@ -1794,17 +1805,6 @@ files = [ [package.dependencies] six = ">=1.5" -[[package]] -name = "pytz" -version = "2024.1" -description = "World timezone definitions, modern and historical" -optional = true -python-versions = "*" -files = [ - {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, - {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, -] - [[package]] name = "pywin32" version = "306" @@ -2351,6 +2351,17 @@ files = [ {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, ] +[[package]] +name = "uritemplate" +version = "4.1.1" +description = "Implementation of RFC 6570 URI Templates" +optional = true +python-versions = ">=3.6" +files = [ + {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, + {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, +] + [[package]] name = "urllib3" version = "2.2.1" @@ -2432,9 +2443,8 @@ docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.link testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] [extras] -django = ["django", "requests"] django-ninja = ["django", "django-ninja", "requests"] -django-rest-framework = ["django", "djangorestframework", "requests"] +django-rest-framework = ["django", "djangorestframework", "inflection", "requests", "uritemplate"] fastapi = ["fastapi", "httpx", "starlette"] flask = ["flask", "requests"] litestar = ["httpx", "litestar"] @@ -2443,4 +2453,4 @@ starlette = ["httpx", "starlette"] [metadata] lock-version = "2.0" python-versions = ">=3.8,<4.0" -content-hash = "cdf7fa20ad6a3fd43c4a96b9db5d21261d94382d8f6bb4a569f78d233e12eb45" +content-hash = "265fcc36bcee1c5eb03584e161a61730f6f37686d0da654fea233ebaf5f905c2" diff --git a/pyproject.toml b/pyproject.toml index 192da1a..6f49d62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,9 +34,11 @@ djangorestframework = { version = ">=3.10.0", optional = true } fastapi = { version = ">=0.87.0", optional = true } flask = { version = ">=2.0.0", optional = true } httpx = { version = ">=0.22.0", optional = true } +inflection = { version = ">=0.5.1", optional = true } litestar = { version = ">=2.0.0", optional = true } requests = { version = ">=2.26.0", optional = true } starlette = { version = ">=0.21.0,<1.0.0", optional = true } +uritemplate = { version = ">=3.0.0", optional = true } [tool.poetry.group.dev.dependencies] ipykernel = "^6.26.0" @@ -65,9 +67,14 @@ types-six = "*" types-ujson = "*" [tool.poetry.extras] -django = ["django", "requests"] django_ninja = ["django", "django-ninja", "requests"] -django_rest_framework = ["django", "djangorestframework", "requests"] +django_rest_framework = [ + "django", + "djangorestframework", + "uritemplate", # required for schema generation + "inflection", # required for schema generation + "requests", +] fastapi = ["fastapi", "starlette", "httpx"] flask = ["flask", "requests"] litestar = ["litestar", "httpx"] diff --git a/tests/django_ninja_urls.py b/tests/django_ninja_urls.py index 97969c5..e511981 100644 --- a/tests/django_ninja_urls.py +++ b/tests/django_ninja_urls.py @@ -6,7 +6,7 @@ api = NinjaAPI() -@api.get("/foo") +@api.get("/foo", summary="Foo", description="Foo") def foo(request: HttpRequest) -> str: return "foo" diff --git a/tests/django_rest_framework_urls.py b/tests/django_rest_framework_urls.py index 4f8edb8..60fbf9b 100644 --- a/tests/django_rest_framework_urls.py +++ b/tests/django_rest_framework_urls.py @@ -6,6 +6,8 @@ class FooView(APIView): + """Foo""" + def get(self, request: Request) -> Response: return Response("foo") diff --git a/tests/test_django_ninja.py b/tests/test_django_ninja.py index f1d9681..d47adfd 100644 --- a/tests/test_django_ninja.py +++ b/tests/test_django_ninja.py @@ -75,7 +75,7 @@ def test_middleware_requests_ok(client: Client, mocker: MockerFixture): mock.assert_called_once() assert mock.call_args is not None assert mock.call_args.kwargs["method"] == "GET" - assert mock.call_args.kwargs["path"] == "api/foo/" + assert mock.call_args.kwargs["path"] == "/api/foo/{bar}" assert mock.call_args.kwargs["status_code"] == 200 assert mock.call_args.kwargs["response_time"] > 0 assert int(mock.call_args.kwargs["response_size"]) > 0 @@ -88,6 +88,14 @@ def test_middleware_requests_ok(client: Client, mocker: MockerFixture): assert int(mock.call_args.kwargs["request_size"]) > 0 +def test_middleware_requests_404(client: Client, mocker: MockerFixture): + mock = mocker.patch("apitally.client.base.RequestCounter.add_request") + + response = client.get("/api/none") + assert response.status_code == 404 + mock.assert_not_called() + + def test_middleware_requests_error(client: Client, mocker: MockerFixture): mock = mocker.patch("apitally.client.base.RequestCounter.add_request") @@ -96,7 +104,7 @@ def test_middleware_requests_error(client: Client, mocker: MockerFixture): mock.assert_called_once() assert mock.call_args is not None assert mock.call_args.kwargs["method"] == "PUT" - assert mock.call_args.kwargs["path"] == "api/baz" + assert mock.call_args.kwargs["path"] == "/api/baz" assert mock.call_args.kwargs["status_code"] == 500 assert mock.call_args.kwargs["response_time"] > 0 @@ -109,22 +117,47 @@ def test_middleware_validation_error(client: Client, mocker: MockerFixture): mock.assert_called_once() assert mock.call_args is not None assert mock.call_args.kwargs["method"] == "GET" - assert mock.call_args.kwargs["path"] == "api/val" + assert mock.call_args.kwargs["path"] == "/api/val" assert len(mock.call_args.kwargs["detail"]) == 1 assert mock.call_args.kwargs["detail"][0]["loc"] == ["query", "foo"] -def test_get_app_info(mocker: MockerFixture): - from django.urls import get_resolver +def test_get_app_info(): + from apitally.django import _get_app_info - from apitally.django import _extract_views_from_url_patterns, _get_app_info - - views = _extract_views_from_url_patterns(get_resolver().url_patterns) - - app_info = _get_app_info(views=views) + app_info = _get_app_info(app_version="1.2.3", urlconfs=[None]) openapi = json.loads(app_info["openapi"]) - assert len(app_info["paths"]) == len(openapi["paths"]) + assert len(app_info["paths"]) == 5 + assert len(openapi["paths"]) == 5 - app_info = _get_app_info(views=views, app_version="1.2.3", openapi_url="/api/openapi.json") - assert "openapi" in app_info + assert app_info["versions"]["django"] + assert app_info["versions"]["django-ninja"] assert app_info["versions"]["app"] == "1.2.3" + assert app_info["client"] == "python:django" + + +def test_get_ninja_api_instances(): + from ninja import NinjaAPI + + from apitally.django import _get_ninja_api_instances + + apis = _get_ninja_api_instances() + assert len(apis) == 1 + api = list(apis)[0] + assert isinstance(api, NinjaAPI) + + +def test_get_ninja_api_endpoints(): + from apitally.django import _get_ninja_paths + + endpoints = _get_ninja_paths([None]) + assert len(endpoints) == 5 + assert all(len(e["summary"]) > 0 for e in endpoints) + assert any(e["description"] is not None and len(e["description"]) > 0 for e in endpoints) + + +def test_check_import(): + from apitally.django import _check_import + + assert _check_import("ninja") is True + assert _check_import("nonexistentpackage") is False diff --git a/tests/test_django_rest_framework.py b/tests/test_django_rest_framework.py index d9ceefe..d57002a 100644 --- a/tests/test_django_rest_framework.py +++ b/tests/test_django_rest_framework.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import sys from importlib.util import find_spec from typing import TYPE_CHECKING @@ -71,7 +72,7 @@ def test_middleware_requests_ok(client: APIClient, mocker: MockerFixture): mock.assert_called_once() assert mock.call_args is not None assert mock.call_args.kwargs["method"] == "GET" - assert mock.call_args.kwargs["path"] == "foo//" + assert mock.call_args.kwargs["path"] == "/foo/{bar}/" assert mock.call_args.kwargs["status_code"] == 200 assert mock.call_args.kwargs["response_time"] > 0 assert int(mock.call_args.kwargs["response_size"]) > 0 @@ -84,6 +85,14 @@ def test_middleware_requests_ok(client: APIClient, mocker: MockerFixture): assert int(mock.call_args.kwargs["request_size"]) > 0 +def test_middleware_requests_404(client: APIClient, mocker: MockerFixture): + mock = mocker.patch("apitally.client.base.RequestCounter.add_request") + + response = client.get("/api/none") + assert response.status_code == 404 + mock.assert_not_called() + + def test_middleware_requests_error(client: APIClient, mocker: MockerFixture): mock = mocker.patch("apitally.client.base.RequestCounter.add_request") @@ -92,19 +101,30 @@ def test_middleware_requests_error(client: APIClient, mocker: MockerFixture): mock.assert_called_once() assert mock.call_args is not None assert mock.call_args.kwargs["method"] == "PUT" - assert mock.call_args.kwargs["path"] == "baz/" + assert mock.call_args.kwargs["path"] == "/baz/" assert mock.call_args.kwargs["status_code"] == 500 assert mock.call_args.kwargs["response_time"] > 0 def test_get_app_info(): - from django.urls import get_resolver - - from apitally.django import _extract_views_from_url_patterns, _get_app_info + from apitally.django import _get_app_info - views = _extract_views_from_url_patterns(get_resolver().url_patterns) - app_info = _get_app_info(views=views, app_version="1.2.3") + app_info = _get_app_info(app_version="1.2.3", urlconfs=[None]) + openapi = json.loads(app_info["openapi"]) assert len(app_info["paths"]) == 4 + assert len(openapi["paths"]) == 4 + assert app_info["versions"]["django"] + assert app_info["versions"]["djangorestframework"] assert app_info["versions"]["app"] == "1.2.3" assert app_info["client"] == "python:django" + + +def test_get_drf_api_endpoints(): + from apitally.django import _get_drf_paths + + endpoints = _get_drf_paths([None]) + assert len(endpoints) == 4 + assert endpoints[0]["method"] == "GET" + assert endpoints[0]["path"] == "/foo/" + assert endpoints[1]["path"] == "/foo/{bar}/"