From 15b970da270de5d0b3e56ac5246b34126ddcebc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigurd=20Lj=C3=B8dal?= <544451+ljodal@users.noreply.github.com> Date: Tue, 18 Jul 2023 12:43:24 +0200 Subject: [PATCH] Upgrade to Pydantic v2 (#8) --- .github/workflows/lint.yaml | 4 +- django_api_decorator/decorators.py | 329 ++++++---------------------- django_api_decorator/openapi.py | 275 +++++------------------ django_api_decorator/schema_file.py | 2 +- django_api_decorator/type_utils.py | 55 ----- django_api_decorator/types.py | 5 + poetry.lock | 155 +++++++++---- pyproject.toml | 2 +- tests/django_settings.py | 3 + tests/test_decorator.py | 6 +- tests/test_openapi.py | 35 ++- tests/test_response_encoder.py | 106 --------- tests/test_response_encoding.py | 206 +++++++++++++++++ 13 files changed, 491 insertions(+), 692 deletions(-) delete mode 100644 django_api_decorator/type_utils.py delete mode 100644 tests/test_response_encoder.py create mode 100644 tests/test_response_encoding.py diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 89f9b3b..0e00ae5 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -13,7 +13,7 @@ jobs: - black --check . - flake8 . - mypy . - python_version: ["3.10"] + python_version: ["3.10", "3.11"] steps: - name: Checkout @@ -41,4 +41,4 @@ jobs: - name: Run linter run: | - poetry run ${{ matrix.check }} \ No newline at end of file + poetry run ${{ matrix.check }} diff --git a/django_api_decorator/decorators.py b/django_api_decorator/decorators.py index 39882b2..bdccb50 100644 --- a/django_api_decorator/decorators.py +++ b/django_api_decorator/decorators.py @@ -1,13 +1,9 @@ -import dataclasses -import datetime import functools import inspect -import json import logging -import types import typing from collections.abc import Callable, Mapping -from typing import Any, Protocol, TypedDict, cast +from typing import Annotated, Any, TypedDict import pydantic from django.conf import settings @@ -15,15 +11,10 @@ from django.db import transaction from django.http import Http404, HttpRequest, HttpResponse, JsonResponse from django.views.decorators.http import require_http_methods -from pydantic.json import pydantic_encoder - -from .type_utils import ( - is_list, - is_optional, - is_union, - unwrap_list_item_type, - unwrap_optional, -) +from pydantic.fields import FieldInfo +from pydantic.functional_validators import BeforeValidator +from pydantic_core import PydanticUndefined + from .types import ApiMeta, FieldError, PublicAPIError P = typing.ParamSpec("P") @@ -81,7 +72,7 @@ def api( ) def default_auth_check(request: HttpRequest) -> bool: - return request.user.is_authenticated + return hasattr(request, "user") and request.user.is_authenticated _auth_check = ( auth_check @@ -94,19 +85,19 @@ def decorator(func: Callable[..., Any]) -> Callable[..., HttpResponse]: # Get a function that we can call to extract view parameters from # the requests query parameters. - parse_query_params = _get_query_param_parser( + query_params_model = _get_query_params_model( parameters=signature.parameters, query_params=query_params or [] ) # If the method has a "body" argument, get a function to call to parse # the request body into the type expected by the view. - body_parser: BodyParser | None = None + body_adapter = None if "body" in signature.parameters: - body_parser = _get_body_parser(parameter=signature.parameters["body"]) + body_adapter = _get_body_adapter(parameter=signature.parameters["body"]) # Get a function to use for encoding the value returned from the view # into a request we can return to the client. - response_encoder = _get_response_encoder( + response_adapter = _get_response_adapter( type_annotation=signature.return_annotation ) @@ -124,11 +115,19 @@ def inner(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: # Parse the request body if the request method allows a body and the # view has requested that we should parse the body. - if _can_have_body(request.method) and body_parser: - extra_kwargs["body"] = body_parser(request=request) + if _can_have_body(request.method) and body_adapter: + extra_kwargs["body"] = body_adapter.validate_json(request.body) # Parse query params and add them to the parameters given to the view. - extra_kwargs.update(parse_query_params(request)) + raw_query_params: dict[str, Any] = {} + for key in request.GET: + if value := request.GET.getlist(key): + raw_query_params[key] = value[0] if len(value) == 1 else value + else: + raw_query_params[key] = True + + query_params = query_params_model.model_validate(raw_query_params) + extra_kwargs.update(query_params.model_dump(exclude_defaults=True)) except ( ValidationError, pydantic.ValidationError, @@ -176,13 +175,25 @@ def inner(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: if isinstance(response, HttpResponse): return response + if not response_adapter: + raise TypeError( + f"{func} is annotated to return an http response, but returned " + f"{type(response)}" + ) + # Encode the response from the view to json and create a response object. - return response_encoder(payload=response, status=response_status) + payload = response_adapter.dump_json(response) + return HttpResponse( + payload, status=response_status, content_type="application/json" + ) inner._api_meta = ApiMeta( # type: ignore[attr-defined] method=method, query_params=query_params or [], response_status=response_status, + body_adapter=body_adapter, + query_params_model=query_params_model, + response_adapter=response_adapter, ) return inner @@ -194,119 +205,41 @@ def inner(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ####################### -class Validator(Protocol): - def __call__(self, value: str, *, query_param_name: str) -> Any: - ... - - -class _missing: - """Marker for missing query parameters""" - - -def _validate_int(value: str, *, query_param_name: str) -> int: - try: - return int(value) - except (TypeError, ValueError): - raise ValidationError(f"{query_param_name} must be an integer") - - -def _validate_bool(value: str, *, query_param_name: str) -> bool: - value = value.lower().strip() - - if value in ("", "yes", "on", "true", "1"): - return True - - if value in ("no", "off", "false", "0"): - return False - - raise ValidationError(f"{query_param_name} must be a boolean") - - -def _validate_date(value: str, *, query_param_name: str) -> datetime.date: - try: - return datetime.date.fromisoformat(value) - except ValueError: - raise ValidationError(f"{query_param_name} must be a valid date") - - -def _get_validator(parameter: inspect.Parameter) -> Validator: - annotation = parameter.annotation - if annotation is inspect.Parameter.empty: - raise ValueError( - f"Parameter {parameter.name} specified as a query param must have a" - f" type annotation." - ) - - param_is_optional = is_optional(annotation) - - if param_is_optional and parameter.default is inspect.Parameter.empty: - raise ValueError( - f"Parameter {parameter.name} specified as an optional param must have a" - f" default value." - ) +def validate_boolean(value: Any) -> Any: + return True if value == "" else value - if annotation is str or (param_is_optional and unwrap_optional(annotation) is str): - return lambda value, query_param_name: value - if annotation is int or (param_is_optional and unwrap_optional(annotation) is int): - return _validate_int - - if annotation is datetime.date or ( - param_is_optional and unwrap_optional(annotation) is datetime.date - ): - return _validate_date - - if annotation is bool or ( - param_is_optional and unwrap_optional(annotation) is bool - ): - return _validate_bool - - raise ValueError( - f"Unsupported type annotation for query param {parameter.name}: {annotation}" - ) +TYPE_MAPPING = { + bool: Annotated[bool, BeforeValidator(validate_boolean)], # type: ignore[call-arg] +} -def _get_query_param_parser( +def _get_query_params_model( *, parameters: Mapping[str, inspect.Parameter], query_params: list[str], -) -> Callable[[HttpRequest], Mapping[str, Any]]: - query_param_mapping = ( - {query_param.replace("_", "-"): query_param for query_param in query_params} - if query_params - else {} - ) - query_param_validators = { - query_param: _get_validator(parameters[arg_name]) - for query_param, arg_name in query_param_mapping.items() - } - required_params = { - arg_name - for query_param, arg_name in query_param_mapping.items() - if parameters[arg_name].default is inspect.Parameter.empty - } - - def parser(request: HttpRequest) -> Mapping[str, Any]: - validated = {} - - for query_param, arg_name in query_param_mapping.items(): - validator = query_param_validators[query_param] - value = request.GET.get(query_param, _missing) - - # If the argument is required, make sure it has a value - if value is _missing: - if arg_name in required_params: - raise ValidationError( - f"Query parameter {query_param} must be specified" - ) - else: - validated[arg_name] = validator( - cast(str, value), query_param_name=query_param - ) - - return validated +) -> pydantic.BaseModel: + if any(arg_name not in parameters for arg_name in query_params): + raise TypeError("All parameters specified in query_params must exist") + + fields: dict[str, tuple[Any, FieldInfo]] = {} + + for arg_name in query_params: + annotation = parameters[arg_name].annotation + annotation = TYPE_MAPPING.get(annotation, annotation) + field = pydantic.fields.Field( + default=( + parameters[arg_name].default + if not parameters[arg_name].default == inspect.Parameter.empty + else PydanticUndefined + ), + alias=arg_name.replace("_", "-") if "_" in arg_name else None, + ) + fields[arg_name] = (annotation, field) - return parser + return pydantic.create_model( # type: ignore[no-any-return,call-overload] + "QueryParams", **fields + ) ################ @@ -314,67 +247,16 @@ def parser(request: HttpRequest) -> Mapping[str, Any]: ################ -class BodyParser(Protocol): - def __call__(self, *, request: HttpRequest) -> Any: - ... - - def _can_have_body(method: str | None) -> bool: return method in ("POST", "PATCH", "PUT") -def _get_body_parser(*, parameter: inspect.Parameter) -> BodyParser: +def _get_body_adapter(*, parameter: inspect.Parameter) -> pydantic.TypeAdapter[Any]: annotation = parameter.annotation if annotation is inspect.Parameter.empty: raise TypeError("The body parameter must have a type annotation") - body_is_list = is_list(type_annotation=annotation) - if body_is_list: - annotation = unwrap_list_item_type(type_annotation=annotation) - - if issubclass(annotation, pydantic.BaseModel): - return _pydantic_parser(model_cls=annotation, body_is_list=body_is_list) - - raise ValueError( - f"Annotation for body parameter must be a django-rest-framework or pydantic " - f"serializer class, the current type annotation is: {annotation}" - ) - - -def _pydantic_parser( - *, model_cls: type[pydantic.BaseModel], body_is_list: bool -) -> BodyParser: - def parser( - *, request: HttpRequest - ) -> pydantic.BaseModel | list[pydantic.BaseModel]: - try: - data = json.loads(request.body) - except json.decoder.JSONDecodeError as e: - raise ValidationError("Invalid JSON") from e - - if body_is_list: - if not isinstance(data, list): - raise ValidationError("Expected request body to be a list") - - if not len(data): - raise ValidationError("Empty list not allowed") - - result = [] - for i, element in enumerate(data): - if not isinstance(element, dict): - raise ValidationError(f"Expected list element {i} to be an object") - - result.append(model_cls(**element)) - return result - - else: - if not isinstance(data, dict): - raise ValidationError("Expected request body to be an object") - - instance = model_cls(**data) - return instance - - return parser + return pydantic.TypeAdapter(annotation) ##################### @@ -382,83 +264,14 @@ def parser( ##################### -class ResponseEncoder(Protocol): - def __call__(self, *, payload: Any, status: int) -> HttpResponse: - ... - - -def _is_class(*, type_annotation: Annotation) -> bool: - return inspect.isclass(type_annotation) and ( - type(type_annotation) - is not types.GenericAlias # type: ignore[comparison-overlap] - ) - - -def _get_response_encoder(*, type_annotation: Annotation) -> ResponseEncoder: - type_is_class = _is_class(type_annotation=type_annotation) - - if type_is_class and issubclass(type_annotation, HttpResponse): - return lambda payload, status: payload - - if dataclasses.is_dataclass(type_annotation): - return _dataclass_encoder - - if type_is_class and issubclass(type_annotation, pydantic.BaseModel): - return _pydantic_encoder - - # We need to unwrap inner list and union annotations - # to verify whether we support them. - inner_type_annotations: tuple[type, ...] = tuple() - - type_is_list = is_list(type_annotation=type_annotation) - type_is_union = is_union(type_annotation=type_annotation) - - if type_is_list or type_is_union: - inner_type_annotations = typing.get_args(type_annotation) - - if inner_type_annotations and all( - _is_class(type_annotation=t) for t in inner_type_annotations - ): - # Pydantic encoder supports both list and Union wrappers - if all(issubclass(t, pydantic.BaseModel) for t in inner_type_annotations): - return _pydantic_encoder - - if any(issubclass(t, pydantic.BaseModel) for t in inner_type_annotations): - raise NotImplementedError( - "@api: We only support all values being pydantic models in a union" - ) - - if any(dataclasses.is_dataclass(t) for t in inner_type_annotations): - raise NotImplementedError( - "@api: We do not support encoding dataclasses inside lists or unions" - ) - - # Assume any other response can be JSON encoded. We might want to restrict - # this to some verified types 🤔 - return _json_encoder - - -def _json_encoder(*, payload: Any, status: int) -> HttpResponse: - return JsonResponse( - payload, - status=status, - json_dumps_params={"default": pydantic_encoder}, - safe=False, - ) - - -def _pydantic_encoder(payload: Any, status: int) -> HttpResponse: - return JsonResponse( - payload, - status=status, - json_dumps_params={"default": pydantic_encoder}, - safe=False, - ) - - -def _dataclass_encoder(*, payload: Any, status: int) -> HttpResponse: - data = dataclasses.asdict(payload) - return _json_encoder(payload=data, status=status) +def _get_response_adapter( + *, type_annotation: Annotation +) -> pydantic.TypeAdapter[Any] | None: + if type_annotation == inspect.Parameter.empty: + raise TypeError("Missing annotation for return type of api view") + if type(type_annotation) is type and issubclass(type_annotation, HttpResponse): + return None + return pydantic.TypeAdapter(type_annotation) ################## diff --git a/django_api_decorator/openapi.py b/django_api_decorator/openapi.py index 26669a9..2c5bd86 100644 --- a/django_api_decorator/openapi.py +++ b/django_api_decorator/openapi.py @@ -1,78 +1,21 @@ import dataclasses -import inspect import logging import re -import typing from collections.abc import Callable, Sequence -from datetime import date -from typing import TYPE_CHECKING, Any, Union, cast +from typing import Any, cast import pydantic -import pydantic.schema from django.http import HttpResponse from django.urls.resolvers import RoutePattern, URLPattern, URLResolver -from pydantic import BaseModel -from pydantic.utils import get_model - -from .type_utils import ( - get_inner_list_type, - is_optional, - is_pydantic_model, - is_union, - unwrap_optional, -) -from .types import ApiMeta +from pydantic_core import PydanticUndefined -if TYPE_CHECKING: - from pydantic.dataclasses import Dataclass +from .types import ApiMeta logger = logging.getLogger(__name__) schema_ref = "#/components/schemas/{model}" -def is_type_supported(t: type) -> bool: - """ - Controls whether or not we support the provided type as request body or response - data. - """ - - try: - return is_pydantic_model(t) - except TypeError: - return False - - -def name_for_type(t: type) -> str: - """ - Returns OpenAPI schema for the provided type. - """ - - assert is_type_supported(t) - - # normalize_name removes special characters like [] from generics. - # get_model gets the pydantic model, even if a pydantic dataclass is used. - - return pydantic.schema.normalize_name(get_model(t).__name__) - - -def schema_type_ref(t: type, *, is_list: bool = False) -> dict[str, Any]: - """ - Returns a openapi reference to the provided type, and optionally wraps it in an - array - """ - - reference = {"$ref": schema_ref.format(model=name_for_type(t))} - - if is_list: - return { - "type": "array", - "items": reference, - } - - return reference - - def get_resolved_url_patterns( base_patterns: Sequence[URLResolver | URLPattern], ) -> list[tuple[URLPattern, str]]: @@ -144,97 +87,67 @@ def replacer(match: re.Match[str]) -> str: return path, parameters -def get_schema_for_type_annotation( - input_type_annotation: type, -) -> tuple[dict[str, Any] | None, list[type]]: - """ - Helper function that generates a OpenAPI schema based on an input_type_annotation - Supports pydantic models directly, or with Union / list wrappers. - """ - - type_is_union = is_union(type_annotation=input_type_annotation) - if type_is_union: - type_annotations = typing.get_args(input_type_annotation) - else: - type_annotations = (input_type_annotation,) - - # List of schemas we generate based on the return types (to support oneOf union - # types) - schemas = [] - inner_type_annotations = [] - - for t in type_annotations: - type_annotation, type_is_list = get_inner_list_type(t) - if type_annotation is not None and is_type_supported(type_annotation): - schemas.append(schema_type_ref(type_annotation, is_list=type_is_list)) - inner_type_annotations.append(type_annotation) - else: - # If one of the type's are not supported, skip the view - return None, [] - - if type_is_union and len(schemas) > 0: - return {"oneOf": schemas}, inner_type_annotations - - if len(schemas) == 1: - return schemas[0], inner_type_annotations - - return None, [] - - def paths_and_types_for_view( *, view_name: str, callback: Callable[..., HttpResponse], resolved_url: str -) -> tuple[dict[str, Any], list[type]]: +) -> tuple[dict[str, Any], dict[str, Any]]: api_meta: ApiMeta | None = getattr(callback, "_api_meta", None) assert api_meta is not None - signature = inspect.signature(callback) - # Types that should be included in the schema object (referenced via # schema_type_ref) - types: list[type] = [] - - schema, return_types = get_schema_for_type_annotation(signature.return_annotation) - - if schema: - types += return_types - api_response = {"content": {"application/json": {"schema": schema}}} + components: dict[str, Any] = {} + + def to_ref_if_object(schema: dict[str, Any]) -> dict[str, Any]: + if schema.get("type", None) == "object" and "title" in schema: + name = schema["title"] + ref = schema_ref.format(model=name) + components[name] = schema + return {"$ref": ref} + + return schema + + if api_meta.response_adapter: + response_schema = api_meta.response_adapter.json_schema(ref_template=schema_ref) + if defs := response_schema.pop("$defs", None): + components.update(defs) + response_schema = to_ref_if_object(response_schema) + api_response = {"content": {"application/json": {"schema": response_schema}}} else: api_response = {} - logger.debug( - "Return type of %s (%s) unsupported: %s", - resolved_url, - view_name, - signature.return_annotation, - ) - additional_data = {} + request_body = {} + if api_meta.body_adapter: + body_schema = api_meta.body_adapter.json_schema(ref_template=schema_ref) + if defs := body_schema.pop("$defs", None): + components.update(defs) - if "body" in signature.parameters: - body_schema, body_return_types = get_schema_for_type_annotation( - signature.parameters["body"].annotation - ) - if body_schema: - types += body_return_types - additional_data = { - "requestBody": { - "required": True, - "content": {"application/json": {"schema": body_schema}}, - } + body_schema = to_ref_if_object(body_schema) + + request_body = { + "requestBody": { + "required": True, + "content": {"application/json": {"schema": body_schema}}, } - else: - logger.debug( - "Body type of %s (%s) unsupported: %s", - resolved_url, - view_name, - signature.parameters["body"].annotation, - ) + } - path, url_parameters = django_path_to_openapi_url_and_parameters(resolved_url) + path, parameters = django_path_to_openapi_url_and_parameters(resolved_url) - query_parameters = openapi_query_parameters( - query_params=api_meta.query_params, signature=signature - ) + for name, field in api_meta.query_params_model.model_fields.items(): + schema = pydantic.TypeAdapter(field.annotation).json_schema( + ref_template=schema_ref + ) + schema = to_ref_if_object(schema) + + param = { + "name": field.alias or name, + "in": "query", + "required": field.is_required(), + "schema": schema, + } + if field.default != PydanticUndefined: + param["default"] = field.default + parameters.append(param) # Assuming standard django folder structure with [project name]/[app name]/.... app_name = callback.__module__.split(".")[1] @@ -248,8 +161,8 @@ def paths_and_types_for_view( "description": callback.__doc__ or "", # Tags are useful for grouping operations in codegen "tags": [app_name], - "parameters": url_parameters + query_parameters, - **additional_data, + "parameters": parameters, + **request_body, "responses": { api_meta.response_status: { "description": "", @@ -260,77 +173,7 @@ def paths_and_types_for_view( } } - return paths, types - - -def openapi_query_parameters( - *, query_params: list[str], signature: inspect.Signature -) -> list[dict[str, Any]]: - """ - Converts a function signature and a list of query params into openapi query - parameters. - """ - - parameters = [] - for query_param in query_params: - query_url_name = query_param.replace("_", "-") - - parameter = signature.parameters[query_param] - - annotation = parameter.annotation - has_default = parameter.default != inspect.Parameter.empty - - param_is_optional = is_optional(annotation) - if param_is_optional: - annotation = unwrap_optional(annotation) - - schema = None - - if annotation is str: - schema = {"type": "string"} - - if annotation is int: - schema = {"type": "integer"} - - if annotation is date: - schema = {"type": "string", "format": "date"} - - if annotation is bool: - schema = {"type": "boolean"} - - if schema is None: - logger.warning( - "Could not generate types for query param %s with type %s.", - query_url_name, - annotation, - ) - continue - - parameters.append( - { - "name": query_url_name, - "in": "query", - "required": not (param_is_optional or has_default), - "schema": schema, - } - ) - - return parameters - - -def schemas_for_types(api_types: list[type]) -> dict[str, Any]: - # This only supports Pydantic models for now. - assert all( - hasattr(t, "__pydantic_model__") or issubclass(t, BaseModel) for t in api_types - ) - - return cast( - dict[str, Any], - pydantic.schema.schema( - cast(Sequence[Union[type[BaseModel], type["Dataclass"]]], api_types), - ref_template=schema_ref, - )["definitions"], - ) + return paths, components def generate_api_spec( @@ -400,15 +243,15 @@ class OpenApiOperation: ) api_paths: dict[str, Any] = {} - api_types = [] + api_components = {} for operation in operations: - paths, types = paths_and_types_for_view( + paths, components = paths_and_types_for_view( view_name=operation.name, callback=operation.callback, resolved_url=operation.url, ) - api_types += types + api_components.update(components) for path, val in paths.items(): if path in api_paths: @@ -417,16 +260,16 @@ class OpenApiOperation: else: api_paths[path] = val - api_schemas = schemas_for_types(api_types) - api_spec = { "openapi": "3.0.0", "info": {"title": "API overview", "version": "0.0.1"}, "paths": api_paths, - "components": {"schemas": api_schemas}, + "components": {"schemas": api_components}, } - logger.info("Generated %s paths and %s schemas", len(api_paths), len(api_schemas)) + logger.info( + "Generated %s paths and %s schemas", len(api_paths), len(api_components) + ) logger.info("%s @api annotated views", len(operations)) return api_spec diff --git a/django_api_decorator/schema_file.py b/django_api_decorator/schema_file.py index 73cdfd5..374605c 100644 --- a/django_api_decorator/schema_file.py +++ b/django_api_decorator/schema_file.py @@ -17,7 +17,7 @@ def get_api_spec() -> dict[str, Any]: "ROOT_URLCONF must be set in settings in order to generate an api spec." ) - urlpatterns = import_module(settings.ROOT_URLCONF).urlpatterns # type: ignore[misc] + urlpatterns = import_module(settings.ROOT_URLCONF).urlpatterns return generate_api_spec(urlpatterns=urlpatterns) diff --git a/django_api_decorator/type_utils.py b/django_api_decorator/type_utils.py deleted file mode 100644 index 5e25f4a..0000000 --- a/django_api_decorator/type_utils.py +++ /dev/null @@ -1,55 +0,0 @@ -import types -import typing - -import pydantic - - -def is_dict(*, type_annotation: type) -> bool: - return typing.get_origin(type_annotation) is dict - - -def is_any(*, type_annotation: type) -> bool: - return getattr(type_annotation, "_name", None) == "Any" - - -def is_union(*, type_annotation: type) -> bool: - return typing.get_origin(type_annotation) in ( - types.UnionType, # PEP 604 union type expressions - typing.Union, # Old-style explicit unions - ) - - -def is_optional(type_annotation: type) -> bool: - return is_union( - type_annotation=type_annotation - ) and types.NoneType in typing.get_args(type_annotation) - - -def unwrap_optional(type_annotation: type) -> type: - return next( # type: ignore[no-any-return] - arg - for arg in typing.get_args(type_annotation) - if not issubclass(arg, types.NoneType) - ) - - -def is_list(*, type_annotation: type) -> bool: - return typing.get_origin(type_annotation) is list - - -def unwrap_list_item_type(*, type_annotation: type) -> type: - return typing.get_args(type_annotation)[0] # type: ignore[no-any-return] - - -def get_inner_list_type(type_annotation: type) -> tuple[type, bool]: - type_is_list = is_list(type_annotation=type_annotation) - if type_is_list: - type_annotation = unwrap_list_item_type(type_annotation=type_annotation) - return type_annotation, type_is_list - - -def is_pydantic_model(t: type) -> bool: - return issubclass(t, pydantic.BaseModel) or ( - hasattr(t, "__pydantic_model__") - and issubclass(t.__pydantic_model__, pydantic.BaseModel) - ) diff --git a/django_api_decorator/types.py b/django_api_decorator/types.py index 4ddf923..2a5a555 100644 --- a/django_api_decorator/types.py +++ b/django_api_decorator/types.py @@ -1,6 +1,8 @@ from dataclasses import dataclass from typing import Any, TypedDict +from pydantic import BaseModel, TypeAdapter + class FieldError(TypedDict): message: str @@ -17,6 +19,9 @@ class ApiMeta: method: str query_params: list[str] response_status: int + query_params_model: BaseModel + body_adapter: TypeAdapter[Any] | None + response_adapter: TypeAdapter[Any] | None class PublicAPIError(Exception): diff --git a/poetry.lock b/poetry.lock index 30134fb..620672d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,16 @@ # This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +[[package]] +name = "annotated-types" +version = "0.5.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.7" +files = [ + {file = "annotated_types-0.5.0-py3-none-any.whl", hash = "sha256:58da39888f92c276ad970249761ebea80ba544b77acddaa1a4d6cf78287d45fd"}, + {file = "annotated_types-0.5.0.tar.gz", hash = "sha256:47cdc3490d9ac1506ce92c7aaa76c579dc3509ff11e098fc867e5130ab7be802"}, +] + [[package]] name = "asgiref" version = "3.7.2" @@ -358,55 +369,115 @@ files = [ [[package]] name = "pydantic" -version = "1.10.11" -description = "Data validation and settings management using python type hints" +version = "2.0.1" +description = "Data validation using Python type hints" optional = false python-versions = ">=3.7" files = [ - {file = "pydantic-1.10.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ff44c5e89315b15ff1f7fdaf9853770b810936d6b01a7bcecaa227d2f8fe444f"}, - {file = "pydantic-1.10.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a6c098d4ab5e2d5b3984d3cb2527e2d6099d3de85630c8934efcfdc348a9760e"}, - {file = "pydantic-1.10.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16928fdc9cb273c6af00d9d5045434c39afba5f42325fb990add2c241402d151"}, - {file = "pydantic-1.10.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0588788a9a85f3e5e9ebca14211a496409cb3deca5b6971ff37c556d581854e7"}, - {file = "pydantic-1.10.11-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e9baf78b31da2dc3d3f346ef18e58ec5f12f5aaa17ac517e2ffd026a92a87588"}, - {file = "pydantic-1.10.11-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:373c0840f5c2b5b1ccadd9286782852b901055998136287828731868027a724f"}, - {file = "pydantic-1.10.11-cp310-cp310-win_amd64.whl", hash = "sha256:c3339a46bbe6013ef7bdd2844679bfe500347ac5742cd4019a88312aa58a9847"}, - {file = "pydantic-1.10.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:08a6c32e1c3809fbc49debb96bf833164f3438b3696abf0fbeceb417d123e6eb"}, - {file = "pydantic-1.10.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a451ccab49971af043ec4e0d207cbc8cbe53dbf148ef9f19599024076fe9c25b"}, - {file = "pydantic-1.10.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b02d24f7b2b365fed586ed73582c20f353a4c50e4be9ba2c57ab96f8091ddae"}, - {file = "pydantic-1.10.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f34739a89260dfa420aa3cbd069fbcc794b25bbe5c0a214f8fb29e363484b66"}, - {file = "pydantic-1.10.11-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e297897eb4bebde985f72a46a7552a7556a3dd11e7f76acda0c1093e3dbcf216"}, - {file = "pydantic-1.10.11-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d185819a7a059550ecb85d5134e7d40f2565f3dd94cfd870132c5f91a89cf58c"}, - {file = "pydantic-1.10.11-cp311-cp311-win_amd64.whl", hash = "sha256:4400015f15c9b464c9db2d5d951b6a780102cfa5870f2c036d37c23b56f7fc1b"}, - {file = "pydantic-1.10.11-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2417de68290434461a266271fc57274a138510dca19982336639484c73a07af6"}, - {file = "pydantic-1.10.11-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:331c031ba1554b974c98679bd0780d89670d6fd6f53f5d70b10bdc9addee1713"}, - {file = "pydantic-1.10.11-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8268a735a14c308923e8958363e3a3404f6834bb98c11f5ab43251a4e410170c"}, - {file = "pydantic-1.10.11-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:44e51ba599c3ef227e168424e220cd3e544288c57829520dc90ea9cb190c3248"}, - {file = "pydantic-1.10.11-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d7781f1d13b19700b7949c5a639c764a077cbbdd4322ed505b449d3ca8edcb36"}, - {file = "pydantic-1.10.11-cp37-cp37m-win_amd64.whl", hash = "sha256:7522a7666157aa22b812ce14c827574ddccc94f361237ca6ea8bb0d5c38f1629"}, - {file = "pydantic-1.10.11-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bc64eab9b19cd794a380179ac0e6752335e9555d214cfcb755820333c0784cb3"}, - {file = "pydantic-1.10.11-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8dc77064471780262b6a68fe67e013298d130414d5aaf9b562c33987dbd2cf4f"}, - {file = "pydantic-1.10.11-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe429898f2c9dd209bd0632a606bddc06f8bce081bbd03d1c775a45886e2c1cb"}, - {file = "pydantic-1.10.11-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:192c608ad002a748e4a0bed2ddbcd98f9b56df50a7c24d9a931a8c5dd053bd3d"}, - {file = "pydantic-1.10.11-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ef55392ec4bb5721f4ded1096241e4b7151ba6d50a50a80a2526c854f42e6a2f"}, - {file = "pydantic-1.10.11-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:41e0bb6efe86281623abbeeb0be64eab740c865388ee934cd3e6a358784aca6e"}, - {file = "pydantic-1.10.11-cp38-cp38-win_amd64.whl", hash = "sha256:265a60da42f9f27e0b1014eab8acd3e53bd0bad5c5b4884e98a55f8f596b2c19"}, - {file = "pydantic-1.10.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:469adf96c8e2c2bbfa655fc7735a2a82f4c543d9fee97bd113a7fb509bf5e622"}, - {file = "pydantic-1.10.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e6cbfbd010b14c8a905a7b10f9fe090068d1744d46f9e0c021db28daeb8b6de1"}, - {file = "pydantic-1.10.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abade85268cc92dff86d6effcd917893130f0ff516f3d637f50dadc22ae93999"}, - {file = "pydantic-1.10.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9738b0f2e6c70f44ee0de53f2089d6002b10c33264abee07bdb5c7f03038303"}, - {file = "pydantic-1.10.11-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:787cf23e5a0cde753f2eabac1b2e73ae3844eb873fd1f5bdbff3048d8dbb7604"}, - {file = "pydantic-1.10.11-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:174899023337b9fc685ac8adaa7b047050616136ccd30e9070627c1aaab53a13"}, - {file = "pydantic-1.10.11-cp39-cp39-win_amd64.whl", hash = "sha256:1954f8778489a04b245a1e7b8b22a9d3ea8ef49337285693cf6959e4b757535e"}, - {file = "pydantic-1.10.11-py3-none-any.whl", hash = "sha256:008c5e266c8aada206d0627a011504e14268a62091450210eda7c07fabe6963e"}, - {file = "pydantic-1.10.11.tar.gz", hash = "sha256:f66d479cf7eb331372c470614be6511eae96f1f120344c25f3f9bb59fb1b5528"}, + {file = "pydantic-2.0.1-py3-none-any.whl", hash = "sha256:7a3e3b1d0384eaa313f0810cffa475d6849794a9ae5768989518114771cb9241"}, + {file = "pydantic-2.0.1.tar.gz", hash = "sha256:041945a6c75f2451a343674ec7d220cb7e207884fb06aaf2c16b6d0bfaf2bc39"}, ] [package.dependencies] -typing-extensions = ">=4.2.0" +annotated-types = ">=0.4.0" +pydantic-core = "2.0.2" +typing-extensions = ">=4.6.1" [package.extras] -dotenv = ["python-dotenv (>=0.10.4)"] -email = ["email-validator (>=1.0.3)"] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.0.2" +description = "" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pydantic_core-2.0.2-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:fb3d452def28f86fcec749659fea183650c23aa46ae4d8a9996463a1793587b5"}, + {file = "pydantic_core-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:13ff737b9dbda2175bf2d59f8c8d0989b9a331a50d1eb8b7e6e0fdc264af3e93"}, + {file = "pydantic_core-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ccade95f48f47c898632d8dd995704924fce0f99deb7fd4f24348792769abec"}, + {file = "pydantic_core-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:433b13fa81a06589dae5198dd285c5621714d4b6d75da058ba8347f8c36cb796"}, + {file = "pydantic_core-2.0.2-cp310-cp310-manylinux_2_24_armv7l.whl", hash = "sha256:a5576ad07f480a21b38fff2e15d2c90ab3b18f36692065235df237711b402afd"}, + {file = "pydantic_core-2.0.2-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:15cb57ca61280eca0b8d721d3629871ab239954c4cec049acf9354405836f341"}, + {file = "pydantic_core-2.0.2-cp310-cp310-manylinux_2_24_s390x.whl", hash = "sha256:e28d86253cdc638d084751bcc1217944370c567722d377c1364fd1433d0a41f9"}, + {file = "pydantic_core-2.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b8b622793e7b7ecb25916f30e91d49424a1f10db08aa151ff7eabd29039ae15c"}, + {file = "pydantic_core-2.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0b435c029f00b402df3ab19c07b6d8a2e26a5abbb15117b93c457e3ed40237d7"}, + {file = "pydantic_core-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:89d271bae5b6e43936e0365b387d317bd309c5e7c5645b7608b939410fb86968"}, + {file = "pydantic_core-2.0.2-cp310-none-win32.whl", hash = "sha256:5598f9d4e063e9a64233792dc0f8a0fab8036fb66d25cfc356649667a6542bfb"}, + {file = "pydantic_core-2.0.2-cp310-none-win_amd64.whl", hash = "sha256:c17fd1d0fef829b364fbbd06aad286b7a73b7b93a46f1967aff1c8f78e5a250a"}, + {file = "pydantic_core-2.0.2-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:e7d8df9e29ecc2930d27fccde99ae86c1dfc42c1f92e81715df2a7dc1f7f466e"}, + {file = "pydantic_core-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27338dfc0a474645d6fe2139b30f006a381f7926e80485370361d7e882a60034"}, + {file = "pydantic_core-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dbe2b50a4c3bcc9962449eea1c73d2e509a4e3a96df38511b898eea768fde4a4"}, + {file = "pydantic_core-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:196c90996542db0151265a1fe7b32d20f5d66fc00ec12ef6f10dd6a3be5aa05f"}, + {file = "pydantic_core-2.0.2-cp311-cp311-manylinux_2_24_armv7l.whl", hash = "sha256:75bbf0045f52696aa317b38e67ef5c80a15b7aab572956df2c6fb44f3f4c8b3e"}, + {file = "pydantic_core-2.0.2-cp311-cp311-manylinux_2_24_ppc64le.whl", hash = "sha256:fb6551210cef7423d68eaaeab60a9445e17edd33d251b2ab6c783afce9811df8"}, + {file = "pydantic_core-2.0.2-cp311-cp311-manylinux_2_24_s390x.whl", hash = "sha256:8ebb72dec9eefc3eb419de764d0510bbaa08e4db2b4a997576cce338a5f93c97"}, + {file = "pydantic_core-2.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc901bb6ffe6d983903242dd7495660161b8901307c5280534fee3b0a90f98e6"}, + {file = "pydantic_core-2.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4cd3178131bb7d0d3df947587d76cf9d1ab4318fe45e8ad18dafba3b1f0cda6d"}, + {file = "pydantic_core-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc4cb821dc67963463f8d8be6dca8933210d050009b32f683d02444a3d5f1e02"}, + {file = "pydantic_core-2.0.2-cp311-none-win32.whl", hash = "sha256:9cf009170f5f93c3dad4c4f73d827541d4bb7099cf69216c091d8cdd33867255"}, + {file = "pydantic_core-2.0.2-cp311-none-win_amd64.whl", hash = "sha256:4ed79de66b4b9acdd613c48befe4afcbee05f6153d793df6922ffc392f46720e"}, + {file = "pydantic_core-2.0.2-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:26f948f36f679d84cb1b66be40775a09275579e9bba01178dbe9b8231dcbf691"}, + {file = "pydantic_core-2.0.2-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:c815a0908065dd8eae0740e55063fcf730c5ef86edf6210ecd53ace3a85c9911"}, + {file = "pydantic_core-2.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d088fdc5cc709a715cf9f49e698a5690cc00616d3379e55d07423e628a21a097"}, + {file = "pydantic_core-2.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e63f360661847422423410ebe755258aefad8bd67e9ac516eb1d02a90bdf788"}, + {file = "pydantic_core-2.0.2-cp37-cp37m-manylinux_2_24_armv7l.whl", hash = "sha256:0cec91249c78b5697294b01e66acb819433f4111ae640b7300dd5508a522342e"}, + {file = "pydantic_core-2.0.2-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:27b3eb357a801519dcf42f6c88a3a37e140cf29be21dd5dc152cfc9fa44c34d2"}, + {file = "pydantic_core-2.0.2-cp37-cp37m-manylinux_2_24_s390x.whl", hash = "sha256:038876cd2dfc1319e0256995ee74cdd90df2ce03bc6060d5eaee01cc78cf3dae"}, + {file = "pydantic_core-2.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f45943b592070fd744660fc8e31a010ae78a6e91f8e6431c07f6dce022eb03f"}, + {file = "pydantic_core-2.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c44ec0439fac342f773cd848b20cf28cc376670369a6d42845d180f18f2671e3"}, + {file = "pydantic_core-2.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5465264bbc535a8650a3806ae5bd07e2691428004a52c961281eadce519c60cc"}, + {file = "pydantic_core-2.0.2-cp37-none-win32.whl", hash = "sha256:26722063f83c3c4f596adc1eadfa03249afa38e75f3516684de9b57e15d07346"}, + {file = "pydantic_core-2.0.2-cp37-none-win_amd64.whl", hash = "sha256:638b474da73e71079f39a80e4d70196853c2d2fc98c3d425ce3a3ae738e2245f"}, + {file = "pydantic_core-2.0.2-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:bd9587083b48ec822960a8047249c8119e82749bdf96cecc2e1975322ccb1405"}, + {file = "pydantic_core-2.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2e02faa4a5e9bd1d7cb4b056c911826f67c4bf298979f89f07c3f2446cd0cf86"}, + {file = "pydantic_core-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24a46c1fd078f3dc7d075200e48b219ed0876f81753201a2d97ad09165d5383f"}, + {file = "pydantic_core-2.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8136e89efab6f8399bdaf5254758db37049eeaa2f39645ce999aa5162392be28"}, + {file = "pydantic_core-2.0.2-cp38-cp38-manylinux_2_24_armv7l.whl", hash = "sha256:07f02b4a474fa89be0bb0b0c42eb605d2a9c8fe11ea7f82fb754060fd0a5ac33"}, + {file = "pydantic_core-2.0.2-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:e5bcca875379fab98c7b8b4ddfe932844d9ac7dc0a850c5afa414d17988aed93"}, + {file = "pydantic_core-2.0.2-cp38-cp38-manylinux_2_24_s390x.whl", hash = "sha256:9d65b216c0e55414330e46c272896d4858a30d53310aa6e58520e2fc3d122deb"}, + {file = "pydantic_core-2.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:51defb4826a28644034915ec5f5a5d3be2d56b683891343d53dfca936c634326"}, + {file = "pydantic_core-2.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4277e1941faa5c59fddfc49dae98dc94c16288bc9a09c7b17599c8388aeadcb5"}, + {file = "pydantic_core-2.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c7a2c290d6abff5abf6566aa5ea07342e74af42f4defb1f33b3b3d9e7ff1c61f"}, + {file = "pydantic_core-2.0.2-cp38-none-win32.whl", hash = "sha256:21dcb4f0168f3877cb487dc18362b78bea1e877bcb9c6b4af7563d5e00508cc0"}, + {file = "pydantic_core-2.0.2-cp38-none-win_amd64.whl", hash = "sha256:1005ab00b3f39b044408a357b41b66709b6eca17092d2713ee4b79d85a86457b"}, + {file = "pydantic_core-2.0.2-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:210ed18f2c438b282a2d5710c07dfa42b8de63647f650c742ecd18a4e02a0618"}, + {file = "pydantic_core-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7d6a9e510ae4ea02db709472102fa7b59d48441a6c0419a7d21d0b96672a469"}, + {file = "pydantic_core-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d5cece19558a3490ace346d70322766e670c51ce98ab9bea3e85efba6c00424"}, + {file = "pydantic_core-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b73e646fda49a5b503f7484a8797a36697b28b5be3adb597460f1d3d337fb82"}, + {file = "pydantic_core-2.0.2-cp39-cp39-manylinux_2_24_armv7l.whl", hash = "sha256:7e5264ed7727ab09c410a98c47430c2ab426c2edb9a7b613ca1d785dd3506b7d"}, + {file = "pydantic_core-2.0.2-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:d2db12d32b3b83c3d1a2044f9ba31aca9a8224c7eb15d949bdae3e826ee8c6ec"}, + {file = "pydantic_core-2.0.2-cp39-cp39-manylinux_2_24_s390x.whl", hash = "sha256:3127bd2a5764ed08529ca03f8b9e486d347fb2f604cd8333ae7e55a1693073af"}, + {file = "pydantic_core-2.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c9f856a5c8938f2e0c7bb337f09d5212afd390627929c53e5f0c5944c99732fc"}, + {file = "pydantic_core-2.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f8119485a74487780fecf8c03cce66a2fb13da2e68f4219af7aca9d0eb8ff64d"}, + {file = "pydantic_core-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e931731368ea56f1787fc408757708348639ef2aa1f01e3d483ad1574780b92"}, + {file = "pydantic_core-2.0.2-cp39-none-win32.whl", hash = "sha256:1fa900836d3995ecf34b48f4687a7908b5de85f194e534a7f3a88bfeaee7e25b"}, + {file = "pydantic_core-2.0.2-cp39-none-win_amd64.whl", hash = "sha256:e6973ccb84a532e35b6a9f7f8d6024688186d950278700d408836219aa5b6164"}, + {file = "pydantic_core-2.0.2-pp37-pypy37_pp73-macosx_10_7_x86_64.whl", hash = "sha256:ca833856df881b9809747131c38bf7b6af7262ab2c77a2834b9e9d64cf43ab4e"}, + {file = "pydantic_core-2.0.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a349f816319ac85759a19ccb0e93992fe77f8e1961a389cd15c3b5c6098bcabd"}, + {file = "pydantic_core-2.0.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b67ede74b43598feb405a628c83087b3df1066a388ab060cdd5333d061ecf3f5"}, + {file = "pydantic_core-2.0.2-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bf4bb512eb302acbef4774f65a9ae83edfb283055de7b18b9656b8fda0869652"}, + {file = "pydantic_core-2.0.2-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:fede91ea67570eb296d4ae88aecb9c51a46cdccb35a388dba759183ba84c61d6"}, + {file = "pydantic_core-2.0.2-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:7d1c453a36e69ddd4ea47a8e5426a63fdcb731d18122571fbdfda23b07ad28b1"}, + {file = "pydantic_core-2.0.2-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:751e6deca13d89bc5ffc4684ac8a4ea08c6c0ac8dfe12cc5d6927f249879131d"}, + {file = "pydantic_core-2.0.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:312263dea8116f68972c41c53c0a5b5bf9f7732e7bdc978acb847ed7c9fc8207"}, + {file = "pydantic_core-2.0.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17334cef22055154b7faf7254cc0bf86fea34a7343225b8c6d2d0e54f3533048"}, + {file = "pydantic_core-2.0.2-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:84f1eb4d23a37f77b20dabffe7d5971c6c8eea78bd977fcd2007704ccb540230"}, + {file = "pydantic_core-2.0.2-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4677520ade160805ad55a6418db7beea9dea34f0a091da1f0bcf09c66091b54"}, + {file = "pydantic_core-2.0.2-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:c63bb44c2af1250fcf6e8447b0fda17f09d28e4677910f5bc1328881ae2c527e"}, + {file = "pydantic_core-2.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:2c351a141124c216fe4a0119ef2fa5bc70eec710e59cdd79346475b3f78d15e9"}, + {file = "pydantic_core-2.0.2-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:f295db65d4de14c0b46168a6db73be34b8fe4e3e2699a9c574b37412d0dd2a41"}, + {file = "pydantic_core-2.0.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c344dd1c345b2206515edaba0e0bf4aa2b1c456822f3ac9bc0d9f7fc971a8934"}, + {file = "pydantic_core-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31f95633f6a3ddc8e0b850157ac0cedb8ccacbe4349310b4be6d724860d8f5c0"}, + {file = "pydantic_core-2.0.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c587db8f31a1c3270991945c20c2ace289fbfa7cf2d533f67f47e95c9ead83e"}, + {file = "pydantic_core-2.0.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a10ce991b6986c91fdf100611d97f76b2950a1d2c2e72be0484565bf95b03767"}, + {file = "pydantic_core-2.0.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:aab82425d10bf0624e4a7ac902eed33adae413e827b53d82ae131a10c3130208"}, + {file = "pydantic_core-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:b024721a940a3311328d50f7cc3d9a7aced0f5ee1fd30c0fa7cbbc542ec3a55c"}, + {file = "pydantic_core-2.0.2.tar.gz", hash = "sha256:996ffb7ae3c8cb7506a58dae52bbf13a7bbbfce6c3110a2b44c20d2587e57b9b"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pyflakes" @@ -550,4 +621,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "521e75c0b078d08bd8376a82a58f7294e3291db69458c95cbc59824c1b6c6c92" +content-hash = "f1b8eb95992e3aff905933513b5ba97f703c5a2369de0d7a6561f926964e05f3" diff --git a/pyproject.toml b/pyproject.toml index 4d49040..3e50fa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ packages = [{include = "django_api_decorator"}] [tool.poetry.dependencies] python = "^3.10" Django = ">=3" -pydantic = "^1.10.2" +pydantic = "^2.0" [tool.poetry.group.dev.dependencies] diff --git a/tests/django_settings.py b/tests/django_settings.py index 18fc015..8d008c8 100644 --- a/tests/django_settings.py +++ b/tests/django_settings.py @@ -2,3 +2,6 @@ USE_TZ = True API_DECORATOR_DEFAULT_ATOMIC = False +API_DECORATOR_DEFAULT_LOGIN_REQUIRED = False + +ROOT_URLCONF = "tests.urls" diff --git a/tests/test_decorator.py b/tests/test_decorator.py index d6c73e5..7d10d1d 100644 --- a/tests/test_decorator.py +++ b/tests/test_decorator.py @@ -318,15 +318,15 @@ def view(request: HttpRequest, body: list[Body]) -> JsonResponse: == 400 ) - # Empty list is not a valid payload assert ( client.post( "/", data=[], content_type="application/json", ).status_code - == 400 + == 200 ) + assert ( client.post( "/", @@ -343,4 +343,4 @@ def view(request: HttpRequest, body: list[Body]) -> JsonResponse: content_type="application/json", ) assert response.status_code == 400 - assert response.json()["field_errors"].keys() == {"num"} + assert response.json()["field_errors"].keys() == {"0.num", "1.num", "1.d"} diff --git a/tests/test_openapi.py b/tests/test_openapi.py index c2326b6..36bc49b 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -96,7 +96,10 @@ def view( "name": "opt-num", "in": "query", "required": False, - "schema": {"type": "integer"}, + "schema": { + "anyOf": [{"type": "integer"}, {"type": "null"}] + }, + "default": None, }, { "name": "date", @@ -108,7 +111,13 @@ def view( "name": "opt-date", "in": "query", "required": False, - "schema": {"type": "string", "format": "date"}, + "schema": { + "anyOf": [ + {"type": "string", "format": "date"}, + {"type": "null"}, + ] + }, + "default": None, }, { "name": "string", @@ -120,7 +129,8 @@ def view( "name": "opt-string", "in": "query", "required": False, - "schema": {"type": "string"}, + "schema": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "default": None, }, { "name": "boolean", @@ -132,7 +142,10 @@ def view( "name": "opt-boolean", "in": "query", "required": False, - "schema": {"type": "boolean"}, + "schema": { + "anyOf": [{"type": "boolean"}, {"type": "null"}] + }, + "default": None, }, ], "requestBody": { @@ -160,8 +173,8 @@ def view( "schemas": { "State": { "title": "State", - "description": "An enumeration.", "enum": [1, 2], + "type": "integer", }, "Response": { "title": "Response", @@ -178,9 +191,15 @@ def view( "properties": { "name": {"title": "Name", "type": "string"}, "num": {"title": "Num", "type": "integer"}, - "d": {"title": "D", "type": "string", "format": "date"}, + "d": { + "title": "D", + "anyOf": [ + {"type": "string", "format": "date"}, + {"type": "null"}, + ], + }, }, - "required": ["name", "num"], + "required": ["name", "num", "d"], }, } }, @@ -225,7 +244,7 @@ def view(request: HttpRequest) -> A | B | C: "content": { "application/json": { "schema": { - "oneOf": [ + "anyOf": [ {"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}, {"$ref": "#/components/schemas/C"}, diff --git a/tests/test_response_encoder.py b/tests/test_response_encoder.py deleted file mode 100644 index 6312f25..0000000 --- a/tests/test_response_encoder.py +++ /dev/null @@ -1,106 +0,0 @@ -import dataclasses -from typing import Any, Union - -import pydantic -import pytest -from django.http.response import HttpResponse - -from django_api_decorator.decorators import ( - _dataclass_encoder, - _get_response_encoder, - _json_encoder, - _pydantic_encoder, -) - - -def test_get_response_encoder_any() -> None: - assert _get_response_encoder(type_annotation=Any) == _json_encoder - - -def test_get_response_encoder_dict() -> None: - assert _get_response_encoder(type_annotation=dict) == _json_encoder - - -def test_get_response_encoder_pydantic_model() -> None: - class PydanticRecord(pydantic.BaseModel): - pass - - assert _get_response_encoder(type_annotation=PydanticRecord) == _pydantic_encoder - - -def test_get_response_encoder_dataclass() -> None: - @dataclasses.dataclass - class DataclassRecord: - pass - - assert _get_response_encoder(type_annotation=DataclassRecord) == _dataclass_encoder - - -def test_get_response_encoder_http_response() -> None: - response_encoder = _get_response_encoder(type_annotation=HttpResponse) - assert callable(response_encoder) - assert response_encoder.__name__ == "" # type: ignore[attr-defined] - - -def test_get_response_encoder_list_of_dicts() -> None: - assert ( - _get_response_encoder(type_annotation=list[dict]) # type: ignore - == _json_encoder - ) - assert _get_response_encoder(type_annotation=list[dict[str, Any]]) == _json_encoder - - -def test_get_response_encoder_union_of_str_int_bool() -> None: - assert _get_response_encoder(type_annotation=Union[str, int, bool]) == _json_encoder - assert _get_response_encoder(type_annotation=str | int | bool) == _json_encoder - - -def test_get_response_encoder_list_of_pydantic_records() -> None: - class PydanticRecord(pydantic.BaseModel): - pass - - assert ( - _get_response_encoder(type_annotation=list[PydanticRecord]) == _pydantic_encoder - ) - - -def test_get_response_encoder_list_of_dataclasses() -> None: - @dataclasses.dataclass - class DataclassRecord: - pass - - with pytest.raises(NotImplementedError): - _get_response_encoder(type_annotation=list[DataclassRecord]) - - -def test_get_response_encoder_union_of_pydantic_records() -> None: - class PydanticRecord(pydantic.BaseModel): - pass - - class PydanticTwoRecord(pydantic.BaseModel): - pass - - assert ( - _get_response_encoder(type_annotation=Union[PydanticRecord, PydanticTwoRecord]) - == _pydantic_encoder - ) - - -def test_get_response_encoder_union_of_pydantic_and_dict() -> None: - class PydanticRecord(pydantic.BaseModel): - pass - - with pytest.raises(NotImplementedError): - _get_response_encoder(type_annotation=Union[PydanticRecord, dict]) - - -def test_get_response_encoder_union_of_pydantic_and_dataclass() -> None: - class PydanticRecord(pydantic.BaseModel): - pass - - @dataclasses.dataclass - class DataclassRecord: - pass - - with pytest.raises(NotImplementedError): - _get_response_encoder(type_annotation=Union[PydanticRecord, DataclassRecord]) diff --git a/tests/test_response_encoding.py b/tests/test_response_encoding.py new file mode 100644 index 0000000..8072f01 --- /dev/null +++ b/tests/test_response_encoding.py @@ -0,0 +1,206 @@ +import random + +import pytest +from django.http import HttpRequest, JsonResponse +from django.test.client import Client +from django.urls import path +from pydantic import BaseModel +from typing_extensions import TypedDict + +from django_api_decorator.decorators import api +from django_api_decorator.openapi import generate_api_spec + + +class MyTypedDict(TypedDict): + a: int + + +class MyPydanticModel(BaseModel): + a: int + + +@api(method="GET") +def view_json_response(r: HttpRequest) -> JsonResponse: + return JsonResponse({"a": 1}) + + +@api(method="GET") +def view_typed_dict(r: HttpRequest) -> MyTypedDict: + return {"a": 1} + + +@api(method="GET") +def view_int(r: HttpRequest) -> int: + return 1 + + +@api(method="GET") +def view_bool(r: HttpRequest) -> bool: + return False + + +@api(method="GET") +def view_pydantic_model(r: HttpRequest) -> MyPydanticModel: + return MyPydanticModel(a=1) + + +@api(method="GET") +def view_union(r: HttpRequest) -> int | str: + return random.choice([1, "foo"]) # type: ignore[return-value] + + +urlpatterns = [ + path("json-response", view_json_response), + path("typed-dict", view_typed_dict), + path("int", view_int), + path("bool", view_bool), + path("pydantic-model", view_pydantic_model), + path("union", view_union), +] + + +@pytest.mark.parametrize( + "url,expected_response", + [ + ("/json-response", b'{"a": 1}'), + ("/typed-dict", b'{"a":1}'), + ("/int", b"1"), + ("/bool", b"false"), + ("/pydantic-model", b'{"a":1}'), + ], +) +@pytest.mark.urls(__name__) +def test_response_encoding(url: str, expected_response: bytes, client: Client) -> None: + response = client.get(url) + assert response.status_code == 200 + assert response.content == expected_response + + +def test_schema() -> None: + spec = generate_api_spec(urlpatterns) + assert spec == { + "openapi": "3.0.0", + "info": {"title": "API overview", "version": "0.0.1"}, + "paths": { + "/union": { + "get": { + "operationId": "view_union", + "description": "", + "tags": ["test_response_encoding"], + "parameters": [], + "responses": { + 200: { + "description": "", + "content": { + "application/json": { + "schema": { + "anyOf": [ + {"type": "integer"}, + {"type": "string"}, + ] + } + } + }, + } + }, + } + }, + "/pydantic-model": { + "get": { + "operationId": "view_pydantic_model", + "description": "", + "tags": ["test_response_encoding"], + "parameters": [], + "responses": { + 200: { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyPydanticModel" + } + } + }, + } + }, + } + }, + "/bool": { + "get": { + "operationId": "view_bool", + "description": "", + "tags": ["test_response_encoding"], + "parameters": [], + "responses": { + 200: { + "description": "", + "content": { + "application/json": {"schema": {"type": "boolean"}} + }, + } + }, + } + }, + "/int": { + "get": { + "operationId": "view_int", + "description": "", + "tags": ["test_response_encoding"], + "parameters": [], + "responses": { + 200: { + "description": "", + "content": { + "application/json": {"schema": {"type": "integer"}} + }, + } + }, + } + }, + "/typed-dict": { + "get": { + "operationId": "view_typed_dict", + "description": "", + "tags": ["test_response_encoding"], + "parameters": [], + "responses": { + 200: { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyTypedDict" + } + } + }, + } + }, + } + }, + "/json-response": { + "get": { + "operationId": "view_json_response", + "description": "", + "tags": ["test_response_encoding"], + "parameters": [], + "responses": {200: {"description": ""}}, + } + }, + }, + "components": { + "schemas": { + "MyPydanticModel": { + "properties": {"a": {"title": "A", "type": "integer"}}, + "required": ["a"], + "title": "MyPydanticModel", + "type": "object", + }, + "MyTypedDict": { + "properties": {"a": {"title": "A", "type": "integer"}}, + "required": ["a"], + "title": "MyTypedDict", + "type": "object", + }, + } + }, + }