├── docs ├── tools │ ├── __init__.py │ └── sphinx_ext │ │ └── __init__.py ├── examples │ ├── __init__.py │ ├── middleware │ │ ├── __init__.py │ │ ├── custom_header.py │ │ ├── async_controller.py │ │ ├── get_response.py │ │ ├── add_request_id.py │ │ ├── usage_add_request_id.py │ │ ├── csrf_protect_json.py │ │ ├── multi_status.py │ │ ├── complete_csrf_setup.py │ │ ├── rate_limit.py │ │ └── built_in_decorators.py │ ├── using_controller │ │ ├── simple_routing_controllers.py │ │ ├── compose_blueprints.py │ │ ├── blueprints.py │ │ └── custom_meta.py │ ├── routing │ │ ├── simple_router.py │ │ ├── controllers.py │ │ └── blueprints.py │ ├── validation │ │ └── httpspec │ │ │ ├── right_way.py │ │ │ ├── per_controller.py │ │ │ └── per_endpoint.py │ ├── testing │ │ ├── pydantic_controller.py │ │ └── polyfactory_usage.py │ ├── getting_started │ │ ├── urls.py │ │ ├── msgspec_controller.py │ │ ├── pydantic_controller.py │ │ └── single_file_asgi.py │ ├── returning_responses │ │ ├── modify.py │ │ ├── modify_cookies.py │ │ ├── modify_headers.py │ │ ├── validate.py │ │ ├── per_blueprint.py │ │ ├── active_validation.py │ │ ├── validate_cookies.py │ │ ├── per_endpoint.py │ │ ├── per_controller.py │ │ ├── validate_headers.py │ │ └── right_way.py │ ├── core_concepts │ │ └── glossary.py │ └── error_handling │ │ ├── blueprint.py │ │ ├── endpoint.py │ │ └── controller.py ├── pages │ ├── sse.rst │ ├── deep-dive │ │ ├── changelog.rst │ │ └── internal-api.rst │ ├── micro-framework.rst │ ├── plugins.rst │ └── components.rst ├── _static │ └── images │ │ ├── logo-dark.png │ │ ├── logo-light.png │ │ └── favicon.svg ├── _templates │ ├── moreinfo.html │ ├── badges.html │ └── github.html ├── Makefile └── make.bat ├── django_modern_rest ├── py.typed ├── plugins │ └── __init__.py ├── internal │ ├── __init__.py │ ├── io.py │ └── json │ │ └── __init__.py ├── openapi │ ├── core │ │ ├── __init__.py │ │ ├── registry.py │ │ ├── context.py │ │ ├── merger.py │ │ └── builder.py │ ├── generators │ │ ├── __init__.py │ │ ├── component.py │ │ └── path_item.py │ ├── __init__.py │ ├── objects │ │ ├── license.py │ │ ├── contact.py │ │ ├── external_documentation.py │ │ ├── server_variable.py │ │ ├── oauth_flow.py │ │ ├── paths.py │ │ ├── request_body.py │ │ ├── server.py │ │ ├── reference.py │ │ ├── oauth_flows.py │ │ ├── xml.py │ │ ├── encoding.py │ │ ├── tag.py │ │ ├── example.py │ │ ├── callback.py │ │ ├── discriminator.py │ │ ├── info.py │ │ ├── security_requirement.py │ │ ├── link.py │ │ ├── response.py │ │ ├── media_type.py │ │ ├── responses.py │ │ ├── enums.py │ │ ├── parameter.py │ │ ├── path_item.py │ │ ├── security_scheme.py │ │ ├── header.py │ │ ├── open_api.py │ │ ├── operation.py │ │ └── components.py │ ├── renderers │ │ ├── __init__.py │ │ ├── json.py │ │ ├── redoc.py │ │ ├── scalar.py │ │ └── swagger.py │ ├── config.py │ ├── views.py │ └── spec.py ├── static │ └── django_modern_rest │ │ └── swagger │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ └── index.css ├── apps.py ├── templates │ └── django_modern_rest │ │ ├── redoc.html │ │ ├── scalar.html │ │ └── swagger.html ├── validation │ ├── __init__.py │ ├── blueprint.py │ └── payload.py ├── __init__.py └── exceptions.py ├── django_test_app ├── server │ ├── __init__.py │ ├── apps │ │ ├── __init__.py │ │ ├── openapi │ │ │ ├── __init__.py │ │ │ ├── urls.py │ │ │ └── config.py │ │ ├── controllers │ │ │ ├── __init__.py │ │ │ └── urls.py │ │ ├── middlewares │ │ │ ├── __init__.py │ │ │ ├── urls.py │ │ │ └── middleware.py │ │ └── models_example │ │ │ ├── __init__.py │ │ │ ├── migrations │ │ │ └── __init__.py │ │ │ ├── urls.py │ │ │ ├── serializers.py │ │ │ ├── views.py │ │ │ ├── services.py │ │ │ └── models.py │ ├── asgi.py │ ├── wsgi.py │ └── urls.py ├── mypy.ini └── manage.py ├── .github ├── bake.toml ├── zizmor.yml ├── dependabot.yml ├── workflows │ └── relator.yml └── pull_request_template.md ├── benchmarks ├── requirements.txt ├── Makefile └── apps │ └── fastapi.py ├── CHANGELOG.md ├── tests ├── test_integration │ ├── test_openapi │ │ ├── __init__.py │ │ ├── test_schema.py │ │ └── test_renderers.py │ ├── test_models_example.py │ └── test_contollers │ │ └── test_user_update_controller.py └── test_unit │ ├── test_response │ └── test_build_response.py │ ├── test_controllers │ ├── test_http_method_not_allowed.py │ ├── test_customization │ │ ├── test_endpoint_customization.py │ │ ├── test_serializer_context_customization.py │ │ ├── test_disable_errors_from_components.py │ │ └── test_contoller_validator_customization.py │ ├── test_deprecated_parts.py │ ├── test_query_method.py │ ├── test_head_method.py │ ├── test_extend_controller.py │ └── test_collect_metadata.py │ ├── test_validation │ └── test_http_spec.py │ ├── test_plugins │ ├── test_pydantic │ │ ├── test_none_return.py │ │ ├── test_forward_ref.py │ │ └── test_model_config.py │ └── test_msgspec │ │ ├── test_components.py │ │ └── test_serializers.py │ ├── test_endpoint │ ├── test_validate_forward_ref.py │ ├── test_runtime_error.py │ ├── test_endpoint_future_annotations.py │ └── test_request_invalid.py │ ├── test_openapi │ ├── test_core │ │ └── test_registry.py │ └── test_views.py │ ├── test_decorators │ ├── test_dispatch_decorator.py │ └── test_endpoint_decorator.py │ └── test_components │ └── test_type_params.py ├── schemathesis.toml ├── .editorconfig ├── .readthedocs.yml ├── typesafety └── assert_type │ ├── test_routing.py │ ├── test_msgspec.py │ └── test_controller.py ├── LICENSE ├── Makefile ├── .importlinter ├── django_modern_rest_pytest.py ├── setup.cfg ├── .pre-commit-config.yaml └── CONTRIBUTING.md /docs/tools/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_modern_rest/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_modern_rest/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_test_app/server/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/examples/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_modern_rest/internal/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_test_app/server/apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_test_app/server/apps/openapi/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/generators/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_test_app/server/apps/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_test_app/server/apps/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_test_app/server/apps/models_example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_test_app/server/apps/models_example/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/pages/sse.rst: -------------------------------------------------------------------------------- 1 | Server Sent Events aka SSE 2 | ========================== 3 | -------------------------------------------------------------------------------- /.github/bake.toml: -------------------------------------------------------------------------------- 1 | [formatter] 2 | ensure_final_newline = true 3 | auto_insert_phony_declarations = true 4 | -------------------------------------------------------------------------------- /.github/zizmor.yml: -------------------------------------------------------------------------------- 1 | rules: 2 | unpinned-uses: 3 | config: 4 | policies: 5 | "*": ref-pin 6 | -------------------------------------------------------------------------------- /benchmarks/requirements.txt: -------------------------------------------------------------------------------- 1 | -e ..[msgspec] 2 | fastapi 3 | djangorestframework 4 | django-ninja 5 | uvicorn 6 | gunicorn 7 | tabulate 8 | -------------------------------------------------------------------------------- /docs/_static/images/logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wemake-services/django-modern-rest/HEAD/docs/_static/images/logo-dark.png -------------------------------------------------------------------------------- /docs/_static/images/logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wemake-services/django-modern-rest/HEAD/docs/_static/images/logo-light.png -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Version history 2 | 3 | We follow [Semantic Versions](https://semver.org/). 4 | 5 | ## Version 0.1.0 6 | 7 | - Initial release 8 | -------------------------------------------------------------------------------- /docs/pages/deep-dive/changelog.rst: -------------------------------------------------------------------------------- 1 | List of changes 2 | =============== 3 | 4 | .. include:: ../../../CHANGELOG.md 5 | :parser: myst_parser.sphinx_ 6 | -------------------------------------------------------------------------------- /tests/test_integration/test_openapi/__init__.py: -------------------------------------------------------------------------------- 1 | # This file exists to resolve mypy duplicate module error 2 | # for test_renderers.py files in both unit and integration test directories 3 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/__init__.py: -------------------------------------------------------------------------------- 1 | from django_modern_rest.openapi.config import OpenAPIConfig as OpenAPIConfig 2 | from django_modern_rest.openapi.spec import openapi_spec as openapi_spec 3 | -------------------------------------------------------------------------------- /django_modern_rest/static/django_modern_rest/swagger/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wemake-services/django-modern-rest/HEAD/django_modern_rest/static/django_modern_rest/swagger/favicon-16x16.png -------------------------------------------------------------------------------- /django_modern_rest/static/django_modern_rest/swagger/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wemake-services/django-modern-rest/HEAD/django_modern_rest/static/django_modern_rest/swagger/favicon-32x32.png -------------------------------------------------------------------------------- /django_modern_rest/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjangoModernRestConfig(AppConfig): 5 | """Class representing a Django Modern Rest application.""" 6 | 7 | name = 'django_modern_rest' 8 | -------------------------------------------------------------------------------- /django_modern_rest/static/django_modern_rest/swagger/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | overflow: -moz-scrollbars-vertical; 4 | overflow-y: scroll; 5 | } 6 | 7 | *, 8 | *:before, 9 | *:after { 10 | box-sizing: inherit; 11 | } 12 | 13 | body { 14 | margin: 0; 15 | background: #fafafa; 16 | } 17 | -------------------------------------------------------------------------------- /benchmarks/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: bench 2 | bench: 3 | python run_benchmark.py 4 | 5 | .PHONY: bench-resolver 6 | bench-resolver: 7 | hyperfine --warmup 1 --shell=none -L impl django,dmr --show-output \ 8 | -L case best,avg,worst --min-runs=5 \ 9 | -n {impl}-{case} \ 10 | "python features/url_resolver.py --impl {impl} --case {case}" 11 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/license.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import final 3 | 4 | 5 | @final 6 | @dataclass(frozen=True, kw_only=True, slots=True) 7 | class License: 8 | """License information for the exposed API.""" 9 | 10 | name: str 11 | identifier: str | None = None 12 | url: str | None = None 13 | -------------------------------------------------------------------------------- /django_test_app/server/apps/models_example/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from django_modern_rest.routing import Router 4 | from server.apps.models_example import views 5 | 6 | router = Router([ 7 | path( 8 | 'user', 9 | views.UserCreateController.as_view(), 10 | name='user_model_create', 11 | ), 12 | ]) 13 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/contact.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import final 3 | 4 | 5 | @final 6 | @dataclass(frozen=True, kw_only=True, slots=True) 7 | class Contact: 8 | """Contact information for the exposed API.""" 9 | 10 | name: str | None = None 11 | url: str | None = None 12 | email: str | None = None 13 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/external_documentation.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import final 3 | 4 | 5 | @final 6 | @dataclass(frozen=True, kw_only=True, slots=True) 7 | class ExternalDocumentation: 8 | """Allows referencing an external resource for extended documentation.""" 9 | 10 | url: str 11 | description: str | None = None 12 | -------------------------------------------------------------------------------- /docs/pages/micro-framework.rst: -------------------------------------------------------------------------------- 1 | Micro-framework out of Django 2 | ============================= 3 | 4 | Single file Django 5 | ------------------ 6 | 7 | You don't need microframeworks to build small APIs, because 8 | Django is a microframework itself. With the only difference: it scales! 9 | 10 | .. literalinclude:: /examples/getting_started/single_file_asgi.py 11 | :linenos: 12 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/server_variable.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import final 3 | 4 | 5 | @final 6 | @dataclass(frozen=True, kw_only=True, slots=True) 7 | class ServerVariable: 8 | """An object representing a `Server Variable` for server URL template.""" 9 | 10 | default: str 11 | enum: list[str] | None = None 12 | description: str | None = None 13 | -------------------------------------------------------------------------------- /docs/examples/using_controller/simple_routing_controllers.py: -------------------------------------------------------------------------------- 1 | from django_modern_rest.controller import Controller 2 | from django_modern_rest.serialization import BaseSerializer 3 | 4 | 5 | class UserList(Controller[BaseSerializer]): 6 | """UserList.""" 7 | 8 | 9 | class PostList(Controller[BaseSerializer]): 10 | """PostList.""" 11 | 12 | 13 | class UserDetail(Controller[BaseSerializer]): 14 | """UserDetail.""" 15 | -------------------------------------------------------------------------------- /schemathesis.toml: -------------------------------------------------------------------------------- 1 | # We need test repeatability. 2 | # We will achieve diversity by using a number of examples. 3 | seed = 42 4 | 5 | # NOTE: We extend the rules as the schema generation results improve. 6 | [phases.examples] 7 | enabled = true 8 | 9 | [phases.coverage] 10 | enabled = true 11 | 12 | [phases.fuzzing] 13 | enabled = true 14 | 15 | [phases.stateful] 16 | enabled = true 17 | 18 | [generation] 19 | max-examples = 1 20 | -------------------------------------------------------------------------------- /docs/examples/routing/simple_router.py: -------------------------------------------------------------------------------- 1 | from examples.using_controller import simple_routing_controllers as views # noqa: I001 2 | 3 | from django_modern_rest.routing import Router, path 4 | 5 | router = Router( 6 | [ 7 | path('api/v1/users/', views.UserList.as_view()), 8 | path('api/v1/posts/', views.PostList.as_view()), 9 | path('api/v1/users//', views.UserDetail.as_view()), 10 | ], 11 | ) 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "02:00" 8 | open-pull-requests-limit: 10 9 | cooldown: 10 | default-days: 7 11 | 12 | - package-ecosystem: github-actions 13 | directory: "/" 14 | schedule: 15 | interval: daily 16 | time: "02:00" 17 | open-pull-requests-limit: 10 18 | cooldown: 19 | default-days: 7 20 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/oauth_flow.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import final 3 | 4 | 5 | @final 6 | @dataclass(frozen=True, kw_only=True, slots=True) 7 | class OAuthFlow: 8 | """Configuration details for a supported OAuth Flow.""" 9 | 10 | authorization_url: str | None = None 11 | token_url: str | None = None 12 | refresh_url: str | None = None 13 | scopes: dict[str, str] | None = None 14 | -------------------------------------------------------------------------------- /docs/examples/routing/controllers.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | from django_modern_rest.routing import Router 4 | from examples.using_controller.custom_meta import SettingsController 5 | 6 | router = Router([ 7 | path( 8 | 'settings/', 9 | SettingsController.as_view(), 10 | name='settings', 11 | ), 12 | ]) 13 | 14 | urlpatterns = [ 15 | path('api/', include((router.urls, 'server'), namespace='api')), 16 | ] 17 | -------------------------------------------------------------------------------- /docs/pages/deep-dive/internal-api.rst: -------------------------------------------------------------------------------- 1 | Internal API 2 | ============ 3 | 4 | 5 | json serialization 6 | ------------------ 7 | 8 | .. autoclass:: django_modern_rest.internal.json.Serialize 9 | :members: 10 | 11 | .. autoclass:: django_modern_rest.internal.json.Deserialize 12 | :members: 13 | 14 | 15 | middleware wrappers 16 | ------------------- 17 | 18 | .. autoclass:: django_modern_rest.internal.middleware_wrapper.DecoratorWithResponses 19 | :members: 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Check http://editorconfig.org for more information 2 | # This is the main config file for this project: 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | trim_trailing_whitespace = true 12 | 13 | [*.{py,pyi}] 14 | indent_style = space 15 | indent_size = 4 16 | 17 | [Makefile] 18 | indent_style = tab 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/paths.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from django_modern_rest.openapi.objects import PathItem 5 | 6 | Paths = dict[str, 'PathItem'] 7 | """ 8 | Holds the relative paths to the individual endpoints and their operations. 9 | The path is appended to the URL from the Server Object in order to construct 10 | the full URL. The Paths MAY be empty, due to 11 | Access Control List (ACL) constraints. 12 | """ 13 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/request_body.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, final 3 | 4 | if TYPE_CHECKING: 5 | from django_modern_rest.openapi.objects.media_type import MediaType 6 | 7 | 8 | @final 9 | @dataclass(frozen=True, kw_only=True, slots=True) 10 | class RequestBody: 11 | """Describes a single request body.""" 12 | 13 | content: 'dict[str, MediaType]' 14 | description: str | None = None 15 | required: bool = False 16 | -------------------------------------------------------------------------------- /django_test_app/server/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for django_test_app project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /django_test_app/server/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for django_test_app project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/server.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, final 3 | 4 | if TYPE_CHECKING: 5 | from django_modern_rest.openapi.objects.server_variable import ( 6 | ServerVariable, 7 | ) 8 | 9 | 10 | @final 11 | @dataclass(frozen=True, kw_only=True, slots=True) 12 | class Server: 13 | """An object representing a `Server`.""" 14 | 15 | url: str 16 | description: str | None = None 17 | variables: 'dict[str, ServerVariable] | None' = None 18 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/reference.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import final 3 | 4 | 5 | @final 6 | @dataclass(frozen=True, kw_only=True, slots=True) 7 | class Reference: 8 | """ 9 | A simple object to allow referencing other components in document. 10 | 11 | The `$ref` string value contains a URI RFC3986, which identifies 12 | the location of the value being referenced. 13 | """ 14 | 15 | ref: str 16 | summary: str | None = None 17 | description: str | None = None 18 | -------------------------------------------------------------------------------- /django_test_app/server/apps/models_example/serializers.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from typing import final 3 | 4 | import pydantic 5 | 6 | 7 | @final 8 | class TagSchema(pydantic.BaseModel): 9 | name: str 10 | 11 | 12 | @final 13 | class RoleSchema(pydantic.BaseModel): 14 | name: str 15 | 16 | 17 | class UserCreateSchema(pydantic.BaseModel): 18 | email: str 19 | role: RoleSchema 20 | tag_list: list[TagSchema] 21 | 22 | 23 | @final 24 | class UserSchema(UserCreateSchema): 25 | id: int 26 | created_at: dt.datetime 27 | -------------------------------------------------------------------------------- /docs/_templates/moreinfo.html: -------------------------------------------------------------------------------- 1 |

2 | Links 3 |

4 | 18 | -------------------------------------------------------------------------------- /docs/examples/validation/httpspec/right_way.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import final 3 | 4 | from django_modern_rest import Controller, modify 5 | from django_modern_rest.plugins.pydantic import PydanticSerializer 6 | 7 | 8 | @final 9 | class JobController(Controller[PydanticSerializer]): 10 | @modify(status_code=HTTPStatus.NO_CONTENT) 11 | def post(self) -> None: 12 | print('Job created') # noqa: WPS421 13 | 14 | 15 | # run: {"controller": "JobController", "method": "post", "url": "/api/job/", "curl_args": ["-D", "-"]} # noqa: ERA001, E501 16 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/oauth_flows.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, final 3 | 4 | if TYPE_CHECKING: 5 | from django_modern_rest.openapi.objects.oauth_flow import OAuthFlow 6 | 7 | 8 | @final 9 | @dataclass(frozen=True, kw_only=True, slots=True) 10 | class OAuthFlows: 11 | """Allows configuration of the supported OAuth Flows.""" 12 | 13 | implicit: 'OAuthFlow | None' = None 14 | password: 'OAuthFlow | None' = None 15 | client_credentials: 'OAuthFlow | None' = None 16 | authorization_code: 'OAuthFlow | None' = None 17 | -------------------------------------------------------------------------------- /django_modern_rest/internal/io.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, TypeVar 2 | 3 | _ItemT = TypeVar('_ItemT') 4 | 5 | 6 | if TYPE_CHECKING: 7 | 8 | def identity(wrapped: _ItemT) -> _ItemT: 9 | """We still need to lie in type annotations. I am sad.""" 10 | raise NotImplementedError 11 | 12 | else: 13 | 14 | async def identity(wrapped: _ItemT) -> _ItemT: # noqa: RUF029 15 | """ 16 | Just returns an object wrapped in a coroutine. 17 | 18 | Needed for django view handling, where async views 19 | require coroutine return types. 20 | """ 21 | return wrapped 22 | -------------------------------------------------------------------------------- /docs/examples/middleware/custom_header.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import Any, TypeAlias 3 | 4 | from django.http import HttpRequest, HttpResponse 5 | 6 | _CallableAny: TypeAlias = Callable[..., Any] 7 | 8 | 9 | def custom_header_middleware( 10 | get_response: Callable[[HttpRequest], HttpResponse], 11 | ) -> _CallableAny: 12 | """Simple middleware that adds a custom header to response.""" 13 | 14 | def decorator(request: HttpRequest) -> Any: 15 | response = get_response(request) 16 | response['X-Custom-Header'] = 'CustomValue' 17 | return response 18 | 19 | return decorator 20 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/xml.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import final 3 | 4 | 5 | @final 6 | @dataclass(frozen=True, kw_only=True, slots=True) 7 | class XML: 8 | """ 9 | A metadata object that allows for more fine-tuned XML model definitions. 10 | 11 | When using arrays, XML element names are not inferred 12 | (for singular/plural forms) and the name property `SHOULD` be used 13 | to add that information. 14 | """ 15 | 16 | name: str | None = None 17 | namespace: str | None = None 18 | prefix: str | None = None 19 | attribute: bool = False 20 | wrapped: bool = False 21 | -------------------------------------------------------------------------------- /docs/examples/middleware/async_controller.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from django_modern_rest import Controller, ResponseSpec 4 | from django_modern_rest.plugins.pydantic import PydanticSerializer 5 | from examples.middleware.csrf_protect_json import csrf_protect_json 6 | 7 | 8 | @csrf_protect_json 9 | class AsyncController(Controller[PydanticSerializer]): 10 | """Example async controller using CSRF protection middleware.""" 11 | 12 | responses: ClassVar[list[ResponseSpec]] = csrf_protect_json.responses 13 | 14 | async def post(self) -> dict[str, str]: 15 | # Your async logic here 16 | return {'message': 'async response'} 17 | -------------------------------------------------------------------------------- /docs/examples/routing/blueprints.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | from django_modern_rest.routing import Router, compose_blueprints 4 | from examples.using_controller.blueprints import ( 5 | UserCreateBlueprint, 6 | UserListBlueprint, 7 | ) 8 | 9 | router = Router([ 10 | path( 11 | 'user/', 12 | compose_blueprints( 13 | UserCreateBlueprint, 14 | UserListBlueprint, 15 | # Can compose as many blueprints as you need! 16 | ).as_view(), 17 | name='users', 18 | ), 19 | ]) 20 | 21 | urlpatterns = [ 22 | path('api/', include((router.urls, 'server'), namespace='api')), 23 | ] 24 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/encoding.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, final 3 | 4 | if TYPE_CHECKING: 5 | from django_modern_rest.openapi.objects.header import Header 6 | from django_modern_rest.openapi.objects.reference import Reference 7 | 8 | 9 | @final 10 | @dataclass(frozen=True, kw_only=True, slots=True) 11 | class Encoding: 12 | """A single encoding definition applied to a single schema property.""" 13 | 14 | content_type: str | None = None 15 | headers: 'dict[str, Header | Reference] | None' = None 16 | style: str | None = None 17 | explode: bool = False 18 | allow_reserved: bool = False 19 | -------------------------------------------------------------------------------- /django_modern_rest/templates/django_modern_rest/redoc.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | {{ title|default:"ReDoc" }} 7 | 8 | 14 | 15 | 16 |
17 | 18 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = -W 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = django-modern-rest 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/tag.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, final 3 | 4 | if TYPE_CHECKING: 5 | from django_modern_rest.openapi.objects.external_documentation import ( 6 | ExternalDocumentation, 7 | ) 8 | 9 | 10 | @final 11 | @dataclass(frozen=True, kw_only=True, slots=True) 12 | class Tag: 13 | """ 14 | Adds metadata to a single tag that is used by the `Operation` object. 15 | 16 | It is not mandatory to have a `Tag` object per tag defined in the 17 | `Operation` object instances. 18 | """ 19 | 20 | name: str 21 | description: str | None = None 22 | external_docs: 'ExternalDocumentation | None' = None 23 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/example.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, final 3 | 4 | 5 | @final 6 | @dataclass(frozen=True, kw_only=True, slots=True) 7 | class Example: 8 | """ 9 | Example Object. 10 | 11 | In all cases, the example value is expected to be compatible with the 12 | type schema of its associated value. Tooling implementations MAY choose 13 | to validate compatibility automatically, and reject the example 14 | value(s) if incompatible. 15 | """ 16 | 17 | id: str | None = None 18 | summary: str | None = None 19 | description: str | None = None 20 | value: Any | None = None 21 | external_value: str | None = None 22 | -------------------------------------------------------------------------------- /.github/workflows/relator.yml: -------------------------------------------------------------------------------- 1 | name: Relator 2 | 3 | on: 4 | issues: 5 | types: [labeled] 6 | 7 | permissions: 8 | issues: read 9 | 10 | jobs: 11 | notify: 12 | name: "Telegram notification" 13 | if: github.event.label.name == 'help wanted' 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 2 16 | steps: 17 | - name: Send Telegram notification for new issue 18 | uses: reagento/relator@919d3a1593a3ed3e8b8f2f39013cc6f5498241da # v1.6.0 19 | with: 20 | tg-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} 21 | tg-chat-id: "@opensource_findings_python" 22 | github-token: ${{ secrets.GITHUB_TOKEN }} 23 | custom-labels: "django-modern-rest" 24 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-lts-latest 5 | tools: {python: "3.12"} 6 | jobs: 7 | pre_create_environment: 8 | - asdf plugin add poetry 9 | - asdf install poetry latest 10 | - asdf global poetry latest 11 | - poetry config virtualenvs.create false 12 | - poetry self add poetry-plugin-export 13 | - poetry export 14 | --all-extras 15 | --only main --only docs 16 | --format=requirements.txt 17 | --output=requirements.txt 18 | 19 | python: 20 | install: 21 | - requirements: requirements.txt 22 | - method: pip 23 | path: . 24 | 25 | sphinx: 26 | configuration: 'docs/conf.py' 27 | fail_on_warning: true 28 | -------------------------------------------------------------------------------- /django_test_app/server/apps/models_example/views.py: -------------------------------------------------------------------------------- 1 | from typing import final 2 | 3 | from django_modern_rest import ( 4 | Body, 5 | Controller, 6 | ) 7 | from django_modern_rest.plugins.pydantic import PydanticSerializer 8 | from server.apps.models_example.serializers import UserCreateSchema, UserSchema 9 | from server.apps.models_example.services import user_create_service 10 | 11 | 12 | @final 13 | class UserCreateController( 14 | Body[UserCreateSchema], 15 | Controller[PydanticSerializer], 16 | ): 17 | def post(self) -> UserSchema: 18 | return UserSchema.model_validate( 19 | user_create_service(self.parsed_body), 20 | from_attributes=True, # <- note 21 | ) 22 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/callback.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union 2 | 3 | if TYPE_CHECKING: 4 | from django_modern_rest.openapi.objects.path_item import PathItem 5 | from django_modern_rest.openapi.objects.reference import Reference 6 | 7 | Callback = dict[str, Union['PathItem', 'Reference']] 8 | """ 9 | A map of possible out-of band callbacks related to the parent operation. 10 | 11 | Each value in the map is a Path Item Object that describes a set of requests 12 | that may be initiated by the API provider and the expected responses. 13 | The key value used to identify the path item object is an expression, 14 | evaluated at runtime, that identifies a URL to use for the callback operation. 15 | """ 16 | -------------------------------------------------------------------------------- /docs/examples/testing/pydantic_controller.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import final 3 | 4 | import pydantic 5 | 6 | from django_modern_rest import Body, Controller 7 | from django_modern_rest.plugins.pydantic import PydanticSerializer 8 | 9 | 10 | class UserCreateModel(pydantic.BaseModel): 11 | email: str 12 | age: int 13 | 14 | 15 | class UserModel(UserCreateModel): 16 | uid: uuid.UUID 17 | 18 | 19 | @final 20 | class UserController( 21 | Controller[PydanticSerializer], 22 | Body[UserCreateModel], 23 | ): 24 | def post(self) -> UserModel: 25 | return UserModel( 26 | uid=uuid.uuid4(), 27 | age=self.parsed_body.age, 28 | email=self.parsed_body.email, 29 | ) 30 | -------------------------------------------------------------------------------- /docs/pages/plugins.rst: -------------------------------------------------------------------------------- 1 | Plugins 2 | ======= 3 | 4 | To be able to support multiple :term:`serializer` models 5 | like ``pydantic`` and ``msgspec``, we have a concept of a plugin. 6 | 7 | There are several bundled ones, but you can write your own as well. 8 | To do that see our advanced :ref:`serializer` guide. 9 | 10 | As a user you are only interested in choosing the right plugin 11 | for the :term:`controller` definition. 12 | 13 | .. tabs:: 14 | 15 | .. tab:: msgspec 16 | 17 | .. code:: python 18 | 19 | from django_modern_rest.plugins.msgspec import MsgspecSerializer 20 | 21 | .. tab:: pydantic 22 | 23 | .. code:: python 24 | 25 | from django_modern_rest.plugins.pydantic import PydanticSerializer 26 | -------------------------------------------------------------------------------- /typesafety/assert_type/test_routing.py: -------------------------------------------------------------------------------- 1 | from django_modern_rest import Blueprint 2 | from django_modern_rest.plugins.pydantic import PydanticSerializer 3 | from django_modern_rest.routing import compose_blueprints 4 | 5 | # 0 args: 6 | compose_blueprints() # type: ignore[call-arg] 7 | 8 | 9 | class _FirstBlueprint(Blueprint[PydanticSerializer]): ... 10 | 11 | 12 | # 1 arg: 13 | compose_blueprints(_FirstBlueprint) 14 | 15 | 16 | class _SecondBlueprint(Blueprint[PydanticSerializer]): ... 17 | 18 | 19 | # 2 args: 20 | compose_blueprints(_FirstBlueprint, _SecondBlueprint) 21 | 22 | 23 | class _ThirdBlueprint(Blueprint[PydanticSerializer]): ... 24 | 25 | 26 | # More args: 27 | compose_blueprints(_FirstBlueprint, _SecondBlueprint, _ThirdBlueprint) 28 | -------------------------------------------------------------------------------- /django_modern_rest/templates/django_modern_rest/scalar.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | {{ title|default:"Scalar API Reference"}} 7 | 8 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /docs/examples/validation/httpspec/per_controller.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import final 3 | 4 | from django_modern_rest import Controller, modify 5 | from django_modern_rest.plugins.pydantic import PydanticSerializer 6 | from django_modern_rest.settings import HttpSpec 7 | 8 | 9 | @final 10 | class JobController(Controller[PydanticSerializer]): 11 | no_validate_http_spec = frozenset((HttpSpec.empty_response_body,)) 12 | 13 | @modify(status_code=HTTPStatus.NO_CONTENT) 14 | def post(self) -> int: 15 | job_id = 4 # very random number :) 16 | return job_id # noqa: RET504 17 | 18 | 19 | # run: {"controller": "JobController", "method": "post", "url": "/api/job/", "curl_args": ["-D", "-"]} # noqa: ERA001, E501 20 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/discriminator.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import final 3 | 4 | 5 | @final 6 | @dataclass(unsafe_hash=True, frozen=True, kw_only=True, slots=True) 7 | class Discriminator: 8 | """ 9 | Discriminator Object. 10 | 11 | When request bodies or response payloads may be one of a number of 12 | different schemas, a discriminator object can be used to aid in 13 | serialization, deserialization, and validation. 14 | The discriminator is a specific object in a schema which is used to 15 | inform the consumer of the document of an alternative schema 16 | based on the value associated with it. 17 | """ 18 | 19 | property_name: str 20 | mapping: dict[str, str] | None = None 21 | -------------------------------------------------------------------------------- /docs/examples/middleware/get_response.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | from django.http import HttpRequest, HttpResponse 4 | 5 | 6 | def my_middleware( 7 | get_response: Callable[[HttpRequest], HttpResponse], 8 | ) -> Callable[[HttpRequest], HttpResponse]: 9 | """ 10 | get_response is a callback. 11 | 12 | It will: 13 | 1. Call the next middleware (if any) 14 | 2. Eventually call your controller/view 15 | 3. Return the response 16 | """ 17 | 18 | def middleware_function(request: HttpRequest) -> HttpResponse: # noqa: WPS430 19 | # Your middleware logic here 20 | response = get_response(request) # Call the view 21 | return response # noqa: RET504 22 | 23 | return middleware_function 24 | -------------------------------------------------------------------------------- /docs/examples/validation/httpspec/per_endpoint.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import final 3 | 4 | from django_modern_rest import Controller, modify 5 | from django_modern_rest.plugins.pydantic import PydanticSerializer 6 | from django_modern_rest.settings import HttpSpec 7 | 8 | 9 | @final 10 | class JobController(Controller[PydanticSerializer]): 11 | @modify( 12 | status_code=HTTPStatus.NO_CONTENT, 13 | no_validate_http_spec={HttpSpec.empty_response_body}, 14 | ) 15 | def post(self) -> int: 16 | job_id = 4 # very random number :) 17 | return job_id # noqa: RET504 18 | 19 | 20 | # run: {"controller": "JobController", "method": "post", "url": "/api/job/", "curl_args": ["-D", "-"]} # noqa: ERA001, E501 21 | -------------------------------------------------------------------------------- /docs/examples/getting_started/urls.py: -------------------------------------------------------------------------------- 1 | # needs for run example 2 | from .pydantic_controller import UserController # noqa: I001, WPS300 3 | 4 | from django.urls import include, path 5 | 6 | from django_modern_rest.routing import Router 7 | 8 | 9 | # Router is just a collection of regular Django urls: 10 | router = Router([ 11 | path( 12 | 'user/', 13 | UserController.as_view(), 14 | name='users', 15 | ), 16 | ]) 17 | 18 | # Just a regular `urlpatterns` definition. 19 | urlpatterns = [ 20 | path('api/', include((router.urls, 'rest_app'), namespace='api')), 21 | ] 22 | 23 | # run: {"controller": "UserController", "method": "post", "body": {"email": "user@wms.org"}, "headers": {"X-API-Consumer": "my-api"}, "url": "/api/user/"} # noqa: ERA001, E501 24 | -------------------------------------------------------------------------------- /docs/examples/using_controller/compose_blueprints.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, final 2 | 3 | from django_modern_rest.controller import BlueprintsT, Controller 4 | from django_modern_rest.plugins.pydantic import PydanticSerializer 5 | from examples.using_controller.blueprints import ( 6 | UserCreateBlueprint, 7 | UserListBlueprint, 8 | ) 9 | 10 | 11 | @final 12 | class ComposedController(Controller[PydanticSerializer]): 13 | blueprints: ClassVar[BlueprintsT] = [ 14 | UserListBlueprint, 15 | UserCreateBlueprint, 16 | ] 17 | 18 | 19 | # run: {"controller": "ComposedController", "method": "get"} # noqa: ERA001, E501 20 | # run: {"controller": "ComposedController", "method": "post", "body": {"email": "user@wms.org", "age": 10}} # noqa: ERA001, E501 21 | -------------------------------------------------------------------------------- /django_test_app/server/apps/openapi/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import URLPattern 2 | 3 | from django_modern_rest.openapi import openapi_spec 4 | from django_modern_rest.openapi.renderers import ( 5 | JsonRenderer, 6 | RedocRenderer, 7 | ScalarRenderer, 8 | SwaggerRenderer, 9 | ) 10 | from django_modern_rest.routing import Router 11 | from server.apps.openapi.config import ( 12 | get_openapi_config, 13 | ) 14 | 15 | 16 | def build_spec(router: Router) -> tuple[list[URLPattern], str, str]: 17 | return openapi_spec( 18 | router=router, 19 | renderers=[ 20 | SwaggerRenderer(), 21 | JsonRenderer(), 22 | ScalarRenderer(), 23 | RedocRenderer(), 24 | ], 25 | config=get_openapi_config(), 26 | ) 27 | -------------------------------------------------------------------------------- /docs/_templates/badges.html: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/renderers/__init__.py: -------------------------------------------------------------------------------- 1 | from django_modern_rest.openapi.renderers.base import ( 2 | BaseRenderer as BaseRenderer, 3 | ) 4 | from django_modern_rest.openapi.renderers.base import ( 5 | SerializedSchema as SerializedSchema, 6 | ) 7 | from django_modern_rest.openapi.renderers.base import ( 8 | json_serializer as json_serializer, 9 | ) 10 | from django_modern_rest.openapi.renderers.json import ( 11 | JsonRenderer as JsonRenderer, 12 | ) 13 | from django_modern_rest.openapi.renderers.redoc import ( 14 | RedocRenderer as RedocRenderer, 15 | ) 16 | from django_modern_rest.openapi.renderers.scalar import ( 17 | ScalarRenderer as ScalarRenderer, 18 | ) 19 | from django_modern_rest.openapi.renderers.swagger import ( 20 | SwaggerRenderer as SwaggerRenderer, 21 | ) 22 | -------------------------------------------------------------------------------- /tests/test_unit/test_response/test_build_response.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_modern_rest.plugins.pydantic import PydanticSerializer 4 | from django_modern_rest.response import build_response 5 | 6 | 7 | def test_build_response_no_status() -> None: 8 | """Ensure that either method name or status_code is required.""" 9 | with pytest.raises(ValueError, match='status_code'): 10 | build_response( # type: ignore[call-overload] 11 | PydanticSerializer, 12 | raw_data=[], 13 | ) 14 | 15 | with pytest.raises(ValueError, match='status_code'): 16 | build_response( # type: ignore[call-overload] 17 | PydanticSerializer, 18 | raw_data=[], 19 | method=None, # pyright: ignore[reportArgumentType] 20 | ) 21 | -------------------------------------------------------------------------------- /docs/examples/returning_responses/modify.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import final 3 | 4 | import pydantic 5 | 6 | from django_modern_rest import Body, Controller, modify 7 | from django_modern_rest.plugins.pydantic import PydanticSerializer 8 | 9 | 10 | class UserModel(pydantic.BaseModel): 11 | email: str 12 | 13 | 14 | @final 15 | class UserController( 16 | Controller[PydanticSerializer], 17 | Body[UserModel], 18 | ): 19 | @modify(status_code=HTTPStatus.OK) 20 | def post(self) -> UserModel: 21 | # This response would have an explicit status code `200`: 22 | return self.parsed_body 23 | 24 | 25 | # run: {"controller": "UserController", "method": "post", "body": {"email": "user@wms.org"}, "url": "/api/user/", "curl_args": ["-D", "-"]} # noqa: ERA001, E501 26 | -------------------------------------------------------------------------------- /django_test_app/mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | # Mypy configuration: 3 | # https://mypy.readthedocs.io/en/latest/config_file.html 4 | packages = server 5 | 6 | enable_error_code = 7 | truthy-bool, 8 | truthy-iterable, 9 | redundant-expr, 10 | unused-awaitable, 11 | ignore-without-code, 12 | possibly-undefined, 13 | redundant-self, 14 | explicit-override, 15 | unimported-reveal, 16 | deprecated, 17 | exhaustive-match, 18 | 19 | explicit_package_bases = true 20 | ; ignore_missing_imports = true 21 | local_partial_types = true 22 | strict = true 23 | strict_bytes = true 24 | warn_unreachable = true 25 | 26 | plugins = 27 | mypy_django_plugin.main, 28 | pydantic.mypy, 29 | 30 | 31 | [mypy.plugins.django-stubs] 32 | # Docs: https://github.com/typeddjango/django-stubs 33 | django_settings_module = server.settings 34 | -------------------------------------------------------------------------------- /django_test_app/server/apps/models_example/services.py: -------------------------------------------------------------------------------- 1 | from django.db import transaction 2 | 3 | from server.apps.models_example import serializers 4 | from server.apps.models_example.models import Role, Tag, User 5 | 6 | 7 | def user_create_service(user_schema: serializers.UserCreateSchema) -> User: 8 | """This is a function just for the demo purpose, it is usually a class.""" 9 | with transaction.atomic(): 10 | role = Role.objects.create(name=user_schema.role.name) 11 | user = User.objects.create( 12 | email=user_schema.email, 13 | role_id=role.pk, 14 | ) 15 | 16 | # Handle m2m: 17 | tags = Tag.objects.bulk_create([ 18 | Tag(name=tag.name) for tag in user_schema.tag_list 19 | ]) 20 | user.tags.set(tags) 21 | return user 22 | -------------------------------------------------------------------------------- /django_test_app/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | 4 | import os 5 | import sys 6 | 7 | 8 | def main() -> None: # pragma: no cover 9 | """Run administrative tasks.""" 10 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings') 11 | try: 12 | from django.core.management import ( # noqa: PLC0415 13 | execute_from_command_line, 14 | ) 15 | except ImportError as exc: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | 'available on your PYTHONPATH environment variable? Did you ' 19 | 'forget to activate a virtual environment?', 20 | ) from exc 21 | execute_from_command_line(sys.argv) 22 | 23 | 24 | if __name__ == '__main__': 25 | main() 26 | -------------------------------------------------------------------------------- /tests/test_unit/test_controllers/test_http_method_not_allowed.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_modern_rest import ( 4 | Controller, 5 | ) 6 | from django_modern_rest.plugins.pydantic import PydanticSerializer 7 | from django_modern_rest.test import DMRRequestFactory 8 | 9 | 10 | class _WrongController(Controller[PydanticSerializer]): 11 | def get(self) -> None: 12 | self.http_method_not_allowed(self.request) # type: ignore[deprecated] 13 | 14 | 15 | def test_http_method_not_allowed(dmr_rf: DMRRequestFactory) -> None: 16 | """Ensure that `http_method_not_allowed` is not allowed to be called.""" 17 | request = dmr_rf.get('/whatever/') 18 | 19 | with pytest.raises( 20 | (DeprecationWarning, NotImplementedError), 21 | match='handle_method_not_allowed', 22 | ): 23 | _WrongController.as_view()(request) 24 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/core/registry.py: -------------------------------------------------------------------------------- 1 | class OperationIdRegistry: 2 | """Registry for OpenAPI operation IDs.""" 3 | 4 | def __init__(self) -> None: 5 | """Initialize an empty operation ids registry.""" 6 | self._operation_ids: set[str] = set() 7 | 8 | def register(self, operation_id: str) -> None: 9 | """Register a operation ID in the registry.""" 10 | if operation_id in self._operation_ids: 11 | raise ValueError( 12 | f'Operation ID {operation_id!r} is already registered in the ' 13 | 'OpenAPI specification. Operation IDs must be unique across ' 14 | 'all endpoints to ensure proper API documentation. ' 15 | 'Please use a different operation ID for this endpoint.', 16 | ) 17 | 18 | self._operation_ids.add(operation_id) 19 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/info.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, final 3 | 4 | if TYPE_CHECKING: 5 | from django_modern_rest.openapi.objects.contact import Contact 6 | from django_modern_rest.openapi.objects.license import License 7 | 8 | 9 | @final 10 | @dataclass(frozen=True, kw_only=True, slots=True) 11 | class Info: 12 | """ 13 | The Info object provides metadata about the API. 14 | 15 | The metadata MAY be used by the clients if needed, and MAY be presented 16 | in editing or documentation generation tools for convenience. 17 | """ 18 | 19 | title: str 20 | version: str 21 | summary: str | None = None 22 | description: str | None = None 23 | terms_of_service: str | None = None 24 | contact: 'Contact | None' = None 25 | license: 'License | None' = None 26 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/security_requirement.py: -------------------------------------------------------------------------------- 1 | from typing import TypeAlias 2 | 3 | SecurityRequirement: TypeAlias = dict[str, list[str]] 4 | """ 5 | Lists the required security schemes to execute this operation. 6 | 7 | The name used for each property MUST correspond to a security scheme 8 | declared in the Security Schemes under the Components Object. 9 | Security Requirement Objects that contain multiple schemes require that all 10 | schemes MUST be satisfied for a request to be authorized. This enables support 11 | for scenarios where multiple query parameters or HTTP headers are required 12 | to convey security information. When a list of Security Requirement Objects 13 | is defined on the OpenAPI Object or Operation Object, only one of the 14 | Security Requirement Objects in the list needs to be 15 | satisfied to authorize the request. 16 | """ 17 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/link.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, Any, final 3 | 4 | if TYPE_CHECKING: 5 | from django_modern_rest.openapi.objects.server import Server 6 | 7 | 8 | @final 9 | @dataclass(frozen=True, kw_only=True, slots=True) 10 | class Link: 11 | """ 12 | The Link object represents a possible design-time link for a response. 13 | 14 | The presence of a link does not guarantee the caller's ability 15 | to successfully invoke it, rather it provides a known relationship 16 | and traversal mechanism between responses and other operations. 17 | """ 18 | 19 | operation_ref: str | None = None 20 | operation_id: str | None = None 21 | parameters: dict[str, Any] | None = None 22 | request_body: Any | None = None 23 | description: str | None = None 24 | server: 'Server | None' = None 25 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/response.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, final 3 | 4 | if TYPE_CHECKING: 5 | from django_modern_rest.openapi.objects.header import Header 6 | from django_modern_rest.openapi.objects.link import Link 7 | from django_modern_rest.openapi.objects.media_type import MediaType 8 | from django_modern_rest.openapi.objects.reference import Reference 9 | 10 | 11 | @final 12 | @dataclass(frozen=True, kw_only=True, slots=True) 13 | class Response: 14 | """ 15 | Describes a single response from an API Operation. 16 | 17 | Including design-time, static links to operations based on the response. 18 | """ 19 | 20 | description: str 21 | headers: 'dict[str, Header | Reference] | None' = None 22 | content: 'dict[str, MediaType] | None' = None 23 | links: 'dict[str, Link | Reference] | None' = None 24 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/media_type.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, Any, final 3 | 4 | if TYPE_CHECKING: 5 | from django_modern_rest.openapi.objects.encoding import Encoding 6 | from django_modern_rest.openapi.objects.example import Example 7 | from django_modern_rest.openapi.objects.reference import Reference 8 | from django_modern_rest.openapi.objects.schema import Schema 9 | 10 | 11 | @final 12 | @dataclass(frozen=True, kw_only=True, slots=True) 13 | class MediaType: 14 | """ 15 | Media Type Object. 16 | 17 | Each Media Type Object provides schema and examples for the media 18 | type identified by its key. 19 | """ 20 | 21 | schema: 'Reference | Schema | None' = None 22 | example: Any | None = None 23 | examples: 'dict[str, Example | Reference] | None' = None 24 | encoding: 'dict[str, Encoding] | None' = None 25 | -------------------------------------------------------------------------------- /docs/examples/getting_started/msgspec_controller.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import final 3 | 4 | import msgspec 5 | 6 | from django_modern_rest import Body, Controller, Headers 7 | from django_modern_rest.plugins.msgspec import MsgspecSerializer 8 | 9 | 10 | class UserCreateModel(msgspec.Struct): 11 | email: str 12 | 13 | 14 | class UserModel(UserCreateModel): 15 | uid: uuid.UUID 16 | 17 | 18 | class HeaderModel(msgspec.Struct): 19 | consumer: str = msgspec.field(name='X-API-Consumer') 20 | 21 | 22 | @final 23 | class UserController( 24 | Controller[MsgspecSerializer], 25 | Body[UserCreateModel], 26 | Headers[HeaderModel], 27 | ): 28 | def post(self) -> UserModel: 29 | """All added props have the correct runtime and static types.""" 30 | assert self.parsed_headers.consumer == 'my-api' 31 | return UserModel(uid=uuid.uuid4(), email=self.parsed_body.email) 32 | -------------------------------------------------------------------------------- /docs/examples/getting_started/pydantic_controller.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import final 3 | 4 | import pydantic 5 | 6 | from django_modern_rest import Body, Controller, Headers 7 | from django_modern_rest.plugins.pydantic import PydanticSerializer 8 | 9 | 10 | class UserCreateModel(pydantic.BaseModel): 11 | email: str 12 | 13 | 14 | class UserModel(UserCreateModel): 15 | uid: uuid.UUID 16 | 17 | 18 | class HeaderModel(pydantic.BaseModel): 19 | consumer: str = pydantic.Field(alias='X-API-Consumer') 20 | 21 | 22 | @final 23 | class UserController( 24 | Controller[PydanticSerializer], 25 | Body[UserCreateModel], 26 | Headers[HeaderModel], 27 | ): 28 | def post(self) -> UserModel: 29 | """All added props have the correct runtime and static types.""" 30 | assert self.parsed_headers.consumer == 'my-api' 31 | return UserModel(uid=uuid.uuid4(), email=self.parsed_body.email) 32 | -------------------------------------------------------------------------------- /docs/tools/sphinx_ext/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from auto_pytabs.sphinx_ext import CodeBlockOverride 4 | from sphinx.application import Sphinx 5 | 6 | from tools.sphinx_ext import chartjs, run_examples 7 | 8 | 9 | def _register_directives(app: Sphinx) -> None: 10 | """Directives registered after all extensions have been loaded.""" 11 | app.add_directive( 12 | 'literalinclude', 13 | run_examples.LiteralInclude, 14 | override=True, 15 | ) 16 | app.add_directive('code-block', CodeBlockOverride, override=True) 17 | app.add_directive('chartjs', chartjs.ChartJSDirective) 18 | 19 | 20 | def setup(app: Sphinx) -> dict[str, bool]: 21 | """Initialize Sphinx extensions and return configuration.""" 22 | app.connect('builder-inited', _register_directives) 23 | chartjs.setup(app) 24 | run_examples.setup(app) 25 | return {'parallel_read_safe': True, 'parallel_write_safe': True} 26 | -------------------------------------------------------------------------------- /tests/test_unit/test_controllers/test_customization/test_endpoint_customization.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, final 2 | 3 | from django_modern_rest import Controller 4 | from django_modern_rest.endpoint import Endpoint 5 | from django_modern_rest.plugins.pydantic import PydanticSerializer 6 | 7 | 8 | @final 9 | class _EndpointSubclass(Endpoint): 10 | """Test that we can replace the default implementation.""" 11 | 12 | 13 | @final 14 | class _CustomEndpointController(Controller[PydanticSerializer]): 15 | endpoint_cls: ClassVar[type[Endpoint]] = _EndpointSubclass 16 | 17 | def get(self) -> int: 18 | raise NotImplementedError 19 | 20 | 21 | def test_custom_endpoint_controller() -> None: 22 | """Ensures we can customize the endpoint factory.""" 23 | assert len(_CustomEndpointController.api_endpoints) == 1 24 | assert isinstance( 25 | _CustomEndpointController.api_endpoints['GET'], 26 | _EndpointSubclass, 27 | ) 28 | -------------------------------------------------------------------------------- /django_modern_rest/validation/__init__.py: -------------------------------------------------------------------------------- 1 | from django_modern_rest.validation.blueprint import ( 2 | BlueprintValidator as BlueprintValidator, 3 | ) 4 | from django_modern_rest.validation.controller import ( 5 | ControllerValidator as ControllerValidator, 6 | ) 7 | from django_modern_rest.validation.endpoint_metadata import ( 8 | EndpointMetadataValidator as EndpointMetadataValidator, 9 | ) 10 | from django_modern_rest.validation.endpoint_metadata import ( 11 | validate_method_name as validate_method_name, 12 | ) 13 | from django_modern_rest.validation.payload import ( 14 | ModifyEndpointPayload as ModifyEndpointPayload, 15 | ) 16 | from django_modern_rest.validation.payload import ( 17 | PayloadT as PayloadT, 18 | ) 19 | from django_modern_rest.validation.payload import ( 20 | ValidateEndpointPayload as ValidateEndpointPayload, 21 | ) 22 | from django_modern_rest.validation.response import ( 23 | ResponseValidator as ResponseValidator, 24 | ) 25 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=django-modern-rest 13 | set SPHINXOPTS=-W 14 | 15 | if "%1" == "" goto help 16 | 17 | %SPHINXBUILD% >NUL 2>NUL 18 | if errorlevel 9009 ( 19 | echo. 20 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 21 | echo.installed, then set the SPHINXBUILD environment variable to point 22 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 23 | echo.may add the Sphinx directory to PATH. 24 | echo. 25 | echo.If you don't have Sphinx installed, grab it from 26 | echo.http://sphinx-doc.org/ 27 | exit /b 1 28 | ) 29 | 30 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 31 | goto end 32 | 33 | :help 34 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 35 | 36 | :end 37 | popd 38 | -------------------------------------------------------------------------------- /docs/examples/returning_responses/modify_cookies.py: -------------------------------------------------------------------------------- 1 | from typing import final 2 | 3 | import pydantic 4 | 5 | from django_modern_rest import Body, Controller, NewCookie, modify 6 | from django_modern_rest.plugins.pydantic import PydanticSerializer 7 | 8 | 9 | class UserModel(pydantic.BaseModel): 10 | email: str 11 | 12 | 13 | @final 14 | class UserController( 15 | Controller[PydanticSerializer], 16 | Body[UserModel], 17 | ): 18 | @modify( 19 | # Add explicit cookie: 20 | cookies={'user_created': NewCookie(value='true', max_age=1000)}, 21 | ) 22 | def post(self) -> UserModel: 23 | # This response would have an implicit status code `201` 24 | # and explicit cookie `user_created` set to `true` with `max-age=1000` 25 | return self.parsed_body 26 | 27 | 28 | # run: {"controller": "UserController", "method": "post", "body": {"email": "user@wms.org"}, "url": "/api/user/", "curl_args": ["-D", "-"]} # noqa: ERA001, E501 29 | -------------------------------------------------------------------------------- /django_modern_rest/__init__.py: -------------------------------------------------------------------------------- 1 | from django_modern_rest.components import Body as Body 2 | from django_modern_rest.components import Cookies as Cookies 3 | from django_modern_rest.components import Headers as Headers 4 | from django_modern_rest.components import Path as Path 5 | from django_modern_rest.components import Query as Query 6 | from django_modern_rest.controller import Blueprint as Blueprint 7 | from django_modern_rest.controller import Controller as Controller 8 | from django_modern_rest.cookies import CookieSpec as CookieSpec 9 | from django_modern_rest.cookies import NewCookie as NewCookie 10 | from django_modern_rest.endpoint import modify as modify 11 | from django_modern_rest.endpoint import validate as validate 12 | from django_modern_rest.headers import HeaderSpec as HeaderSpec 13 | from django_modern_rest.headers import NewHeader as NewHeader 14 | from django_modern_rest.response import APIError as APIError 15 | from django_modern_rest.response import ResponseSpec as ResponseSpec 16 | -------------------------------------------------------------------------------- /tests/test_unit/test_validation/test_http_spec.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import pytest 4 | 5 | from django_modern_rest import Controller, modify 6 | from django_modern_rest.exceptions import EndpointMetadataError 7 | from django_modern_rest.plugins.pydantic import PydanticSerializer 8 | from django_modern_rest.test import DMRRequestFactory 9 | 10 | 11 | @pytest.mark.parametrize( 12 | 'status_code', 13 | [ 14 | HTTPStatus.NO_CONTENT, 15 | HTTPStatus.NOT_MODIFIED, 16 | ], 17 | ) 18 | def test_http_spec_none_body_for_status( 19 | dmr_rf: DMRRequestFactory, 20 | *, 21 | status_code: HTTPStatus, 22 | ) -> None: 23 | """Ensures body validation for some statuses work correctly.""" 24 | with pytest.raises(EndpointMetadataError, match='`None`'): 25 | 26 | class _ValidController(Controller[PydanticSerializer]): 27 | @modify(status_code=status_code) 28 | def post(self) -> int: 29 | raise NotImplementedError 30 | -------------------------------------------------------------------------------- /docs/examples/returning_responses/modify_headers.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import final 3 | 4 | import pydantic 5 | 6 | from django_modern_rest import Body, Controller, NewHeader, modify 7 | from django_modern_rest.plugins.pydantic import PydanticSerializer 8 | 9 | 10 | class UserModel(pydantic.BaseModel): 11 | email: str 12 | 13 | 14 | @final 15 | class UserController( 16 | Controller[PydanticSerializer], 17 | Body[UserModel], 18 | ): 19 | @modify( 20 | status_code=HTTPStatus.OK, 21 | # Add explicit header: 22 | headers={'X-Created': NewHeader(value='true')}, 23 | ) 24 | def post(self) -> UserModel: 25 | # This response would have an explicit status code `200` 26 | # and new explicit header `{'X-Created': 'true'}`: 27 | return self.parsed_body 28 | 29 | 30 | # run: {"controller": "UserController", "method": "post", "body": {"email": "user@wms.org"}, "url": "/api/user/", "curl_args": ["-D", "-"]} # noqa: ERA001, E501 31 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/responses.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union 2 | 3 | if TYPE_CHECKING: 4 | from django_modern_rest.openapi.objects.reference import Reference 5 | from django_modern_rest.openapi.objects.response import Response 6 | 7 | Responses = dict[str, Union['Response', 'Reference']] 8 | """ 9 | A container for the expected responses of an operation. 10 | 11 | The container maps a HTTP response code to the expected response. 12 | The documentation is not necessarily expected to cover all possible HTTP 13 | response codes because they may not be known in advance. 14 | However, documentation is expected to cover a successful operation 15 | response and any known errors. The default MAY be used as a default response 16 | object for all HTTP codes that are not covered individually by the Responses 17 | Object. The Responses Object MUST contain at least one response code, 18 | and if only one response code is provided it SHOULD be the response for a 19 | successful operation call. 20 | """ 21 | -------------------------------------------------------------------------------- /tests/test_unit/test_plugins/test_pydantic/test_none_return.py: -------------------------------------------------------------------------------- 1 | import json 2 | from http import HTTPStatus 3 | from typing import final 4 | 5 | from django.http import HttpResponse 6 | 7 | from django_modern_rest import Controller, modify 8 | from django_modern_rest.plugins.pydantic import PydanticSerializer 9 | from django_modern_rest.test import DMRRequestFactory 10 | 11 | 12 | @final 13 | class _NoneReturnController(Controller[PydanticSerializer]): 14 | @modify(status_code=HTTPStatus.NO_CONTENT) 15 | def post(self) -> None: 16 | """Does not return anything.""" 17 | 18 | 19 | def test_pydantic_none_return( 20 | dmr_rf: DMRRequestFactory, 21 | ) -> None: 22 | """Ensures that `None` works as the return model.""" 23 | request = dmr_rf.post('/whatever/', data={}) 24 | 25 | response = _NoneReturnController.as_view()(request) 26 | 27 | assert isinstance(response, HttpResponse) 28 | assert response.status_code == HTTPStatus.NO_CONTENT 29 | assert json.loads(response.content) is None 30 | -------------------------------------------------------------------------------- /typesafety/assert_type/test_msgspec.py: -------------------------------------------------------------------------------- 1 | from typing import assert_type 2 | 3 | import msgspec 4 | from django.http import HttpRequest 5 | 6 | from django_modern_rest import Body, Controller, Headers, Query 7 | from django_modern_rest.plugins.msgspec import MsgspecSerializer 8 | 9 | 10 | class _HeaderModel(msgspec.Struct): 11 | token: str 12 | 13 | 14 | # Test that type args work: 15 | _HeaderModel(token=1) # type: ignore[arg-type] 16 | 17 | 18 | class _QueryModel(msgspec.Struct): 19 | search: str 20 | 21 | 22 | class _MyController( 23 | Controller[MsgspecSerializer], 24 | Body[dict[str, int]], 25 | Headers[_HeaderModel], 26 | Query[_QueryModel], 27 | ): 28 | def get(self) -> str: 29 | """All added props have the correct types.""" 30 | assert_type(self.request, HttpRequest) 31 | assert_type(self.parsed_body, dict[str, int]) 32 | assert_type(self.parsed_headers, _HeaderModel) 33 | assert_type(self.parsed_query, _QueryModel) 34 | return 'Done' 35 | -------------------------------------------------------------------------------- /docs/examples/middleware/add_request_id.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from collections.abc import Callable 3 | from typing import Any, TypeAlias 4 | 5 | from django.http import HttpRequest, HttpResponse 6 | 7 | _CallableAny: TypeAlias = Callable[..., Any] 8 | 9 | 10 | def add_request_id_middleware( 11 | get_response: Callable[[HttpRequest], HttpResponse], 12 | ) -> _CallableAny: 13 | """Middleware that adds request_id to both request and response. 14 | 15 | This demonstrates the two-phase middleware pattern: 16 | 1. Process request BEFORE calling get_response (adds request.request_id) 17 | 2. Process response AFTER calling get_response (adds X-Request-ID header) 18 | """ 19 | 20 | def decorator(request: HttpRequest) -> Any: 21 | request_id = str(uuid.uuid4()) 22 | request.request_id = request_id # type: ignore[attr-defined] 23 | 24 | response = get_response(request) 25 | response['X-Request-ID'] = request_id 26 | 27 | return response 28 | 29 | return decorator 30 | -------------------------------------------------------------------------------- /docs/examples/middleware/usage_add_request_id.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, final 2 | 3 | from django.http import HttpRequest 4 | from project.app.middleware import add_request_id_json # Don't forget to change 5 | 6 | from django_modern_rest import Controller, ResponseSpec 7 | from django_modern_rest.plugins.pydantic import PydanticSerializer 8 | 9 | 10 | @final 11 | class _RequestWithID(HttpRequest): 12 | request_id: str 13 | 14 | 15 | @final 16 | @add_request_id_json 17 | class RequestIdController(Controller[PydanticSerializer]): 18 | """Controller that uses request_id added by middleware.""" 19 | 20 | responses: ClassVar[list[ResponseSpec]] = add_request_id_json.responses 21 | 22 | # Use request with request_id field 23 | request: _RequestWithID 24 | 25 | def get(self) -> dict[str, str]: 26 | """GET endpoint that returns request_id from modified request.""" 27 | return { 28 | 'request_id': self.request.request_id, 29 | 'message': 'Request ID tracked', 30 | } 31 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # I have made things! 2 | 3 | 11 | 12 | ## Checklist 13 | 14 | 15 | 16 | - [ ] I have double checked that there are no unrelated changes in this pull request (old patches, accidental config files, etc) 17 | - [ ] I have created at least one test case for the changes I have made 18 | - [ ] I have updated the documentation for the changes I have made 19 | 20 | ## Related issues 21 | 22 | 32 | 33 | 38 | -------------------------------------------------------------------------------- /tests/test_unit/test_controllers/test_deprecated_parts.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_modern_rest import Controller 4 | from django_modern_rest.plugins.pydantic import PydanticSerializer 5 | 6 | 7 | class _Custom(Controller[PydanticSerializer]): 8 | """Empty.""" 9 | 10 | 11 | def test_http_method_not_allowed() -> None: 12 | """Ensure that old django method on a controller does not work.""" 13 | controller = _Custom() 14 | 15 | with pytest.raises( 16 | (DeprecationWarning, NotImplementedError), 17 | match='handle_method_not_allowed', 18 | ): 19 | controller.http_method_not_allowed(None) # type: ignore[deprecated, arg-type] 20 | 21 | 22 | def test_controller_options() -> None: 23 | """Ensure that old OPTIONS django method on a controller does not work.""" 24 | controller = _Custom() 25 | 26 | with pytest.raises( 27 | (DeprecationWarning, NotImplementedError), 28 | match='meta', 29 | ): 30 | controller.options(None) # type: ignore[deprecated, arg-type] 31 | -------------------------------------------------------------------------------- /django_test_app/server/apps/models_example/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Tag(models.Model): 5 | name = models.CharField(max_length=100) 6 | 7 | created_at = models.DateTimeField(auto_now_add=True) 8 | updated_at = models.DateTimeField(auto_now=True) 9 | 10 | 11 | class Role(models.Model): 12 | name = models.CharField(max_length=100) 13 | 14 | created_at = models.DateTimeField(auto_now_add=True) 15 | updated_at = models.DateTimeField(auto_now=True) 16 | 17 | 18 | class User(models.Model): 19 | email = models.EmailField(unique=True) 20 | 21 | role = models.ForeignKey( 22 | Role, 23 | on_delete=models.CASCADE, 24 | related_name='users', 25 | ) 26 | tags = models.ManyToManyField(Tag, related_name='users') 27 | 28 | created_at = models.DateTimeField(auto_now_add=True) 29 | updated_at = models.DateTimeField(auto_now=True) 30 | 31 | @property 32 | def tag_list(self) -> list[Tag]: 33 | """Needed for serialization only.""" 34 | return list(self.tags.all()) 35 | -------------------------------------------------------------------------------- /tests/test_unit/test_plugins/test_pydantic/test_forward_ref.py: -------------------------------------------------------------------------------- 1 | import json 2 | from http import HTTPStatus 3 | from typing import final 4 | 5 | import pydantic 6 | from django.http import HttpResponse 7 | 8 | from django_modern_rest import Controller 9 | from django_modern_rest.plugins.pydantic import PydanticSerializer 10 | from django_modern_rest.test import DMRRequestFactory 11 | 12 | 13 | @final 14 | class _ReturnModel(pydantic.BaseModel): 15 | full_name: str 16 | 17 | 18 | @final 19 | class _ForwardRefController(Controller[PydanticSerializer]): 20 | def get(self) -> '_ReturnModel': 21 | return _ReturnModel(full_name='Example') 22 | 23 | 24 | def test_forward_ref_pydantic(dmr_rf: DMRRequestFactory) -> None: 25 | """Ensures by default forward refs are working.""" 26 | request = dmr_rf.get('/whatever/') 27 | 28 | response = _ForwardRefController.as_view()(request) 29 | 30 | assert isinstance(response, HttpResponse) 31 | assert response.status_code == HTTPStatus.OK 32 | assert json.loads(response.content) == {'full_name': 'Example'} 33 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/enums.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from typing import final 3 | 4 | 5 | @final 6 | class OpenAPIFormat(enum.StrEnum): 7 | """OpenAPI format.""" 8 | 9 | DATE = 'date' 10 | DATE_TIME = 'date-time' 11 | TIME = 'time' 12 | DURATION = 'duration' 13 | URL = 'url' 14 | EMAIL = 'email' 15 | IDN_EMAIL = 'idn-email' 16 | HOST_NAME = 'hostname' 17 | IDN_HOST_NAME = 'idn-hostname' 18 | IPV4 = 'ipv4' 19 | IPV6 = 'ipv6' 20 | URI = 'uri' 21 | URI_REFERENCE = 'uri-reference' 22 | URI_TEMPLATE = 'uri-template' 23 | JSON_POINTER = 'json-pointer' 24 | RELATIVE_JSON_POINTER = 'relative-json-pointer' 25 | IRI = 'iri-reference' 26 | IRI_REFERENCE = 'iri-reference' 27 | UUID = 'uuid' 28 | REGEX = 'regex' 29 | BINARY = 'binary' 30 | 31 | 32 | @final 33 | class OpenAPIType(enum.StrEnum): 34 | """OpenAPI types.""" 35 | 36 | ARRAY = 'array' 37 | BOOLEAN = 'boolean' 38 | INTEGER = 'integer' 39 | NULL = 'null' 40 | NUMBER = 'number' 41 | OBJECT = 'object' 42 | STRING = 'string' 43 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/core/context.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from django_modern_rest.openapi.core.registry import OperationIdRegistry 4 | from django_modern_rest.openapi.generators.operation import ( 5 | OperationGenerator, 6 | OperationIDGenerator, 7 | ) 8 | 9 | if TYPE_CHECKING: 10 | from django_modern_rest.openapi.config import OpenAPIConfig 11 | 12 | 13 | class OpenAPIContext: 14 | """ 15 | Context for OpenAPI specification generation. 16 | 17 | Maintains shared state and generators used across the OpenAPI 18 | generation process. Provides access to different generators. 19 | """ 20 | 21 | def __init__( 22 | self, 23 | config: 'OpenAPIConfig', 24 | ) -> None: 25 | """Initialize the OpenAPI context.""" 26 | self.config = config 27 | 28 | # Initialize generators once with shared context: 29 | self.operation_id_registry = OperationIdRegistry() 30 | 31 | self.operation_generator = OperationGenerator(self) 32 | self.operation_id_generator = OperationIDGenerator(self) 33 | -------------------------------------------------------------------------------- /django_test_app/server/apps/openapi/config.py: -------------------------------------------------------------------------------- 1 | from django_modern_rest.openapi import ( 2 | OpenAPIConfig, 3 | ) 4 | from django_modern_rest.openapi.objects import ( 5 | Contact, 6 | ExternalDocumentation, 7 | License, 8 | Server, 9 | Tag, 10 | ) 11 | 12 | 13 | def get_openapi_config() -> OpenAPIConfig: 14 | return OpenAPIConfig( 15 | title='Test API', 16 | version='1.0.0', 17 | summary='Test Summary', 18 | description='Test Description', 19 | terms_of_service='Test Terms of Service', 20 | contact=Contact(name='Test Contact', email='test@test.com'), 21 | license=License(name='Test License', identifier='license'), 22 | external_docs=ExternalDocumentation( 23 | url='https://test.com', 24 | description='Test External Documentation', 25 | ), 26 | servers=[Server(url='http://127.0.0.1:8000/api/')], 27 | tags=[ 28 | Tag(name='Test Tag', description='Tag Description'), 29 | Tag(name='Test Tag 2', description='Tag 2 Description'), 30 | ], 31 | ) 32 | -------------------------------------------------------------------------------- /tests/test_integration/test_models_example.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import pytest 4 | from dirty_equals import IsDatetime, IsPositiveInt 5 | from django.urls import reverse 6 | from faker import Faker 7 | 8 | from django_modern_rest.test import DMRClient 9 | 10 | 11 | @pytest.mark.django_db 12 | def test_user_create_models_example( 13 | dmr_client: DMRClient, 14 | faker: Faker, 15 | ) -> None: 16 | """Ensure that model route works.""" 17 | request_data = { 18 | 'email': faker.email(), 19 | 'role': {'name': faker.name()}, 20 | 'tag_list': [{'name': faker.name()}, {'name': faker.name()}], 21 | } 22 | response = dmr_client.post( 23 | reverse('api:models_example:user_model_create'), 24 | data=request_data, 25 | ) 26 | 27 | assert response.status_code == HTTPStatus.CREATED, response.content 28 | assert response.headers['Content-Type'] == 'application/json' 29 | assert response.json() == { 30 | **request_data, 31 | 'id': IsPositiveInt, 32 | 'created_at': IsDatetime(iso_string=True), 33 | } 34 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/generators/component.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from django_modern_rest.openapi.objects import Paths 4 | from django_modern_rest.openapi.objects.components import Components 5 | 6 | if TYPE_CHECKING: 7 | from django_modern_rest.openapi.core.context import OpenAPIContext 8 | 9 | 10 | class ComponentGenerator: 11 | """ 12 | Generator for OpenAPI Components section. 13 | 14 | The Components Generator is responsible for extracting and organizing 15 | reusable objects from the API specification. It processes all path items 16 | to identify shared components like schemas, parameters, responses, 17 | request bodies, headers, examples, security schemes, links, and callbacks 18 | that can be referenced throughout the OpenAPI specification. 19 | """ 20 | 21 | def __init__(self, context: 'OpenAPIContext') -> None: 22 | """Initialize the Components Generator.""" 23 | self.context = context 24 | 25 | def generate(self, paths_items: Paths) -> Components: 26 | """Generate OpenAPI Components from path items.""" 27 | return Components() 28 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/parameter.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping 2 | from dataclasses import dataclass 3 | from typing import TYPE_CHECKING, Any, final 4 | 5 | if TYPE_CHECKING: 6 | from django_modern_rest.openapi.objects.example import Example 7 | from django_modern_rest.openapi.objects.media_type import MediaType 8 | from django_modern_rest.openapi.objects.reference import Reference 9 | from django_modern_rest.openapi.objects.schema import Schema 10 | 11 | 12 | @final 13 | @dataclass(frozen=True, kw_only=True, slots=True) 14 | class Parameter: 15 | """Describes a single operation parameter.""" 16 | 17 | name: str 18 | param_in: str 19 | schema: 'Schema | Reference | None' = None 20 | description: str | None = None 21 | required: bool = False 22 | deprecated: bool = False 23 | allow_empty_value: bool = False 24 | style: str | None = None 25 | explode: bool | None = None 26 | allow_reserved: bool = False 27 | example: Any | None = None 28 | examples: 'Mapping[str, Example | Reference] | None' = None 29 | content: 'dict[str, MediaType] | None' = None 30 | -------------------------------------------------------------------------------- /docs/_static/images/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /tests/test_unit/test_controllers/test_query_method.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import cast 3 | 4 | from django.http import HttpResponse 5 | 6 | from django_modern_rest import ( 7 | Controller, 8 | ResponseSpec, 9 | validate, 10 | ) 11 | from django_modern_rest.plugins.pydantic import PydanticSerializer 12 | from django_modern_rest.test import DMRRequestFactory 13 | 14 | 15 | class _QueryController(Controller[PydanticSerializer]): 16 | http_methods = frozenset((*Controller.http_methods, 'query')) 17 | 18 | @validate( 19 | ResponseSpec(None, status_code=HTTPStatus.OK), 20 | allow_custom_http_methods=True, 21 | ) 22 | def query(self) -> HttpResponse: 23 | return self.to_response(None, status_code=HTTPStatus.OK) 24 | 25 | 26 | def test_query_method(dmr_rf: DMRRequestFactory) -> None: 27 | """Ensure that `query` method is supported.""" 28 | request = dmr_rf.generic('query', '/whatever/') 29 | 30 | response = cast(HttpResponse, _QueryController.as_view()(request)) 31 | 32 | assert response.status_code == HTTPStatus.OK 33 | assert response.content == b'null' 34 | -------------------------------------------------------------------------------- /docs/examples/using_controller/blueprints.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import final 3 | 4 | import pydantic 5 | 6 | from django_modern_rest import ( # noqa: WPS235 7 | Blueprint, 8 | Body, 9 | ) 10 | from django_modern_rest.plugins.pydantic import PydanticSerializer 11 | 12 | 13 | class _UserInput(pydantic.BaseModel): 14 | email: str 15 | age: int 16 | 17 | 18 | @final 19 | class _UserOutput(_UserInput): 20 | uid: uuid.UUID 21 | 22 | 23 | @final 24 | class UserCreateBlueprint( 25 | Body[_UserInput], # <- needs a request body 26 | Blueprint[PydanticSerializer], 27 | ): 28 | def post(self) -> _UserOutput: 29 | return _UserOutput( 30 | uid=uuid.uuid4(), 31 | email=self.parsed_body.email, 32 | age=self.parsed_body.age, 33 | ) 34 | 35 | 36 | @final 37 | class UserListBlueprint( 38 | # Does not need a request body. 39 | Blueprint[PydanticSerializer], 40 | ): 41 | def get(self) -> list[_UserInput]: 42 | return [ 43 | _UserInput(email='first@mail.ru', age=1), 44 | _UserInput(email='second@mail.ru', age=2), 45 | ] 46 | -------------------------------------------------------------------------------- /typesafety/assert_type/test_controller.py: -------------------------------------------------------------------------------- 1 | from typing import assert_type 2 | 3 | import pydantic 4 | from django.http import HttpRequest 5 | 6 | from django_modern_rest import Body, Controller, Headers, Query 7 | from django_modern_rest.plugins.pydantic import PydanticSerializer 8 | 9 | 10 | class _HeaderModel(pydantic.BaseModel): 11 | token: str 12 | 13 | 14 | class _QueryModel(pydantic.BaseModel): 15 | search: str 16 | 17 | 18 | class _MyController( 19 | Controller[PydanticSerializer], 20 | Body[dict[str, int]], 21 | Headers[_HeaderModel], 22 | Query[_QueryModel], 23 | ): 24 | def get(self) -> str: 25 | """All added props have the correct types.""" 26 | assert_type(self.request, HttpRequest) 27 | assert_type(self.parsed_body, dict[str, int]) 28 | assert_type(self.parsed_headers, _HeaderModel) 29 | assert_type(self.parsed_query, _QueryModel) 30 | return 'Done' 31 | 32 | 33 | class _Handle405Correctly(Controller[PydanticSerializer]): 34 | def whatever(self, request: HttpRequest) -> None: 35 | self.http_method_not_allowed(request) # type: ignore[deprecated] 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2025 wemake-services 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 18 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 19 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 20 | OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /docs/examples/returning_responses/validate.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import final 3 | 4 | import pydantic 5 | from django.http import HttpResponse 6 | 7 | from django_modern_rest import ( 8 | Body, 9 | Controller, 10 | ResponseSpec, 11 | validate, 12 | ) 13 | from django_modern_rest.plugins.pydantic import PydanticSerializer 14 | 15 | 16 | class UserModel(pydantic.BaseModel): 17 | email: str 18 | 19 | 20 | @final 21 | class UserController( 22 | Controller[PydanticSerializer], 23 | Body[UserModel], 24 | ): 25 | @validate( # <- describes unique return types from this endpoint 26 | ResponseSpec( 27 | UserModel, 28 | status_code=HTTPStatus.OK, 29 | ), 30 | ) 31 | def post(self) -> HttpResponse: 32 | # This response would have an explicit status code `200`: 33 | return self.to_response( 34 | self.parsed_body, 35 | status_code=HTTPStatus.OK, 36 | ) 37 | 38 | 39 | # run: {"controller": "UserController", "method": "post", "body": {"email": "user@wms.org"}, "url": "/api/user/", "curl_args": ["-D", "-"]} # noqa: ERA001, E501 40 | -------------------------------------------------------------------------------- /tests/test_unit/test_controllers/test_customization/test_serializer_context_customization.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, final 2 | 3 | from django_modern_rest import Controller 4 | from django_modern_rest.plugins.pydantic import PydanticSerializer 5 | from django_modern_rest.serialization import SerializerContext 6 | 7 | 8 | @final 9 | class _SerializerContextSubclass(SerializerContext): 10 | """Test that we can replace the default serializer context.""" 11 | 12 | 13 | @final 14 | class _CustomSerializerContextController(Controller[PydanticSerializer]): 15 | serializer_context_cls: ClassVar[type[SerializerContext]] = ( 16 | _SerializerContextSubclass 17 | ) 18 | 19 | def get(self) -> int: 20 | raise NotImplementedError 21 | 22 | 23 | def test_custom_serializer_context_cls() -> None: 24 | """Ensure we can customize the serializer context.""" 25 | assert ( 26 | _CustomSerializerContextController.serializer_context_cls 27 | is _SerializerContextSubclass 28 | ) 29 | assert isinstance( 30 | _CustomSerializerContextController._serializer_context, 31 | _SerializerContextSubclass, 32 | ) 33 | -------------------------------------------------------------------------------- /django_test_app/server/apps/middlewares/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from django_modern_rest.routing import Router 4 | from server.apps.middlewares import views 5 | 6 | router = Router([ 7 | path( 8 | 'csrf-protected', 9 | views.CsrfProtectedController.as_view(), 10 | name='csrf_test', 11 | ), 12 | path( 13 | 'async-csrf-protected', 14 | views.AsyncCsrfProtectedController.as_view(), 15 | name='async_csrf_test', 16 | ), 17 | path( 18 | 'custom-header', 19 | views.CustomHeaderController.as_view(), 20 | name='custom_header', 21 | ), 22 | path( 23 | 'rate-limited', 24 | views.RateLimitedController.as_view(), 25 | name='rate_limited', 26 | ), 27 | path( 28 | 'request-id', 29 | views.RequestIdController.as_view(), 30 | name='request_id', 31 | ), 32 | path( 33 | 'login-required', 34 | views.LoginRequiredController.as_view(), 35 | name='login_required', 36 | ), 37 | path( 38 | 'csrf-token', 39 | views.CsrfTokenController.as_view(), 40 | name='csrf_token', 41 | ), 42 | ]) 43 | -------------------------------------------------------------------------------- /tests/test_unit/test_endpoint/test_validate_forward_ref.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations # <- required for test 2 | 3 | from http import HTTPStatus 4 | from typing import TYPE_CHECKING 5 | 6 | import pytest 7 | 8 | from django_modern_rest import ( 9 | Controller, 10 | ResponseSpec, 11 | validate, 12 | ) 13 | from django_modern_rest.exceptions import UnsolvableAnnotationsError 14 | from django_modern_rest.plugins.pydantic import PydanticSerializer 15 | from django_modern_rest.test import DMRRequestFactory 16 | 17 | if TYPE_CHECKING: 18 | from django.http import HttpResponse # <- required for test 19 | 20 | 21 | def test_validate_forward_ref(dmr_rf: DMRRequestFactory) -> None: 22 | """Ensures `@validate` cannot work on forward ref annotation.""" 23 | with pytest.raises(UnsolvableAnnotationsError, match=r'\.get'): 24 | 25 | class _CorrectHeadersController(Controller[PydanticSerializer]): 26 | @validate( 27 | ResponseSpec( 28 | return_type=list[str], 29 | status_code=HTTPStatus.OK, 30 | ), 31 | ) 32 | def get(self) -> HttpResponse: 33 | raise NotImplementedError 34 | -------------------------------------------------------------------------------- /docs/examples/returning_responses/per_blueprint.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import ClassVar, final 3 | 4 | import msgspec 5 | 6 | from django_modern_rest import APIError, Blueprint, Body, Headers 7 | from django_modern_rest.plugins.msgspec import MsgspecSerializer 8 | 9 | 10 | class UserModel(msgspec.Struct): 11 | email: str 12 | 13 | 14 | class HeaderModel(msgspec.Struct): 15 | consumer: str = msgspec.field(name='X-API-Consumer') 16 | 17 | 18 | @final 19 | class UserBlueprint( 20 | Blueprint[MsgspecSerializer], 21 | Body[UserModel], 22 | Headers[HeaderModel], 23 | ): 24 | # Now, we won't validate all endpoints in this blueprint: 25 | validate_responses: ClassVar[bool | None] = False 26 | 27 | def post(self) -> UserModel: 28 | if self.parsed_headers.consumer != 'my-api': 29 | # Notice that this response is never documented in the spec, 30 | # but, it won't raise a validation error, because validation is off 31 | raise APIError( 32 | {'detail': 'Wrong API consumer'}, 33 | status_code=HTTPStatus.NOT_ACCEPTABLE, 34 | ) 35 | # This response will be documented by default: 36 | return self.parsed_body 37 | -------------------------------------------------------------------------------- /docs/examples/core_concepts/glossary.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import final 3 | 4 | import msgspec 5 | 6 | from django_modern_rest import Body, Controller, NewHeader, modify 7 | from django_modern_rest.plugins.msgspec import MsgspecSerializer 8 | 9 | 10 | class UserCreateModel(msgspec.Struct): 11 | email: str 12 | 13 | 14 | class UserModel(UserCreateModel): 15 | uid: uuid.UUID 16 | 17 | 18 | @final 19 | class UserController( # <- `Controller` definition 20 | Controller[MsgspecSerializer], # <- Passing `Serializer` 21 | Body[UserCreateModel], # <- Using `Component` with a model 22 | ): 23 | @modify(headers={'X-Default': NewHeader(value='1')}) # <- extra `Metadata` 24 | def get(self) -> UserModel: # <- `Endpoint` definition 25 | return UserModel(uid=uuid.uuid4(), email='default@email.com') 26 | 27 | def post(self) -> UserModel: # <- `Endpoint` definition 28 | return UserModel(uid=uuid.uuid4(), email=self.parsed_body.email) 29 | 30 | 31 | # run: {"controller": "UserController", "method": "get", "body": {"email": "user@wms.org"}, "url": "/api/user/"} # noqa: ERA001, E501 32 | # run: {"controller": "UserController", "method": "post", "body": {"email": "user@wms.org"}, "url": "/api/user/"} # noqa: ERA001, E501 33 | -------------------------------------------------------------------------------- /tests/test_unit/test_openapi/test_core/test_registry.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_modern_rest.openapi.core.registry import OperationIdRegistry 4 | 5 | 6 | @pytest.fixture 7 | def registry() -> OperationIdRegistry: 8 | """Create OperationIdRegistry instance for testing.""" 9 | return OperationIdRegistry() 10 | 11 | 12 | def test_multiple_unique_operation_ids(registry: OperationIdRegistry) -> None: 13 | """Test that registering multiple unique operation IDs succeeds.""" 14 | operation_ids = frozenset( 15 | ('getUsers', 'createUser', 'updateUser', 'deleteUser'), 16 | ) 17 | 18 | for operation_id in operation_ids: 19 | registry.register(operation_id) 20 | 21 | assert len(registry._operation_ids) == len(operation_ids) 22 | assert registry._operation_ids == operation_ids 23 | 24 | 25 | def test_duplicate_operation_id_raises_error( 26 | registry: OperationIdRegistry, 27 | ) -> None: 28 | """Test that registering a duplicate operation ID raises ValueError.""" 29 | operation_id = 'getUsers' 30 | registry.register(operation_id) 31 | 32 | with pytest.raises( 33 | ValueError, 34 | match=("Operation ID 'getUsers' is already registered"), 35 | ): 36 | registry.register(operation_id) 37 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/renderers/json.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, final 2 | 3 | from django.http import HttpRequest, HttpResponse 4 | from typing_extensions import override 5 | 6 | from django_modern_rest.openapi.converter import ConvertedSchema 7 | from django_modern_rest.openapi.renderers.base import ( 8 | BaseRenderer, 9 | SchemaSerialier, 10 | json_serializer, 11 | ) 12 | 13 | 14 | @final 15 | class JsonRenderer(BaseRenderer): 16 | """ 17 | Renderer for OpenAPI schema in JSON format. 18 | 19 | Provides JSON representation of OpenAPI specification suitable for 20 | API documentation tools and client code generation. 21 | """ 22 | 23 | default_path: ClassVar[str] = 'openapi.json/' 24 | default_name: ClassVar[str] = 'json' 25 | content_type: ClassVar[str] = 'application/json' 26 | serializer: SchemaSerialier = staticmethod(json_serializer) # noqa: WPS421 27 | 28 | @override 29 | def render( 30 | self, 31 | request: HttpRequest, 32 | schema: ConvertedSchema, 33 | ) -> HttpResponse: 34 | """Render the OpenAPI schema as JSON response.""" 35 | return HttpResponse( 36 | content=self.serializer(schema), 37 | content_type=self.content_type, 38 | ) 39 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /usr/bin/env bash 2 | 3 | .PHONY: format 4 | format: 5 | ruff format && ruff check && ruff format 6 | 7 | .PHONY: lint 8 | lint: 9 | poetry run ruff check --exit-non-zero-on-fix 10 | poetry run ruff format --check --diff 11 | poetry run flake8 . 12 | poetry run slotscheck --no-strict-imports -v -m django_modern_rest 13 | poetry run lint-imports 14 | 15 | .PHONY: type-check 16 | type-check: 17 | poetry run mypy . 18 | poetry run pyright 19 | 20 | .PHONY: spell-check 21 | spell-check: 22 | poetry run codespell django_modern_rest tests docs typesafety README.md CONTRIBUTING.md CHANGELOG.md 23 | 24 | .PHONY: unit 25 | unit: 26 | poetry run pytest --inline-snapshot=disable 27 | 28 | .PHONY: smoke 29 | smoke: 30 | # Checks that it is possible to import the base package without django.setup 31 | poetry run python -c 'from django_modern_rest import Controller' 32 | 33 | .PHONY: example 34 | example: 35 | cd django_test_app && poetry run mypy --config-file mypy.ini 36 | PYTHONPATH='docs/' poetry run pytest -o addopts='' \ 37 | --suppress-no-test-exit-code \ 38 | docs/examples/testing/polyfactory_usage.py 39 | 40 | .PHONY: package 41 | package: 42 | poetry run pip check 43 | 44 | .PHONY: test 45 | test: lint type-check example spell-check package smoke unit 46 | -------------------------------------------------------------------------------- /django_modern_rest/exceptions.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import ClassVar, final 3 | 4 | 5 | @final 6 | class UnsolvableAnnotationsError(Exception): 7 | """ 8 | Raised when we can't solve function's annotations using ``get_type_hints``. 9 | 10 | Only raised when there are no other options. 11 | """ 12 | 13 | 14 | @final 15 | class EndpointMetadataError(Exception): 16 | """Raised when user didn't specify some required endpoint metadata.""" 17 | 18 | 19 | @final 20 | class DataParsingError(Exception): 21 | """Raised when json/xml data cannot be parsed.""" 22 | 23 | 24 | class SerializationError(Exception): 25 | """ 26 | Base class for all parsing and serialization errors. 27 | 28 | Do not use it directly, prefer exact exceptions for requests and responses. 29 | """ 30 | 31 | #: Child classes can customize this attribute: 32 | status_code: ClassVar[HTTPStatus] = HTTPStatus.UNPROCESSABLE_ENTITY 33 | 34 | 35 | @final 36 | class RequestSerializationError(SerializationError): 37 | """Raised when we fail to parse some request part.""" 38 | 39 | status_code = HTTPStatus.BAD_REQUEST 40 | 41 | 42 | @final 43 | class ResponseSerializationError(SerializationError): 44 | """Raised when we fail to parse some response part.""" 45 | -------------------------------------------------------------------------------- /tests/test_unit/test_endpoint/test_runtime_error.py: -------------------------------------------------------------------------------- 1 | from typing import final 2 | 3 | import pytest 4 | 5 | from django_modern_rest import Controller 6 | from django_modern_rest.plugins.pydantic import PydanticSerializer 7 | from django_modern_rest.test import DMRAsyncRequestFactory, DMRRequestFactory 8 | 9 | 10 | @final 11 | class _SyncController(Controller[PydanticSerializer]): 12 | def get(self) -> str: 13 | raise ZeroDivisionError('custom') 14 | 15 | 16 | def test_sync_controller_runtime_error(dmr_rf: DMRRequestFactory) -> None: 17 | """Ensures that runtime errors bubble up.""" 18 | request = dmr_rf.get('/whatever/') 19 | 20 | with pytest.raises(ZeroDivisionError, match='custom'): 21 | _SyncController.as_view()(request) 22 | 23 | 24 | @final 25 | class _AsyncController(Controller[PydanticSerializer]): 26 | async def get(self) -> str: 27 | raise ZeroDivisionError('custom') 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_async_controller_runtime_error( 32 | dmr_async_rf: DMRAsyncRequestFactory, 33 | ) -> None: 34 | """Ensures that runtime errors bubble up.""" 35 | request = dmr_async_rf.get('/whatever/') 36 | 37 | with pytest.raises(ZeroDivisionError, match='custom'): 38 | await dmr_async_rf.wrap(_AsyncController.as_view()(request)) 39 | -------------------------------------------------------------------------------- /django_test_app/server/apps/controllers/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, re_path 2 | 3 | from django_modern_rest.routing import Router, compose_blueprints 4 | from server.apps.controllers import views 5 | 6 | router = Router([ 7 | path( 8 | 'user/', 9 | compose_blueprints( 10 | views.UserCreateBlueprint, 11 | views.UserListBlueprint, 12 | ).as_view(), 13 | name='users', 14 | ), 15 | path( 16 | 'user/', 17 | compose_blueprints( 18 | views.UserReplaceBlueprint, 19 | views.UserUpdateBlueprint, 20 | ).as_view(), 21 | name='user_update', 22 | ), 23 | re_path( 24 | r'user/direct/re/(\d+)', 25 | compose_blueprints(views.UserUpdateBlueprint).as_view(), 26 | name='user_update_direct_re', 27 | ), 28 | path( 29 | 'user/direct/', 30 | compose_blueprints(views.UserUpdateBlueprint).as_view(), 31 | name='user_update_direct', 32 | ), 33 | path( 34 | 'headers', 35 | views.ParseHeadersController.as_view(), 36 | name='parse_headers', 37 | ), 38 | path( 39 | 'async_headers', 40 | views.AsyncParseHeadersController.as_view(), 41 | name='async_parse_headers', 42 | ), 43 | ]) 44 | -------------------------------------------------------------------------------- /docs/examples/middleware/csrf_protect_json.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import ClassVar 3 | 4 | from django.http import HttpResponse 5 | from django.views.decorators.csrf import csrf_protect 6 | 7 | from django_modern_rest import Controller, ResponseSpec 8 | from django_modern_rest.decorators import wrap_middleware 9 | from django_modern_rest.plugins.pydantic import PydanticSerializer 10 | from django_modern_rest.response import build_response 11 | 12 | 13 | @wrap_middleware( 14 | csrf_protect, 15 | ResponseSpec( 16 | return_type=dict[str, str], 17 | status_code=HTTPStatus.FORBIDDEN, 18 | ), 19 | ) 20 | def csrf_protect_json(response: HttpResponse) -> HttpResponse: 21 | """Convert CSRF failure responses to JSON.""" 22 | return build_response( 23 | PydanticSerializer, 24 | raw_data={ 25 | 'detail': 'CSRF verification failed. Request aborted.', 26 | }, 27 | status_code=HTTPStatus(response.status_code), 28 | ) 29 | 30 | 31 | @csrf_protect_json 32 | class MyController(Controller[PydanticSerializer]): 33 | """Example controller using CSRF protection middleware.""" 34 | 35 | responses: ClassVar[list[ResponseSpec]] = csrf_protect_json.responses 36 | 37 | def post(self) -> dict[str, str]: 38 | return {'message': 'ok'} 39 | -------------------------------------------------------------------------------- /tests/test_integration/test_contollers/test_user_update_controller.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from django.urls import reverse 4 | from faker import Faker 5 | 6 | from django_modern_rest.test import DMRClient 7 | 8 | 9 | def test_user_update_view(dmr_client: DMRClient, faker: Faker) -> None: 10 | """Ensure that async `put` routes work.""" 11 | user_id = faker.random_int() 12 | response = dmr_client.put( 13 | reverse('api:controllers:user_update', kwargs={'user_id': user_id}), 14 | ) 15 | 16 | assert response.status_code == HTTPStatus.OK 17 | assert response.headers['Content-Type'] == 'application/json' 18 | assert response.json() == {'email': 'new@email.com', 'age': user_id} 19 | 20 | 21 | def test_user_replace_view(dmr_client: DMRClient, faker: Faker) -> None: 22 | """Ensure that async `patch` routes work.""" 23 | user_id = faker.unique.random_int() 24 | email = faker.email() 25 | response = dmr_client.patch( 26 | reverse('api:controllers:user_update', kwargs={'user_id': user_id}), 27 | data={'email': email, 'age': faker.unique.random_int()}, 28 | ) 29 | 30 | assert response.status_code == HTTPStatus.OK 31 | assert response.headers['Content-Type'] == 'application/json' 32 | assert response.json() == {'email': email, 'age': user_id} 33 | -------------------------------------------------------------------------------- /tests/test_unit/test_decorators/test_dispatch_decorator.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import final 3 | 4 | import pytest 5 | from django.contrib.auth.decorators import login_required 6 | from django.contrib.auth.models import AnonymousUser, User 7 | 8 | from django_modern_rest import Controller 9 | from django_modern_rest.decorators import dispatch_decorator 10 | from django_modern_rest.plugins.pydantic import PydanticSerializer 11 | from django_modern_rest.test import DMRRequestFactory 12 | 13 | 14 | @final 15 | @dispatch_decorator(login_required()) 16 | class _MyController(Controller[PydanticSerializer]): 17 | def get(self) -> str: 18 | """Simulates `post` method.""" 19 | return 'Logged in!' 20 | 21 | 22 | @pytest.mark.parametrize( 23 | ('user', 'status_code'), 24 | [ 25 | (AnonymousUser(), HTTPStatus.FOUND), 26 | (User(), HTTPStatus.OK), 27 | ], 28 | ) 29 | def test_login_required( 30 | dmr_rf: DMRRequestFactory, 31 | *, 32 | user: User | AnonymousUser, 33 | status_code: HTTPStatus, 34 | ) -> None: 35 | """Ensures that ``dispatch_decorator`` works and authed user is required.""" 36 | request = dmr_rf.get('/whatever/') 37 | request.user = user 38 | 39 | response = _MyController.as_view()(request) 40 | 41 | assert response.status_code == status_code 42 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/path_item.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, final 3 | 4 | if TYPE_CHECKING: 5 | from django_modern_rest.openapi.objects.operation import Operation 6 | from django_modern_rest.openapi.objects.parameter import Parameter 7 | from django_modern_rest.openapi.objects.reference import Reference 8 | from django_modern_rest.openapi.objects.server import Server 9 | 10 | 11 | @final 12 | @dataclass(frozen=True, kw_only=True, slots=True) 13 | class PathItem: 14 | """ 15 | Describes the operations available on a single path. 16 | 17 | A Path Item MAY be empty, due to ACL constraints. 18 | The path itself is still exposed to the documentation viewer but 19 | they will not know which operations and parameters are available. 20 | """ 21 | 22 | ref: str | None = None 23 | summary: str | None = None 24 | description: str | None = None 25 | get: 'Operation | None' = None 26 | put: 'Operation | None' = None 27 | post: 'Operation | None' = None 28 | delete: 'Operation | None' = None 29 | options: 'Operation | None' = None 30 | head: 'Operation | None' = None 31 | patch: 'Operation | None' = None 32 | trace: 'Operation | None' = None 33 | servers: 'list[Server] | None' = None 34 | parameters: 'list[Parameter | Reference] | None' = None 35 | -------------------------------------------------------------------------------- /docs/examples/using_controller/custom_meta.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import final 3 | 4 | from django.http import HttpResponse 5 | 6 | from django_modern_rest import ( 7 | Controller, 8 | HeaderSpec, 9 | ResponseSpec, 10 | validate, 11 | ) 12 | from django_modern_rest.plugins.msgspec import MsgspecSerializer 13 | 14 | 15 | @final 16 | class SettingsController(Controller[MsgspecSerializer]): 17 | def get(self) -> str: 18 | return 'default get setting' 19 | 20 | def post(self) -> str: 21 | return 'default post setting' 22 | 23 | # `meta` response is also validated, schema is required: 24 | @validate( 25 | ResponseSpec( 26 | None, 27 | status_code=HTTPStatus.NO_CONTENT, 28 | headers={'Allow': HeaderSpec()}, 29 | ), 30 | ) 31 | def meta(self) -> HttpResponse: # Handles `OPTIONS` http method 32 | return self.to_response( 33 | None, 34 | status_code=HTTPStatus.NO_CONTENT, 35 | headers={ 36 | 'Allow': ', '.join( 37 | method for method in sorted(self.api_endpoints.keys()) 38 | ), 39 | }, 40 | ) 41 | 42 | 43 | # run: {"controller": "SettingsController", "method": "options", "url": "/api/settings/", "curl_args": ["-D", "-"]} # noqa: ERA001, E501 44 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/security_scheme.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, Literal, final 3 | 4 | if TYPE_CHECKING: 5 | from django_modern_rest.openapi.objects.oauth_flows import OAuthFlows 6 | 7 | 8 | @final 9 | @dataclass(frozen=True, kw_only=True, slots=True) 10 | class SecurityScheme: 11 | """ 12 | Defines a security scheme that can be used by the operations. 13 | 14 | Supported schemes are HTTP authentication, an API key 15 | (either as a header, a cookie parameter or as a query parameter), 16 | mutual TLS (use of a client certificate), 17 | OAuth2's common flows (implicit, password, client credentials 18 | and authorization code) as defined in RFC6749, and OpenID Connect Discovery. 19 | Please note that as of 2020, the implicit flow is about to be deprecated by 20 | OAuth 2.0 Security Best Current Practice. 21 | Recommended for most use case is Authorization Code Grant flow with PKCE. 22 | """ 23 | 24 | type: Literal['apiKey', 'http', 'mutualTLS', 'oauth2', 'openIdConnect'] 25 | description: str | None = None 26 | name: str | None = None 27 | security_scheme_in: Literal['query', 'header', 'cookie'] | None = None 28 | scheme: str | None = None 29 | bearer_format: str | None = None 30 | flows: 'OAuthFlows | None' = None 31 | open_id_connect_url: str | None = None 32 | -------------------------------------------------------------------------------- /tests/test_integration/test_openapi/test_schema.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import TYPE_CHECKING, Any, Final 3 | 4 | import pytest 5 | import schemathesis as st 6 | from django.urls import reverse 7 | 8 | from django_test_app.server.wsgi import application 9 | 10 | if TYPE_CHECKING: 11 | from schemathesis.specs.openapi.schemas import OpenApiSchema 12 | 13 | _OPENAPI_URL: Final = reverse('openapi:json') 14 | schema = st.pytest.from_fixture('api_schema') 15 | 16 | 17 | # NOTE: The `db` fixture is required to enable database access. 18 | # When `st.openapi.from_wsgi()` makes a WSGI request, Django's request 19 | # lifecycle triggers database operations. 20 | @pytest.fixture 21 | def api_schema(db: Any) -> 'OpenApiSchema': 22 | """Load OpenAPI schema as a pytest fixture.""" 23 | return st.openapi.from_wsgi(_OPENAPI_URL, application) 24 | 25 | 26 | @schema.parametrize() 27 | def test_schema_path_exists(case: st.Case) -> None: 28 | """ 29 | Verify that all API paths defined in the OpenAPI schema are exists. 30 | 31 | Validate that each endpoint path from the schema can be called successfully, 32 | ensuring the schema correctly represents the available API routes. 33 | Note: This test only verifies that endpoints are reachable and does not 34 | validate response structure or content. 35 | """ 36 | assert case.call().status_code != HTTPStatus.NOT_FOUND 37 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from django_modern_rest.openapi.objects import ( # noqa: WPS235 4 | Components, 5 | Contact, 6 | ExternalDocumentation, 7 | License, 8 | PathItem, 9 | Reference, 10 | SecurityRequirement, 11 | Server, 12 | Tag, 13 | ) 14 | 15 | 16 | @dataclass(slots=True, frozen=True, kw_only=True) 17 | class OpenAPIConfig: 18 | """ 19 | Configuration class for customizing OpenAPI specification metadata. 20 | 21 | This class provides a way to configure various aspects of the OpenAPI 22 | specification that will be generated for your API documentation. It allows 23 | you to customize the API information, contact details, licensing, security 24 | requirements, and other metadata that appears in the generated OpenAPI spec. 25 | """ 26 | 27 | title: str 28 | version: str 29 | 30 | summary: str | None = None 31 | description: str | None = None 32 | terms_of_service: str | None = None 33 | contact: Contact | None = None 34 | external_docs: ExternalDocumentation | None = None 35 | security: list[SecurityRequirement] | None = None 36 | license: License | None = None 37 | components: Components | list[Components] | None = None 38 | servers: list[Server] | None = None 39 | tags: list[Tag] | None = None 40 | webhooks: dict[str, PathItem | Reference] | None = None 41 | -------------------------------------------------------------------------------- /docs/examples/testing/polyfactory_usage.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | if sys.version_info >= (3, 14): 6 | pytest.skip(reason='Module does not supported yet', allow_module_level=True) 7 | 8 | import json # type: ignore[unreachable, unused-ignore] 9 | from http import HTTPStatus 10 | from typing import final 11 | 12 | from dirty_equals import IsUUID 13 | from django.http import HttpResponse 14 | from polyfactory.factories.pydantic_factory import ModelFactory 15 | 16 | from django_modern_rest.test import DMRRequestFactory 17 | from examples.testing.pydantic_controller import UserController, UserCreateModel 18 | 19 | 20 | @final 21 | class UserCreateModelFactory(ModelFactory[UserCreateModel]): 22 | """Will create structured random request data for you.""" 23 | 24 | __check_model__ = True 25 | 26 | 27 | def test_create_user(dmr_rf: DMRRequestFactory) -> None: 28 | # This will return random `UserCreatedModel` instances: 29 | request_data = UserCreateModelFactory.build().model_dump(mode='json') 30 | 31 | request = dmr_rf.post('/url/', data=request_data) 32 | 33 | response = UserController.as_view()(request) 34 | 35 | assert isinstance(response, HttpResponse) 36 | assert response.status_code == HTTPStatus.CREATED 37 | assert response.headers == {'Content-Type': 'application/json'} 38 | assert json.loads(response.content) == { 39 | 'uid': IsUUID, 40 | **request_data, 41 | } 42 | -------------------------------------------------------------------------------- /tests/test_unit/test_plugins/test_pydantic/test_model_config.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import Any, final 3 | 4 | import pydantic 5 | import pytest 6 | from django.http import HttpResponse 7 | 8 | from django_modern_rest import Body, Controller 9 | from django_modern_rest.plugins.pydantic import PydanticSerializer 10 | from django_modern_rest.test import DMRRequestFactory 11 | 12 | 13 | @final 14 | class _ReturnModel(pydantic.BaseModel): 15 | full_name: str 16 | 17 | model_config = pydantic.ConfigDict(extra='forbid') 18 | 19 | 20 | @final 21 | class _ModelConfigController( 22 | Controller[PydanticSerializer], 23 | Body[_ReturnModel], 24 | ): 25 | def post(self) -> _ReturnModel: 26 | return self.parsed_body 27 | 28 | 29 | @pytest.mark.parametrize( 30 | ('request_data', 'status_code'), 31 | [ 32 | ({'full_name': ''}, HTTPStatus.CREATED), 33 | ({'full_name': '', 'extra': ''}, HTTPStatus.BAD_REQUEST), 34 | ], 35 | ) 36 | def test_model_config_respected( 37 | dmr_rf: DMRRequestFactory, 38 | *, 39 | request_data: Any, 40 | status_code: HTTPStatus, 41 | ) -> None: 42 | """Ensures by default forward refs are working.""" 43 | request = dmr_rf.post('/whatever/', data=request_data) 44 | 45 | response = _ModelConfigController.as_view()(request) 46 | 47 | assert isinstance(response, HttpResponse) 48 | assert response.status_code == status_code 49 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/header.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, Any, Literal, final 3 | 4 | if TYPE_CHECKING: 5 | from django_modern_rest.openapi.objects.example import Example 6 | from django_modern_rest.openapi.objects.media_type import MediaType 7 | from django_modern_rest.openapi.objects.reference import Reference 8 | from django_modern_rest.openapi.objects.schema import Schema 9 | 10 | 11 | @final 12 | @dataclass(frozen=True, kw_only=True, slots=True) 13 | class Header: 14 | """ 15 | Header Object. 16 | 17 | The Header Object follows the structure of the Parameter Object 18 | with the following changes: 19 | 20 | 1. `name` MUST NOT be specified, it is given in the corresponding 21 | headers map. 22 | 2. `in` MUST NOT be specified, it is implicitly in header. 23 | 3. All traits that are affected by the location MUST be applicable to 24 | a location of header (for example, style). 25 | """ 26 | 27 | schema: 'Schema | Reference | None' = None 28 | name: Literal[''] = '' 29 | param_in: Literal['header'] = 'header' 30 | description: str | None = None 31 | required: bool = False 32 | deprecated: bool = False 33 | style: str | None = None 34 | explode: bool | None = None 35 | example: Any | None = None 36 | examples: 'dict[str, Example | Reference] | None' = None 37 | content: 'dict[str, MediaType] | None' = None 38 | -------------------------------------------------------------------------------- /django_modern_rest/templates/django_modern_rest/swagger.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | {{ title|default:"Swagger UI" }} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /docs/examples/returning_responses/active_validation.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import final 3 | 4 | import msgspec 5 | 6 | from django_modern_rest import APIError, Body, Controller, Headers 7 | from django_modern_rest.plugins.msgspec import MsgspecSerializer 8 | 9 | 10 | class UserModel(msgspec.Struct): 11 | email: str 12 | 13 | 14 | class HeaderModel(msgspec.Struct): 15 | consumer: str = msgspec.field(name='X-API-Consumer') 16 | 17 | 18 | @final 19 | class UserController( 20 | Controller[MsgspecSerializer], 21 | Body[UserModel], 22 | Headers[HeaderModel], 23 | ): 24 | def post(self) -> UserModel: 25 | if self.parsed_headers.consumer != 'my-api': 26 | # Notice that this response is never documented in the spec, 27 | # so, it will raise an error when validation is enabled (default). 28 | raise APIError( 29 | {'detail': 'Wrong API consumer'}, 30 | status_code=HTTPStatus.NOT_ACCEPTABLE, 31 | ) 32 | # This response will be documented by default: 33 | return self.parsed_body 34 | 35 | 36 | # run: {"controller": "UserController", "method": "post", "body": {"email": "user@wms.org"}, "headers": {"X-API-Consumer": "my-api"}, "url": "/api/user/"} # noqa: ERA001, E501 37 | # run: {"controller": "UserController", "method": "post", "body": {"email": "user@wms.org"}, "headers": {"X-API-Consumer": "not-my-api"}, "url": "/api/user/", "curl_args": ["-D", "-"], "fail-with-body": false} # noqa: ERA001, E501 38 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/open_api.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, Final, final 3 | 4 | from django_modern_rest.openapi.objects.components import Components 5 | 6 | if TYPE_CHECKING: 7 | from django_modern_rest.openapi.objects.external_documentation import ( 8 | ExternalDocumentation, 9 | ) 10 | from django_modern_rest.openapi.objects.info import Info 11 | from django_modern_rest.openapi.objects.path_item import PathItem 12 | from django_modern_rest.openapi.objects.paths import Paths 13 | from django_modern_rest.openapi.objects.reference import Reference 14 | from django_modern_rest.openapi.objects.security_requirement import ( 15 | SecurityRequirement, 16 | ) 17 | from django_modern_rest.openapi.objects.server import Server 18 | from django_modern_rest.openapi.objects.tag import Tag 19 | 20 | _OPENAPI_VERSION: Final = '3.1.0' 21 | 22 | 23 | @final 24 | @dataclass(frozen=True, kw_only=True, slots=True) 25 | class OpenAPI: 26 | """This is the root object of the OpenAPI document.""" 27 | 28 | openapi: str = _OPENAPI_VERSION 29 | 30 | info: 'Info' 31 | json_schema_dialect: str | None = None 32 | servers: 'list[Server] | None' = None 33 | paths: 'Paths | None' = None 34 | webhooks: 'dict[str, PathItem | Reference] | None' = None 35 | components: 'Components | None' = None 36 | security: 'list[SecurityRequirement] | None' = None 37 | tags: 'list[Tag] | None' = None 38 | external_docs: 'ExternalDocumentation | None' = None 39 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/renderers/redoc.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, final 2 | 3 | from django.http import HttpRequest, HttpResponse 4 | from django.shortcuts import render 5 | from typing_extensions import override 6 | 7 | from django_modern_rest.openapi.converter import ConvertedSchema 8 | from django_modern_rest.openapi.renderers.base import ( 9 | BaseRenderer, 10 | SchemaSerialier, 11 | json_serializer, 12 | ) 13 | 14 | 15 | @final 16 | class RedocRenderer(BaseRenderer): 17 | """ 18 | Renderer for OpenAPI schema using Redoc. 19 | 20 | Provides interactive HTML interface for exploring OpenAPI specification 21 | using Redoc components. 22 | """ 23 | 24 | # TODO: implement local static later. 25 | default_path: ClassVar[str] = 'redoc/' 26 | default_name: ClassVar[str] = 'redoc' 27 | content_type: ClassVar[str] = 'text/html' 28 | template_name: ClassVar[str] = 'django_modern_rest/redoc.html' 29 | serializer: SchemaSerialier = staticmethod(json_serializer) # noqa: WPS421 30 | 31 | @override 32 | def render( 33 | self, 34 | request: HttpRequest, 35 | schema: ConvertedSchema, 36 | ) -> HttpResponse: 37 | """Render the OpenAPI schema using Redoc template.""" 38 | return render( 39 | request, 40 | self.template_name, 41 | context={ 42 | 'title': schema['info']['title'], 43 | 'schema': self.serializer(schema), 44 | }, 45 | content_type=self.content_type, 46 | ) 47 | -------------------------------------------------------------------------------- /.importlinter: -------------------------------------------------------------------------------- 1 | # See https://github.com/seddonym/import-linter 2 | 3 | [importlinter] 4 | root_package = django_modern_rest 5 | include_external_packages = True 6 | exclude_type_checking_imports = True 7 | 8 | 9 | [importlinter:contract:layers] 10 | name = Layered architecture of our project 11 | type = layers 12 | 13 | containers = 14 | django_modern_rest 15 | 16 | layers = 17 | routing 18 | controller 19 | options_mixins 20 | endpoint 21 | validation 22 | components 23 | response 24 | serialization 25 | # DTOs: 26 | headers | cookies 27 | types 28 | internal 29 | settings 30 | exceptions 31 | 32 | ignore_imports = 33 | # Mixin validation: 34 | django_modern_rest.validation.* -> django_modern_rest.options_mixins 35 | 36 | 37 | [importlinter:contract:no-optional-deps] 38 | name = Do not use these optional dependencies outside of plugins 39 | type = forbidden 40 | 41 | source_modules = 42 | django_modern_rest 43 | 44 | forbidden_modules = 45 | # optinal deps: 46 | pydantic 47 | msgspec 48 | # internal modules: 49 | django_modern_rest_pytest 50 | django_test_app 51 | 52 | ignore_imports = 53 | # Pydantic plugin: 54 | django_modern_rest.plugins.pydantic -> pydantic 55 | # Msgspec plugin: 56 | django_modern_rest.plugins.msgspec -> msgspec 57 | # Optional json support from msgspec: 58 | django_modern_rest.internal.json.msgspec -> msgspec 59 | 60 | 61 | [importlinter:contract:plugins-independence] 62 | name = All plugins must be independent 63 | type = independence 64 | 65 | modules = 66 | django_modern_rest.plugins.* 67 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/renderers/scalar.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, final 2 | 3 | from django.http import HttpRequest, HttpResponse 4 | from django.shortcuts import render 5 | from typing_extensions import override 6 | 7 | from django_modern_rest.openapi.converter import ConvertedSchema 8 | from django_modern_rest.openapi.renderers.base import ( 9 | BaseRenderer, 10 | SchemaSerialier, 11 | json_serializer, 12 | ) 13 | 14 | 15 | @final 16 | class ScalarRenderer(BaseRenderer): 17 | """ 18 | Renderer for OpenAPI schema using Scalar. 19 | 20 | Provides interactive HTML interface for exploring OpenAPI specification 21 | using Scalar API Reference. 22 | """ 23 | 24 | # TODO: implement local static loading 25 | default_path: ClassVar[str] = 'scalar/' 26 | default_name: ClassVar[str] = 'scalar' 27 | content_type: ClassVar[str] = 'text/html' 28 | template_name: ClassVar[str] = 'django_modern_rest/scalar.html' 29 | serializer: SchemaSerialier = staticmethod(json_serializer) # noqa: WPS421 30 | 31 | @override 32 | def render( 33 | self, 34 | request: HttpRequest, 35 | schema: ConvertedSchema, 36 | ) -> HttpResponse: 37 | """Render the OpenAPI schema using Scalar template.""" 38 | return render( 39 | request, 40 | self.template_name, 41 | context={ 42 | 'title': schema['info']['title'], 43 | 'schema': self.serializer(schema), 44 | }, 45 | content_type=self.content_type, 46 | ) 47 | -------------------------------------------------------------------------------- /django_modern_rest/internal/json/__init__.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import Any, Protocol, TypeAlias 3 | 4 | try: # noqa: WPS229 # pragma: no cover 5 | from django_modern_rest.internal.json.msgspec import ( 6 | deserialize as deserialize, 7 | ) 8 | from django_modern_rest.internal.json.msgspec import serialize as serialize 9 | except ImportError: # pragma: no cover 10 | from django_modern_rest.internal.json.raw import ( 11 | deserialize as deserialize, 12 | ) 13 | from django_modern_rest.internal.json.raw import ( 14 | serialize as serialize, 15 | ) 16 | 17 | #: Types that are possible to load json from. 18 | FromJson: TypeAlias = str | bytes | bytearray 19 | 20 | 21 | class Serialize(Protocol): 22 | """Type that represents the `serialize` callback.""" 23 | 24 | def __call__( 25 | self, 26 | to_serialize: Any, 27 | serializer: Callable[[Any], Any], 28 | ) -> bytes: # pyright: ignore[reportReturnType] 29 | """Function to be called on object serialization.""" 30 | 31 | 32 | #: Type that represents the `deserializer` hook. 33 | DeserializeFunc: TypeAlias = Callable[[type[Any], Any], Any] 34 | 35 | 36 | class Deserialize(Protocol): 37 | """Type that represents the `deserialize` callback.""" 38 | 39 | def __call__( 40 | self, 41 | to_deserialize: FromJson, 42 | deserializer: DeserializeFunc, 43 | *, 44 | strict: bool = True, 45 | ) -> Any: 46 | """Function to be called on object deserialization.""" 47 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/operation.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, final 3 | 4 | if TYPE_CHECKING: 5 | from django_modern_rest.openapi.objects.callback import Callback 6 | from django_modern_rest.openapi.objects.external_documentation import ( 7 | ExternalDocumentation, 8 | ) 9 | from django_modern_rest.openapi.objects.parameter import Parameter 10 | from django_modern_rest.openapi.objects.reference import Reference 11 | from django_modern_rest.openapi.objects.request_body import RequestBody 12 | from django_modern_rest.openapi.objects.responses import Responses 13 | from django_modern_rest.openapi.objects.security_requirement import ( 14 | SecurityRequirement, 15 | ) 16 | from django_modern_rest.openapi.objects.server import Server 17 | 18 | 19 | @final 20 | @dataclass(frozen=True, kw_only=True, slots=True) 21 | class Operation: 22 | """Describes a single API operation on a path.""" 23 | 24 | tags: list[str] | None = None 25 | summary: str | None = None 26 | description: str | None = None 27 | external_docs: 'ExternalDocumentation | None' = None 28 | operation_id: str | None = None 29 | parameters: 'list[Parameter | Reference] | None' = None 30 | request_body: 'RequestBody | Reference | None' = None 31 | responses: 'Responses | None' = None 32 | callbacks: 'dict[str, Callback | Reference] | None' = None 33 | deprecated: bool = False 34 | security: 'list[SecurityRequirement] | None' = None 35 | servers: 'list[Server] | None' = None 36 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/renderers/swagger.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, final 2 | 3 | from django.http import HttpRequest, HttpResponse 4 | from django.shortcuts import render 5 | from typing_extensions import override 6 | 7 | from django_modern_rest.openapi.converter import ConvertedSchema 8 | from django_modern_rest.openapi.renderers.base import ( 9 | BaseRenderer, 10 | SchemaSerialier, 11 | json_serializer, 12 | ) 13 | 14 | 15 | @final 16 | class SwaggerRenderer(BaseRenderer): 17 | """ 18 | Renderer for OpenAPI schema using Swagger UI. 19 | 20 | Provides interactive HTML interface for exploring OpenAPI specification 21 | using Swagger UI components. 22 | """ 23 | 24 | # TODO: implement cdn static loading 25 | default_path: ClassVar[str] = 'swagger/' 26 | default_name: ClassVar[str] = 'swagger' 27 | content_type: ClassVar[str] = 'text/html' 28 | template_name: ClassVar[str] = 'django_modern_rest/swagger.html' 29 | serializer: SchemaSerialier = staticmethod(json_serializer) # noqa: WPS421 30 | 31 | @override 32 | def render( 33 | self, 34 | request: HttpRequest, 35 | schema: ConvertedSchema, 36 | ) -> HttpResponse: 37 | """Render the OpenAPI schema using Swagger UI template.""" 38 | return render( 39 | request, 40 | self.template_name, 41 | context={ 42 | 'title': schema['info']['title'], 43 | 'schema': self.serializer(schema), 44 | }, 45 | content_type=self.content_type, 46 | ) 47 | -------------------------------------------------------------------------------- /docs/examples/returning_responses/validate_cookies.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from http import HTTPStatus 3 | from typing import final 4 | 5 | import pydantic 6 | from django.http import HttpResponse 7 | 8 | from django_modern_rest import ( 9 | Body, 10 | Controller, 11 | CookieSpec, 12 | NewCookie, 13 | ResponseSpec, 14 | validate, 15 | ) 16 | from django_modern_rest.plugins.pydantic import PydanticSerializer 17 | 18 | 19 | class UserModel(pydantic.BaseModel): 20 | email: str 21 | 22 | 23 | @final 24 | class UserController( 25 | Controller[PydanticSerializer], 26 | Body[UserModel], 27 | ): 28 | @validate( 29 | ResponseSpec( 30 | UserModel, 31 | status_code=HTTPStatus.CREATED, 32 | cookies={ 33 | 'user_id': CookieSpec(), 34 | 'session': CookieSpec(max_age=1000, required=False), 35 | }, 36 | ), 37 | ) 38 | def post(self) -> HttpResponse: 39 | uid = uuid.uuid4() 40 | # This response would have one required cookie `user_id` 41 | # and one optional cookie `session`: 42 | cookies = {'user_id': NewCookie(value=str(uid))} 43 | if '@ourdomain.com' in self.parsed_body.email: 44 | cookies['session'] = NewCookie(value='true', max_age=1000) 45 | return self.to_response( 46 | self.parsed_body, 47 | cookies=cookies, 48 | ) 49 | 50 | 51 | # run: {"controller": "UserController", "method": "post", "body": {"email": "user@ourdomain.com"}, "url": "/api/user/", "curl_args": ["-D", "-"]} # noqa: ERA001, E501 52 | -------------------------------------------------------------------------------- /django_modern_rest_pytest.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | try: 4 | import pytest 5 | except ImportError: # pragma: no cover 6 | print( # noqa: WPS421 7 | 'Looks like `pytest` is not installed, please install it separately', 8 | ) 9 | raise 10 | 11 | if TYPE_CHECKING: 12 | # We can't import it directly, because it will ruin our coverage measures. 13 | from django_modern_rest.test import ( 14 | DMRAsyncClient, 15 | DMRAsyncRequestFactory, 16 | DMRClient, 17 | DMRRequestFactory, 18 | ) 19 | 20 | 21 | @pytest.fixture 22 | def dmr_client() -> 'DMRClient': 23 | """Customized version of :class:`django.test.Client`.""" 24 | from django_modern_rest.test import DMRClient # noqa: PLC0415 25 | 26 | return DMRClient() 27 | 28 | 29 | @pytest.fixture 30 | def dmr_async_client() -> 'DMRAsyncClient': 31 | """Customized version of :class:`django.test.AsyncClient`.""" 32 | from django_modern_rest.test import DMRAsyncClient # noqa: PLC0415 33 | 34 | return DMRAsyncClient() 35 | 36 | 37 | @pytest.fixture 38 | def dmr_rf() -> 'DMRRequestFactory': 39 | """Customized version of :class:`django.test.RequestFactory`.""" 40 | from django_modern_rest.test import DMRRequestFactory # noqa: PLC0415 41 | 42 | return DMRRequestFactory() 43 | 44 | 45 | @pytest.fixture 46 | def dmr_async_rf() -> 'DMRAsyncRequestFactory': 47 | """Customized version of :class:`django.test.AsyncRequestFactory`.""" 48 | from django_modern_rest.test import DMRAsyncRequestFactory # noqa: PLC0415 49 | 50 | return DMRAsyncRequestFactory() 51 | -------------------------------------------------------------------------------- /docs/_templates/github.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /docs/examples/returning_responses/per_endpoint.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import final 3 | 4 | import msgspec 5 | 6 | from django_modern_rest import APIError, Body, Controller, Headers, modify 7 | from django_modern_rest.plugins.msgspec import MsgspecSerializer 8 | 9 | 10 | class UserModel(msgspec.Struct): 11 | email: str 12 | 13 | 14 | class HeaderModel(msgspec.Struct): 15 | consumer: str = msgspec.field(name='X-API-Consumer') 16 | 17 | 18 | @final 19 | class UserController( 20 | Controller[MsgspecSerializer], 21 | Body[UserModel], 22 | Headers[HeaderModel], 23 | ): 24 | @modify(validate_responses=False) # <- now, we won't validate this endpoint 25 | def post(self) -> UserModel: 26 | if self.parsed_headers.consumer != 'my-api': 27 | # Notice that this response is never documented in the spec, 28 | # but, it won't raise a validation error, because validation is off 29 | raise APIError( 30 | {'detail': 'Wrong API consumer'}, 31 | status_code=HTTPStatus.NOT_ACCEPTABLE, 32 | ) 33 | # This response will be documented by default: 34 | return self.parsed_body 35 | 36 | 37 | # run: {"controller": "UserController", "method": "post", "body": {"email": "user@wms.org"}, "headers": {"X-API-Consumer": "my-api"}, "url": "/api/user/"} # noqa: ERA001, E501 38 | # run: {"controller": "UserController", "method": "post", "body": {"email": "user@wms.org"}, "headers": {"X-API-Consumer": "not-my-api"}, "url": "/api/user/", "curl_args": ["-D", "-"], "fail-with-body": false} # noqa: ERA001, E501 39 | -------------------------------------------------------------------------------- /docs/examples/returning_responses/per_controller.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import ClassVar, final 3 | 4 | import msgspec 5 | 6 | from django_modern_rest import APIError, Body, Controller, Headers 7 | from django_modern_rest.plugins.msgspec import MsgspecSerializer 8 | 9 | 10 | class UserModel(msgspec.Struct): 11 | email: str 12 | 13 | 14 | class HeaderModel(msgspec.Struct): 15 | consumer: str = msgspec.field(name='X-API-Consumer') 16 | 17 | 18 | @final 19 | class UserController( 20 | Controller[MsgspecSerializer], 21 | Body[UserModel], 22 | Headers[HeaderModel], 23 | ): 24 | # Now, we won't validate all endpoints in this controller: 25 | validate_responses: ClassVar[bool | None] = False 26 | 27 | def post(self) -> UserModel: 28 | if self.parsed_headers.consumer != 'my-api': 29 | # Notice that this response is never documented in the spec, 30 | # but, it won't raise a validation error, because validation is off 31 | raise APIError( 32 | {'detail': 'Wrong API consumer'}, 33 | status_code=HTTPStatus.NOT_ACCEPTABLE, 34 | ) 35 | # This response will be documented by default: 36 | return self.parsed_body 37 | 38 | 39 | # run: {"controller": "UserController", "method": "post", "body": {"email": "user@wms.org"}, "headers": {"X-API-Consumer": "my-api"}, "url": "/api/user/"} # noqa: ERA001, E501 40 | # run: {"controller": "UserController", "method": "post", "body": {"email": "user@wms.org"}, "headers": {"X-API-Consumer": "not-my-api"}, "url": "/api/user/", "curl_args": ["-D", "-"], "fail-with-body": false} # noqa: ERA001, E501 41 | -------------------------------------------------------------------------------- /docs/examples/getting_started/single_file_asgi.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import pydantic 4 | from django.conf import settings 5 | from django.core.handlers import asgi 6 | from django.urls import include, path 7 | 8 | from django_modern_rest import Body, Controller, Headers 9 | from django_modern_rest.plugins.pydantic import PydanticSerializer 10 | from django_modern_rest.routing import Router 11 | 12 | if not settings.configured: 13 | settings.configure( 14 | # Keep it as is 15 | ROOT_URLCONF=__name__, 16 | # Required options but feel free to configure as you like 17 | DMR_SETTINGS={}, 18 | ALLOWED_HOSTS='*', 19 | DEBUG=True, 20 | ) 21 | 22 | app = asgi.ASGIHandler() 23 | 24 | 25 | class UserCreateModel(pydantic.BaseModel): 26 | email: str 27 | 28 | 29 | class UserModel(UserCreateModel): 30 | uid: uuid.UUID 31 | 32 | 33 | class HeaderModel(pydantic.BaseModel): 34 | consumer: str = pydantic.Field(alias='X-API-Consumer') 35 | 36 | 37 | class UserController( 38 | Controller[PydanticSerializer], 39 | Body[UserCreateModel], 40 | Headers[HeaderModel], 41 | ): 42 | async def post(self) -> UserModel: 43 | assert self.parsed_headers.consumer == 'my-api' 44 | return UserModel(uid=uuid.uuid4(), email=self.parsed_body.email) 45 | 46 | 47 | router = Router([ 48 | path('user/', UserController.as_view(), name='users'), 49 | ]) 50 | urlpatterns = [ 51 | path('api/', include((router.urls, 'your_app'), namespace='api')), 52 | ] 53 | 54 | # run: {"controller": "UserController", "method": "post", "body": {"email": "djangomodernrest@wms.org"}, "headers": {"X-API-Consumer": "my-api"}, "url": "/api/user/"} # noqa: ERA001, E501 55 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/core/merger.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from django_modern_rest.openapi.objects import ( 4 | Components, 5 | Info, 6 | OpenAPI, 7 | Paths, 8 | ) 9 | 10 | if TYPE_CHECKING: 11 | from django_modern_rest.openapi.core.context import OpenAPIContext 12 | 13 | 14 | class ConfigMerger: 15 | """ 16 | Merges OpenAPI configuration with generated paths and components. 17 | 18 | This class is responsible for combining the OpenAPI configuration 19 | from the context with the generated paths and components to create 20 | a complete OpenAPI specification object. 21 | """ 22 | 23 | def __init__(self, context: 'OpenAPIContext') -> None: 24 | """Initialize the merger with OpenAPI context.""" 25 | self.context = context 26 | 27 | def merge( 28 | self, 29 | paths: Paths, 30 | components: Components, 31 | ) -> OpenAPI: 32 | """Merge paths and components with configuration.""" 33 | config = self.context.config 34 | return OpenAPI( 35 | info=Info( 36 | title=config.title, 37 | version=config.version, 38 | summary=config.summary, 39 | description=config.description, 40 | terms_of_service=config.terms_of_service, 41 | contact=config.contact, 42 | license=config.license, 43 | ), 44 | servers=config.servers, 45 | tags=config.tags, 46 | external_docs=config.external_docs, 47 | security=config.security, 48 | webhooks=config.webhooks, 49 | paths=paths, 50 | components=components, 51 | ) 52 | -------------------------------------------------------------------------------- /docs/examples/error_handling/blueprint.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import ClassVar 3 | 4 | import httpx 5 | from django.http import HttpResponse 6 | from typing_extensions import override 7 | 8 | from django_modern_rest import ( 9 | Blueprint, 10 | ResponseSpec, 11 | ) 12 | from django_modern_rest.endpoint import Endpoint 13 | from django_modern_rest.plugins.pydantic import ( 14 | PydanticSerializer, 15 | ) 16 | 17 | 18 | class ProxyBlueprint(Blueprint[PydanticSerializer]): 19 | responses: ClassVar[list[ResponseSpec]] = [ 20 | # Custom schema that we can return when `HTTPError` happens: 21 | ResponseSpec(str, status_code=HTTPStatus.FAILED_DEPENDENCY), 22 | ] 23 | 24 | async def get(self) -> None: 25 | async with self._client() as client: 26 | # Simulate some real work: 27 | await client.get('https://example.com') 28 | 29 | async def post(self) -> None: 30 | async with self._client() as client: 31 | # Simulate some real work: 32 | await client.post('https://example.com', json={}) 33 | 34 | @override 35 | async def handle_async_error( 36 | self, 37 | endpoint: Endpoint, 38 | exc: Exception, 39 | ) -> HttpResponse: 40 | # Will handle errors in all endpoints. 41 | if isinstance(exc, httpx.HTTPError): 42 | return self.to_error( 43 | 'Request to example.com failed', 44 | status_code=HTTPStatus.FAILED_DEPENDENCY, 45 | ) 46 | # Reraise unfamiliar errors to let someone 47 | # else to handle them further. 48 | raise exc 49 | 50 | def _client(self) -> httpx.AsyncClient: 51 | return httpx.AsyncClient() 52 | -------------------------------------------------------------------------------- /docs/examples/error_handling/endpoint.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import pydantic 4 | from django.http import HttpResponse 5 | 6 | from django_modern_rest import ( 7 | Body, 8 | Controller, 9 | modify, 10 | ) 11 | from django_modern_rest.endpoint import Endpoint 12 | from django_modern_rest.plugins.pydantic import ( 13 | PydanticSerializer, 14 | ) 15 | 16 | 17 | class TwoNumbers(pydantic.BaseModel): 18 | left: int 19 | right: int 20 | 21 | 22 | class MathController(Controller[PydanticSerializer], Body[TwoNumbers]): 23 | def division_error( # <- we define an error handler 24 | self, 25 | endpoint: Endpoint, 26 | exc: Exception, 27 | ) -> HttpResponse: 28 | if isinstance(exc, ZeroDivisionError): 29 | # This response's schema was automatically added 30 | # by `response_from_components = True` setting: 31 | return self.to_error( 32 | {'detail': self.serializer.error_serialize(str(exc))}, 33 | status_code=HTTPStatus.BAD_REQUEST, 34 | ) 35 | # Reraise unfamiliar errors to let someone 36 | # else to handle them further. 37 | raise exc 38 | 39 | @modify(error_handler=division_error) # <- and we pass the handler 40 | def get(self) -> float: # <- has custom error handling 41 | return self.parsed_body.left / self.parsed_body.right 42 | 43 | def post(self) -> float: # <- has only default error handling 44 | return self.parsed_body.left * self.parsed_body.right 45 | 46 | 47 | # run: {"controller": "MathController", "method": "get", "body": {"left": 1, "right": 0}, "url": "/api/math/", "curl_args": ["-D", "-"], "fail-with-body": false} # noqa: ERA001, E501 48 | -------------------------------------------------------------------------------- /docs/examples/error_handling/controller.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import ClassVar 3 | 4 | import httpx 5 | from django.http import HttpResponse 6 | from typing_extensions import override 7 | 8 | from django_modern_rest import ( 9 | Controller, 10 | ResponseSpec, 11 | ) 12 | from django_modern_rest.endpoint import Endpoint 13 | from django_modern_rest.plugins.pydantic import ( 14 | PydanticSerializer, 15 | ) 16 | 17 | 18 | class ProxyController(Controller[PydanticSerializer]): 19 | responses: ClassVar[list[ResponseSpec]] = [ 20 | # Custom schema that we can return when `HTTPError` happens: 21 | ResponseSpec(str, status_code=HTTPStatus.FAILED_DEPENDENCY), 22 | ] 23 | 24 | async def get(self) -> None: 25 | async with self._client() as client: 26 | # Simulate some real work: 27 | await client.get('https://example.com') 28 | 29 | async def post(self) -> None: 30 | async with self._client() as client: 31 | # Simulate some real work: 32 | await client.post('https://example.com', json={}) 33 | 34 | @override 35 | async def handle_async_error( 36 | self, 37 | endpoint: Endpoint, 38 | exc: Exception, 39 | ) -> HttpResponse: 40 | # Will handle errors in all endpoints. 41 | if isinstance(exc, httpx.HTTPError): 42 | return self.to_error( 43 | 'Request to example.com failed', 44 | status_code=HTTPStatus.FAILED_DEPENDENCY, 45 | ) 46 | # Reraise unfamiliar errors to let someone 47 | # else to handle them further. 48 | raise exc 49 | 50 | def _client(self) -> httpx.AsyncClient: 51 | return httpx.AsyncClient() 52 | -------------------------------------------------------------------------------- /docs/examples/middleware/multi_status.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from http import HTTPStatus 3 | 4 | from django.http import HttpRequest, HttpResponse 5 | 6 | from django_modern_rest import ResponseSpec 7 | from django_modern_rest.decorators import wrap_middleware 8 | from django_modern_rest.plugins.pydantic import PydanticSerializer 9 | from django_modern_rest.response import build_response 10 | 11 | 12 | def custom_middleware( 13 | get_response: Callable[[HttpRequest], HttpResponse], 14 | ) -> Callable[[HttpRequest], HttpResponse]: 15 | """Dummy middleware for demonstration purposes.""" 16 | 17 | def decorator(request: HttpRequest) -> HttpResponse: 18 | """Just pass the request through unchanged.""" 19 | return get_response(request) 20 | 21 | return decorator 22 | 23 | 24 | @wrap_middleware( 25 | custom_middleware, 26 | ResponseSpec( 27 | return_type=dict[str, str], 28 | status_code=HTTPStatus.BAD_REQUEST, 29 | ), 30 | ResponseSpec( 31 | return_type=dict[str, str], 32 | status_code=HTTPStatus.UNAUTHORIZED, 33 | ), 34 | ) 35 | def multi_status_middleware(response: HttpResponse) -> HttpResponse: 36 | """Handle multiple status codes.""" 37 | if response.status_code == HTTPStatus.BAD_REQUEST: 38 | return build_response( 39 | PydanticSerializer, 40 | raw_data={'error': 'Bad request'}, 41 | status_code=HTTPStatus(response.status_code), 42 | ) 43 | if response.status_code == HTTPStatus.UNAUTHORIZED: 44 | return build_response( 45 | PydanticSerializer, 46 | raw_data={'error': 'Unauthorized'}, 47 | status_code=HTTPStatus(response.status_code), 48 | ) 49 | return response 50 | -------------------------------------------------------------------------------- /docs/examples/returning_responses/validate_headers.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from http import HTTPStatus 3 | from typing import final 4 | 5 | import pydantic 6 | from django.http import HttpResponse 7 | 8 | from django_modern_rest import ( 9 | Body, 10 | Controller, 11 | HeaderSpec, 12 | ResponseSpec, 13 | validate, 14 | ) 15 | from django_modern_rest.plugins.pydantic import PydanticSerializer 16 | 17 | 18 | class UserModel(pydantic.BaseModel): 19 | email: str 20 | 21 | 22 | @final 23 | class UserController( 24 | Controller[PydanticSerializer], 25 | Body[UserModel], 26 | ): 27 | @validate( 28 | ResponseSpec( 29 | UserModel, 30 | status_code=HTTPStatus.OK, 31 | headers={ 32 | 'X-Created': HeaderSpec(), 33 | 'X-Our-Domain': HeaderSpec(required=False), 34 | }, 35 | ), 36 | ) 37 | def post(self) -> HttpResponse: 38 | uid = uuid.uuid4() 39 | # This response would have an explicit status code `200` 40 | # and one required header `X-Created` and one optional `X-Our-Domain`: 41 | headers = {'X-Created': str(uid)} 42 | if '@ourdomain.com' in self.parsed_body.email: 43 | headers['X-Our-Domain'] = 'true' 44 | return self.to_response( 45 | self.parsed_body, 46 | status_code=HTTPStatus.OK, 47 | headers=headers, 48 | ) 49 | 50 | 51 | # run: {"controller": "UserController", "method": "post", "body": {"email": "user@wms.org"}, "url": "/api/user/", "curl_args": ["-D", "-"]} # noqa: ERA001, E501 52 | # run: {"controller": "UserController", "method": "post", "body": {"email": "user@ourdomain.com"}, "url": "/api/user/", "curl_args": ["-D", "-"]} # noqa: ERA001, E501 53 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/core/builder.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from django_modern_rest.openapi.collector import controller_collector 4 | from django_modern_rest.openapi.core.merger import ConfigMerger 5 | from django_modern_rest.openapi.generators.component import ComponentGenerator 6 | from django_modern_rest.openapi.generators.path_item import PathItemGenerator 7 | 8 | if TYPE_CHECKING: 9 | from django_modern_rest.openapi.core.context import OpenAPIContext 10 | from django_modern_rest.openapi.objects import OpenAPI, Paths 11 | from django_modern_rest.routing import Router 12 | 13 | 14 | class OpenApiBuilder: 15 | """ 16 | Builds OpenAPI specification. 17 | 18 | This class orchestrates the process of generating a complete OpenAPI 19 | specification by collecting controllers from the router, generating path 20 | items for each controller, extracting shared components, and merging 21 | everything together with the configuration. 22 | """ 23 | 24 | def __init__(self, context: 'OpenAPIContext') -> None: 25 | """Initialize the builder with OpenAPI context.""" 26 | self._config_merger = ConfigMerger(context) 27 | self._path_generator = PathItemGenerator(context) 28 | self._component_generator = ComponentGenerator(context) 29 | 30 | def build(self, router: 'Router') -> 'OpenAPI': 31 | """Build complete OpenAPI specification from a router.""" 32 | paths_items: Paths = {} 33 | 34 | for controller in controller_collector(router.urls): 35 | path_item = self._path_generator.generate(controller) 36 | paths_items[controller.path] = path_item 37 | 38 | components = self._component_generator.generate(paths_items) 39 | return self._config_merger.merge(paths_items, components) 40 | -------------------------------------------------------------------------------- /docs/examples/returning_responses/right_way.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import final 3 | 4 | import msgspec 5 | 6 | from django_modern_rest import ( 7 | APIError, 8 | Body, 9 | Controller, 10 | Headers, 11 | ResponseSpec, 12 | modify, 13 | ) 14 | from django_modern_rest.plugins.msgspec import MsgspecSerializer 15 | 16 | 17 | class UserModel(msgspec.Struct): 18 | email: str 19 | 20 | 21 | class HeaderModel(msgspec.Struct): 22 | consumer: str = msgspec.field(name='X-API-Consumer') 23 | 24 | 25 | @final 26 | class UserController( 27 | Controller[MsgspecSerializer], 28 | Body[UserModel], 29 | Headers[HeaderModel], 30 | ): 31 | @modify( 32 | extra_responses=[ 33 | ResponseSpec( 34 | dict[str, str], 35 | status_code=HTTPStatus.NOT_ACCEPTABLE, 36 | ), 37 | ], 38 | ) 39 | def post(self) -> UserModel: 40 | if self.parsed_headers.consumer != 'my-api': 41 | # Notice that this response is now documented in the spec, 42 | # no error will happen, no need to disable the validation. 43 | raise APIError( 44 | {'detail': 'Wrong API consumer'}, 45 | status_code=HTTPStatus.NOT_ACCEPTABLE, 46 | ) 47 | # This response will be documented by default: 48 | return self.parsed_body 49 | 50 | 51 | # run: {"controller": "UserController", "method": "post", "body": {"email": "user@wms.org"}, "headers": {"X-API-Consumer": "my-api"}, "url": "/api/user/"} # noqa: ERA001, E501 52 | # run: {"controller": "UserController", "method": "post", "body": {"email": "user@wms.org"}, "headers": {"X-API-Consumer": "not-my-api"}, "url": "/api/user/", "curl_args": ["-D", "-"], "fail-with-body": false} # noqa: ERA001, E501 53 | -------------------------------------------------------------------------------- /docs/pages/components.rst: -------------------------------------------------------------------------------- 1 | Components 2 | ========== 3 | 4 | ``django-modern-rest`` utilizes component approach to parse all 5 | the unstructured things like headers, body, and cookies 6 | into a string typed and validated model. 7 | 8 | To use a component, you can just add it as a base class to your 9 | :class:`~django_modern_rest.controller.Controller` 10 | or :class:`~django_modern_rest.controller.Blueprint`. 11 | 12 | How does it work? 13 | 14 | - When controller / blueprint is first created, 15 | we iterate over all existing components in this class 16 | - Next, we create a request parsing model during the import time, 17 | with all combined fields to be parsed later 18 | - In runtime, when request is received, we provide the needed data 19 | for this single parsing model 20 | - If everything is ok, we call the needed endpoint with the correct data 21 | - If there's a parsing error we raise 22 | :exc:`~django_modern_rest.exceptions.RequestSerializationError` 23 | and return a beautiful error message for the user 24 | 25 | You can use existing ones or create our own. 26 | 27 | 28 | Existing components 29 | ------------------- 30 | 31 | Parsing headers 32 | ~~~~~~~~~~~~~~~ 33 | 34 | .. autoclass:: django_modern_rest.components.Headers 35 | 36 | Parsing query string 37 | ~~~~~~~~~~~~~~~~~~~~ 38 | 39 | .. autoclass:: django_modern_rest.components.Query 40 | 41 | Parsing request body 42 | ~~~~~~~~~~~~~~~~~~~~ 43 | 44 | .. autoclass:: django_modern_rest.components.Body 45 | 46 | Parsing path parameters 47 | ~~~~~~~~~~~~~~~~~~~~~~~ 48 | 49 | .. autoclass:: django_modern_rest.components.Path 50 | 51 | Parsing cookies 52 | ~~~~~~~~~~~~~~~ 53 | 54 | .. autoclass:: django_modern_rest.components.Cookies 55 | 56 | 57 | Base API 58 | -------- 59 | 60 | .. autoclass:: django_modern_rest.components.ComponentParser 61 | :members: 62 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/generators/path_item.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, TypedDict 2 | 3 | from django_modern_rest.openapi.objects import PathItem 4 | 5 | if TYPE_CHECKING: 6 | from django_modern_rest.openapi.collector import ControllerMapping 7 | from django_modern_rest.openapi.core.context import OpenAPIContext 8 | from django_modern_rest.openapi.objects import Operation 9 | 10 | 11 | # TODO: support openapi 3.2.0 12 | class _PathItemKwargs(TypedDict, total=False): 13 | get: 'Operation' 14 | put: 'Operation' 15 | post: 'Operation' 16 | delete: 'Operation' 17 | options: 'Operation' 18 | head: 'Operation' 19 | patch: 'Operation' 20 | trace: 'Operation' 21 | 22 | 23 | class PathItemGenerator: 24 | """ 25 | Generator for OpenAPI PathItem objects. 26 | 27 | The PathItem Generator is responsible for creating PathItem objects 28 | that represent a single API endpoint with its possible HTTP operations. 29 | It takes a controller mapping and generates a PathItem containing all 30 | the operations (GET, POST, PUT, DELETE, etc.) defined for that endpoint. 31 | """ 32 | 33 | def __init__(self, context: 'OpenAPIContext') -> None: 34 | """Initialize the PathItem Generator.""" 35 | self.context = context 36 | 37 | def generate(self, mapping: 'ControllerMapping') -> PathItem: 38 | """Generate an OpenAPI PathItem from a controller mapping.""" 39 | kwargs: _PathItemKwargs = {} 40 | 41 | for method, endpoint in mapping.controller.api_endpoints.items(): 42 | operation = self.context.operation_generator.generate( 43 | endpoint, 44 | mapping.path, 45 | ) 46 | kwargs[method.lower()] = operation # type: ignore[literal-required] 47 | 48 | return PathItem(**kwargs) 49 | -------------------------------------------------------------------------------- /docs/examples/middleware/complete_csrf_setup.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import ClassVar 3 | 4 | from django.http import HttpResponse 5 | from django.views.decorators.csrf import ensure_csrf_cookie 6 | 7 | from django_modern_rest import Controller, ResponseSpec 8 | from django_modern_rest.decorators import wrap_middleware 9 | from django_modern_rest.plugins.pydantic import PydanticSerializer 10 | from examples.middleware.csrf_protect_json import csrf_protect_json 11 | 12 | 13 | # CSRF cookie for GET requests 14 | @wrap_middleware( 15 | ensure_csrf_cookie, 16 | ResponseSpec( 17 | return_type=dict[str, str], 18 | status_code=HTTPStatus.OK, 19 | ), 20 | ) 21 | def ensure_csrf_cookie_json(response: HttpResponse) -> HttpResponse: 22 | """Return response ensuring CSRF cookie is set.""" 23 | return response 24 | 25 | 26 | @csrf_protect_json 27 | class ProtectedController(Controller[PydanticSerializer]): 28 | """Protected API controller requiring CSRF token.""" 29 | 30 | responses: ClassVar[list[ResponseSpec]] = csrf_protect_json.responses 31 | 32 | def get(self) -> dict[str, str]: 33 | """Get CSRF token.""" 34 | return {'message': 'Use this endpoint to get CSRF token'} 35 | 36 | def post(self) -> dict[str, str]: 37 | """Protected endpoint requiring CSRF token.""" 38 | return {'message': 'Successfully created resource'} 39 | 40 | 41 | @ensure_csrf_cookie_json 42 | class PublicController(Controller[PydanticSerializer]): 43 | responses: ClassVar[list[ResponseSpec]] = ensure_csrf_cookie_json.responses 44 | 45 | def get(self) -> dict[str, str]: 46 | """Public endpoint that sets CSRF cookie.""" 47 | return {'message': 'CSRF cookie set'} 48 | 49 | 50 | # run: {"controller": "PublicController", "method": "get", "curl_args": ["-D", "-"]} # noqa: ERA001, E501 51 | -------------------------------------------------------------------------------- /tests/test_unit/test_controllers/test_head_method.py: -------------------------------------------------------------------------------- 1 | import json 2 | from http import HTTPStatus 3 | from typing import cast 4 | 5 | from django.http import HttpResponse 6 | from inline_snapshot import snapshot 7 | 8 | from django_modern_rest import ( 9 | Controller, 10 | ResponseSpec, 11 | validate, 12 | ) 13 | from django_modern_rest.plugins.pydantic import PydanticSerializer 14 | from django_modern_rest.test import DMRRequestFactory 15 | 16 | 17 | class _HeadController(Controller[PydanticSerializer]): 18 | @validate( 19 | ResponseSpec(None, status_code=HTTPStatus.OK), 20 | allow_custom_http_methods=True, 21 | ) 22 | def head(self) -> HttpResponse: 23 | return self.to_response(None, status_code=HTTPStatus.OK) 24 | 25 | 26 | def test_head_method(dmr_rf: DMRRequestFactory) -> None: 27 | """Ensure that `head` method is supported.""" 28 | request = dmr_rf.head('/whatever/') 29 | 30 | response = cast(HttpResponse, _HeadController.as_view()(request)) 31 | 32 | assert response.status_code == HTTPStatus.OK 33 | assert response.content == b'null' 34 | 35 | 36 | class _GetController(Controller[PydanticSerializer]): 37 | @validate( 38 | ResponseSpec(None, status_code=HTTPStatus.OK), 39 | allow_custom_http_methods=True, 40 | ) 41 | def get(self) -> HttpResponse: 42 | raise NotImplementedError 43 | 44 | 45 | def test_no_explicit_head_method(dmr_rf: DMRRequestFactory) -> None: 46 | """Ensure that `get` is not used as an alias for `head`.""" 47 | request = dmr_rf.head('/whatever/') 48 | 49 | response = cast(HttpResponse, _GetController.as_view()(request)) 50 | 51 | assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED 52 | assert json.loads(response.content) == snapshot({ 53 | 'detail': "Method 'HEAD' is not allowed, allowed: ['GET']", 54 | }) 55 | -------------------------------------------------------------------------------- /django_test_app/server/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for django_test_app project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/5.2/topics/http/urls/ 6 | 7 | Examples: 8 | Function views 9 | 1. Add an import: from my_app import views 10 | 2. Add a URL to urlpatterns: path('', rest_views.home, name='home') 11 | Class-based views 12 | 1. Add an import: from other_app.views import Home 13 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 14 | Including another URLconf 15 | 1. Import the include() function: from django.urls import include, path 16 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 17 | """ 18 | 19 | from django.urls import include, path 20 | 21 | from django_modern_rest.routing import Router 22 | from server.apps.controllers import urls as controllers_urls 23 | from server.apps.middlewares import urls as middleware_urls 24 | from server.apps.models_example import urls as models_example_urls 25 | from server.apps.openapi.urls import build_spec 26 | 27 | router = Router([ 28 | path( 29 | 'model_examples/', 30 | include( 31 | (models_example_urls.router.urls, 'models_example'), 32 | namespace='model_examples', 33 | ), 34 | ), 35 | path( 36 | 'middlewares/', 37 | include( 38 | (middleware_urls.router.urls, 'middlewares'), 39 | namespace='middlewares', 40 | ), 41 | ), 42 | path( 43 | 'controllers/', 44 | include( 45 | (controllers_urls.router.urls, 'controllers'), 46 | namespace='controllers', 47 | ), 48 | ), 49 | ]) 50 | 51 | urlpatterns = [ 52 | path('api/', include((router.urls, 'server'), namespace='api')), 53 | path('docs/', build_spec(router)), 54 | ] 55 | -------------------------------------------------------------------------------- /tests/test_unit/test_controllers/test_customization/test_disable_errors_from_components.py: -------------------------------------------------------------------------------- 1 | import json 2 | from http import HTTPMethod, HTTPStatus 3 | from typing import final 4 | 5 | import pytest 6 | from django.http import HttpResponse 7 | 8 | from django_modern_rest import Controller, ResponseSpec, modify, validate 9 | from django_modern_rest.plugins.pydantic import PydanticSerializer 10 | from django_modern_rest.test import DMRRequestFactory 11 | 12 | 13 | @final 14 | class _WrongController(Controller[PydanticSerializer]): 15 | """All return types of these methods are not correct.""" 16 | 17 | responses_from_components = False 18 | 19 | def get(self) -> str: 20 | return 1 # type: ignore[return-value] 21 | 22 | @modify(status_code=HTTPStatus.OK) 23 | def post(self) -> int: 24 | return 'missing' # type: ignore[return-value] 25 | 26 | @validate( 27 | ResponseSpec( 28 | return_type=dict[str, int], 29 | status_code=HTTPStatus.OK, 30 | ), 31 | ) 32 | def patch(self) -> HttpResponse: 33 | return HttpResponse(b'[]') 34 | 35 | 36 | @pytest.mark.parametrize( 37 | 'method', 38 | [ 39 | HTTPMethod.GET, 40 | HTTPMethod.POST, 41 | HTTPMethod.PATCH, 42 | ], 43 | ) 44 | def test_responses_are_not_added( 45 | dmr_rf: DMRRequestFactory, 46 | *, 47 | method: HTTPMethod, 48 | ) -> None: 49 | """Ensures that response validation works for default settings.""" 50 | endpoint = _WrongController.api_endpoints[str(method)] 51 | assert len(endpoint.metadata.responses) == 1 52 | 53 | request = dmr_rf.generic(str(method), '/whatever/') 54 | 55 | response = _WrongController.as_view()(request) 56 | 57 | assert isinstance(response, HttpResponse) 58 | assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY 59 | assert json.loads(response.content)['detail'] 60 | -------------------------------------------------------------------------------- /tests/test_unit/test_endpoint/test_endpoint_future_annotations.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, TypeAlias 4 | 5 | from inline_snapshot import snapshot 6 | 7 | if TYPE_CHECKING: 8 | from django.http import HttpResponse 9 | 10 | _TypeCheckOnlyAlias: TypeAlias = dict[str, int] 11 | 12 | 13 | import pytest 14 | 15 | from django_modern_rest import Controller 16 | from django_modern_rest.exceptions import UnsolvableAnnotationsError 17 | from django_modern_rest.plugins.pydantic import PydanticSerializer 18 | 19 | _RegularAlias: TypeAlias = list[int] 20 | 21 | 22 | def test_unsolvable_annotations() -> None: 23 | """Ensure that we fail early when some annotations can't be solved.""" 24 | with pytest.raises(UnsolvableAnnotationsError, match='get'): 25 | 26 | class _Wrong(Controller[PydanticSerializer]): 27 | def get(self) -> _TypeCheckOnlyAlias: 28 | raise NotImplementedError 29 | 30 | 31 | def test_unsolvable_response_annotations() -> None: 32 | """Ensure that we fail early when some annotations can't be solved.""" 33 | with pytest.raises(UnsolvableAnnotationsError, match='get'): 34 | 35 | class _Wrong(Controller[PydanticSerializer]): 36 | def get(self) -> HttpResponse: 37 | raise NotImplementedError 38 | 39 | 40 | def test_solvable_response_annotations() -> None: 41 | """Ensure that string annotations still can be solved.""" 42 | 43 | class MyController(Controller[PydanticSerializer]): 44 | def get(self) -> _RegularAlias: 45 | raise NotImplementedError 46 | 47 | endpoint = MyController.api_endpoints['GET'] 48 | assert str(endpoint.response_validator.metadata.responses) == snapshot( 49 | '{: ResponseSpec(return_type=list[int], ' 50 | 'status_code=, headers=None, cookies=None)}', 51 | ) 52 | -------------------------------------------------------------------------------- /tests/test_unit/test_controllers/test_extend_controller.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar 2 | 3 | import pytest 4 | 5 | from django_modern_rest import Controller 6 | from django_modern_rest.exceptions import UnsolvableAnnotationsError 7 | from django_modern_rest.plugins.pydantic import PydanticSerializer 8 | from django_modern_rest.serialization import BaseSerializer 9 | 10 | _SerializerT = TypeVar('_SerializerT', bound=BaseSerializer) 11 | 12 | 13 | def test_controller_without_serializer() -> None: 14 | """Ensure that we need at least one type param for component.""" 15 | with pytest.raises(UnsolvableAnnotationsError, match='_Custom'): 16 | 17 | class _Custom(Controller): # type: ignore[type-arg] 18 | """Empty.""" 19 | 20 | 21 | def test_controller_generic_subclass() -> None: 22 | """Ensure that we can extend controllers with generics.""" 23 | 24 | class _Custom(Controller[_SerializerT]): 25 | """Empty.""" 26 | 27 | assert getattr(_Custom, 'api_endpoints', None) is None 28 | assert getattr(_Custom, 'serializer', None) is None 29 | assert getattr(_Custom, '_existing_http_methods', None) is None 30 | 31 | class _Final(_Custom[PydanticSerializer]): 32 | """Also empty, but not generic.""" 33 | 34 | assert _Final.serializer is PydanticSerializer 35 | assert _Final.api_endpoints == {} 36 | assert _Final._existing_http_methods == {} 37 | 38 | 39 | def test_controller_wrong_serializer() -> None: 40 | """Ensure that we must pass BaseSerializer types to controllers.""" 41 | with pytest.raises(UnsolvableAnnotationsError, match='BaseSerializer'): 42 | 43 | class _Custom(Controller[int]): # type: ignore[type-var] 44 | """Empty.""" 45 | 46 | 47 | def test_controller_empty() -> None: 48 | """Ensure that we can create empty controllers.""" 49 | 50 | class _Custom(Controller[PydanticSerializer]): 51 | """Empty.""" 52 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # All configuration for plugins and other utils is defined here. 2 | # Read more about `setup.cfg`: 3 | # https://docs.python.org/3.11/distutils/configfile.html 4 | 5 | [flake8] 6 | # Base flake8 configuration: 7 | # https://flake8.pycqa.org/en/latest/user/configuration.html 8 | format = wemake 9 | show-source = true 10 | statistics = false 11 | doctests = true 12 | 13 | # Plugins: 14 | max-complexity = 6 15 | max-line-length = 80 16 | max-imports = 25 17 | 18 | # We only run wemake-python-styleguide and system checks: 19 | select = WPS, E99 20 | 21 | # Allow logic in `__init__` 22 | ignore = WPS412 23 | 24 | # Excluding some directories: 25 | extend-exclude = 26 | .venv 27 | # We don't care about style in purposely junk code: 28 | typesafety 29 | # We don't care about style of benchmark apps: 30 | benchmarks 31 | # Artifacts: 32 | docs/_build 33 | 34 | # Ignoring some errors in some files: 35 | per-file-ignores = 36 | # Allow many imported names from a modules. Also allow wrong variables 37 | # names for to comply with the openapi convention (as `item`, `info`, etc): 38 | django_modern_rest/openapi/objects/*.py: WPS201, WPS110 39 | # We have complex modules with a lot of quircks: 40 | django_modern_rest/endpoint.py: WPS402 41 | # Our test client has some autogenerated code: 42 | django_modern_rest/test.py: WPS475, WPS110 43 | # Disable some lints for test settings: 44 | django_test_app/server/settings.py: WPS226, WPS407 45 | # Ignore autogenerated migrations: 46 | django_test_app/server/apps/*/migrations/*.py: WPS102, WPS301, WPS432, WPS458 47 | # Disable some complexity checks for tests: 48 | django_test_app/server/apps/*/views.py: WPS202 49 | # Enable string overuse tests, nested classes, and multiline strings: 50 | tests/*.py: WPS202, WPS204, WPS218, WPS226, WPS430, WPS431, WPS462 51 | # Enable to import annotations from __future__ for Sphinx extensions 52 | docs/tools/sphinx_ext/*.py: WPS201, WPS202 53 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/views.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import Any, ClassVar, final 3 | 4 | from django.http import HttpRequest, HttpResponse, HttpResponseBase 5 | from django.views import View 6 | from typing_extensions import override 7 | 8 | from django_modern_rest.openapi.converter import ConvertedSchema 9 | from django_modern_rest.openapi.renderers import BaseRenderer 10 | 11 | 12 | @final 13 | class OpenAPIView(View): 14 | """ 15 | View for rendering OpenAPI schema documentation. 16 | 17 | This view handles rendering of OpenAPI specifications using 18 | different renderers (JSON, Swagger UI, etc.). 19 | 20 | The view only supports ``GET`` requests and delegates actual rendering 21 | to a `BaseRenderer` instance provided via `as_view`. 22 | """ 23 | 24 | # Hack for preventing parent `as_view()` attributes validating 25 | renderer: ClassVar[BaseRenderer | None] = None 26 | schema: ClassVar[ConvertedSchema | None] = None 27 | 28 | def get(self, request: HttpRequest) -> HttpResponse: 29 | """Handle `GET` request and render the OpenAPI schema.""" 30 | if not isinstance(self.renderer, BaseRenderer): 31 | raise TypeError("Renderer must be a 'BaseRenderer' instance.") 32 | 33 | return self.renderer.render(request, self.schema) # type: ignore[arg-type] 34 | 35 | @override 36 | @classmethod 37 | def as_view( # type: ignore[override] 38 | cls, 39 | renderer: BaseRenderer, 40 | schema: ConvertedSchema, 41 | **initkwargs: Any, 42 | ) -> Callable[..., HttpResponseBase]: 43 | """ 44 | Create a view callable with OpenAPI configuration. 45 | 46 | This method extends Django's base `as_view()` to accept 47 | and configure OpenAPI-specific parameters before creating 48 | the view callable. 49 | """ 50 | return super().as_view(renderer=renderer, schema=schema, **initkwargs) 51 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/objects/components.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, final 3 | 4 | if TYPE_CHECKING: 5 | from django_modern_rest.openapi.objects.callback import Callback 6 | from django_modern_rest.openapi.objects.example import Example 7 | from django_modern_rest.openapi.objects.header import Header 8 | from django_modern_rest.openapi.objects.link import Link 9 | from django_modern_rest.openapi.objects.parameter import Parameter 10 | from django_modern_rest.openapi.objects.path_item import PathItem 11 | from django_modern_rest.openapi.objects.reference import Reference 12 | from django_modern_rest.openapi.objects.request_body import RequestBody 13 | from django_modern_rest.openapi.objects.response import Response 14 | from django_modern_rest.openapi.objects.schema import Schema 15 | from django_modern_rest.openapi.objects.security_scheme import ( 16 | SecurityScheme, 17 | ) 18 | 19 | 20 | @final 21 | @dataclass(frozen=True, kw_only=True, slots=True) 22 | class Components: 23 | """ 24 | Holds a set of reusable objects for different aspects of the OAS. 25 | 26 | All objects defined within the components object will have no effect 27 | on the API unless they are explicitly referenced from properties 28 | outside the components object. 29 | """ 30 | 31 | schemas: 'dict[str, Schema] | None' = None 32 | responses: 'dict[str, Response | Reference] | None' = None 33 | parameters: 'dict[str, Parameter | Reference] | None' = None 34 | examples: 'dict[str, Example | Reference] | None' = None 35 | request_bodies: 'dict[str, RequestBody | Reference] | None' = None 36 | headers: 'dict[str, Header | Reference] | None' = None 37 | security_schemes: 'dict[str, SecurityScheme | Reference] | None' = None 38 | links: 'dict[str, Link | Reference] | None' = None 39 | callbacks: 'dict[str, Callback | Reference] | None' = None 40 | path_items: 'dict[str, PathItem | Reference] | None' = None 41 | -------------------------------------------------------------------------------- /tests/test_integration/test_openapi/test_renderers.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import Final 3 | 4 | import pytest 5 | from django.urls import reverse 6 | 7 | from django_modern_rest.openapi.objects.open_api import _OPENAPI_VERSION 8 | from django_modern_rest.test import DMRClient 9 | 10 | _ENDPOINTS: Final = ( 11 | ('openapi:json', HTTPStatus.OK, 'application/json'), 12 | ('openapi:redoc', HTTPStatus.OK, 'text/html'), 13 | ('openapi:swagger', HTTPStatus.OK, 'text/html'), 14 | ('openapi:scalar', HTTPStatus.OK, 'text/html'), 15 | ) 16 | 17 | 18 | @pytest.mark.parametrize( 19 | ('endpoint_name', 'expected_status', 'expected_content_type'), 20 | _ENDPOINTS, 21 | ) 22 | def test_endpoints( 23 | dmr_client: DMRClient, 24 | *, 25 | endpoint_name: str, 26 | expected_status: int, 27 | expected_content_type: str, 28 | ) -> None: 29 | """Ensure that endpoints work.""" 30 | response = dmr_client.get(reverse(endpoint_name)) 31 | 32 | assert response.status_code == expected_status 33 | assert response.headers['Content-Type'] == expected_content_type 34 | 35 | 36 | @pytest.mark.parametrize( 37 | ('endpoint_name', 'expected_status'), 38 | [(endpoint[0], HTTPStatus.METHOD_NOT_ALLOWED) for endpoint in _ENDPOINTS], 39 | ) 40 | def test_wrong_method( 41 | dmr_client: DMRClient, 42 | *, 43 | endpoint_name: str, 44 | expected_status: int, 45 | ) -> None: 46 | """Ensure that wrong HTTP method is correctly handled.""" 47 | response = dmr_client.post(reverse(endpoint_name)) 48 | 49 | assert response.status_code == expected_status 50 | 51 | 52 | @pytest.mark.parametrize( 53 | 'endpoint_name', 54 | ['openapi:json'], 55 | ) 56 | def test_returns_correct_structure( 57 | dmr_client: DMRClient, 58 | *, 59 | endpoint_name: str, 60 | ) -> None: 61 | """Ensure that OpenAPI JSON endpoint returns correct structure.""" 62 | response = dmr_client.get(reverse(endpoint_name)) 63 | 64 | assert response.json()['openapi'] == _OPENAPI_VERSION 65 | -------------------------------------------------------------------------------- /tests/test_unit/test_controllers/test_customization/test_contoller_validator_customization.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, final 2 | 3 | from typing_extensions import override 4 | 5 | from django_modern_rest import Blueprint, Controller 6 | from django_modern_rest.plugins.pydantic import PydanticSerializer 7 | from django_modern_rest.serialization import BaseSerializer 8 | from django_modern_rest.validation import BlueprintValidator 9 | 10 | 11 | def test_custom_blueprint_validator_cls() -> None: 12 | """Ensure we can customize the blueprint validator factory.""" 13 | 14 | @final 15 | class _BlueprintValidatorSubclass(BlueprintValidator): 16 | # We can add a marker to track if this was called 17 | was_called: ClassVar[bool] = False 18 | 19 | @override 20 | def __call__(self, blueprint: type[Blueprint[BaseSerializer]]) -> None: 21 | self.__class__.was_called = True 22 | return super().__call__(blueprint) 23 | 24 | @final 25 | class _CustomValidatorBlueprint(Blueprint[PydanticSerializer]): 26 | blueprint_validator_cls: ClassVar[type[BlueprintValidator]] = ( 27 | _BlueprintValidatorSubclass 28 | ) 29 | 30 | assert _BlueprintValidatorSubclass.was_called 31 | 32 | 33 | def test_custom_controller_validator_cls() -> None: 34 | """Ensure we can customize the controller validator factory.""" 35 | 36 | @final 37 | class _BlueprintValidatorSubclass(BlueprintValidator): 38 | # We can add a marker to track if this was called 39 | was_called: ClassVar[bool] = False 40 | 41 | @override 42 | def __call__(self, blueprint: type[Blueprint[BaseSerializer]]) -> None: 43 | self.__class__.was_called = True 44 | return super().__call__(blueprint) 45 | 46 | @final 47 | class _CustomValidatorController(Controller[PydanticSerializer]): 48 | blueprint_validator_cls: ClassVar[type[BlueprintValidator]] = ( 49 | _BlueprintValidatorSubclass 50 | ) 51 | 52 | assert _BlueprintValidatorSubclass.was_called 53 | -------------------------------------------------------------------------------- /docs/examples/middleware/rate_limit.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from http import HTTPStatus 3 | from typing import ClassVar 4 | 5 | from django.http import HttpRequest, HttpResponse 6 | 7 | from django_modern_rest import Controller, ResponseSpec 8 | from django_modern_rest.decorators import wrap_middleware 9 | from django_modern_rest.plugins.pydantic import PydanticSerializer 10 | from django_modern_rest.response import build_response 11 | 12 | 13 | def rate_limit_middleware( 14 | get_response: Callable[[HttpRequest], HttpResponse], 15 | ) -> Callable[[HttpRequest], HttpResponse]: 16 | """Middleware that simulates rate limiting.""" 17 | 18 | def decorator(request: HttpRequest) -> HttpResponse: 19 | if request.headers.get('X-Rate-Limited') == 'true': 20 | return build_response( 21 | PydanticSerializer, 22 | raw_data={'detail': 'Rate limit exceeded'}, 23 | status_code=HTTPStatus.TOO_MANY_REQUESTS, 24 | ) 25 | return get_response(request) 26 | 27 | return decorator 28 | 29 | 30 | @wrap_middleware( 31 | rate_limit_middleware, 32 | ResponseSpec( 33 | return_type=dict[str, str], 34 | status_code=HTTPStatus.TOO_MANY_REQUESTS, 35 | ), 36 | ) 37 | def rate_limit_json(response: HttpResponse) -> HttpResponse: 38 | """Pass through the rate limit response.""" 39 | return response 40 | 41 | 42 | @rate_limit_json 43 | class RateLimitedController(Controller[PydanticSerializer]): 44 | """Example controller with custom rate limit middleware.""" 45 | 46 | responses: ClassVar[list[ResponseSpec]] = rate_limit_json.responses 47 | 48 | def post(self) -> dict[str, str]: 49 | return {'message': 'Request processed'} 50 | 51 | 52 | # run: {"controller": "RateLimitedController", "method": "post", "url": "/api/ratelimit/"} # noqa: ERA001, E501 53 | # run: {"controller": "RateLimitedController", "method": "post", "headers": {"X-Rate-Limited": "true"}, "url": "/api/ratelimit/", "curl_args": ["-D", "-"], "fail-with-body": false} # noqa: ERA001, E501 54 | -------------------------------------------------------------------------------- /tests/test_unit/test_plugins/test_msgspec/test_components.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | from http import HTTPStatus 4 | from typing import Annotated, final 5 | 6 | import pytest 7 | 8 | try: 9 | import msgspec 10 | except ImportError: # pragma: no cover 11 | pytest.skip(reason='msgspec is not installed', allow_module_level=True) 12 | 13 | from django.http import HttpResponse 14 | from faker import Faker 15 | 16 | from django_modern_rest import ( 17 | Controller, 18 | Headers, 19 | Query, 20 | ) 21 | from django_modern_rest.plugins.msgspec import MsgspecSerializer 22 | from django_modern_rest.test import DMRRequestFactory 23 | 24 | 25 | @final 26 | class _HeadersModel(msgspec.Struct): 27 | first_name: str 28 | 29 | 30 | @final 31 | class _QueryModel(msgspec.Struct): 32 | # queries are always lists: 33 | last_name: Annotated[list[str], msgspec.Meta(min_length=1, max_length=1)] 34 | 35 | 36 | @final 37 | class _ComponentController( 38 | Controller[MsgspecSerializer], 39 | Headers[_HeadersModel], 40 | Query[_QueryModel], 41 | ): 42 | def get(self) -> str: 43 | first_name = self.parsed_headers.first_name 44 | last_name = self.parsed_query.last_name[0] 45 | return f'{first_name} {last_name}' 46 | 47 | 48 | @pytest.mark.skipif( 49 | sys.version_info >= (3, 14), 50 | reason='3.14 does not fully support msgspec yet', 51 | ) 52 | def test_msgspec_components( 53 | dmr_rf: DMRRequestFactory, 54 | faker: Faker, 55 | ) -> None: 56 | """Ensures that regular parsing works.""" 57 | first_name = faker.name() 58 | last_name = faker.last_name() 59 | request = dmr_rf.get( 60 | f'/whatever/?last_name={last_name}', 61 | data={}, 62 | headers={'first_name': first_name}, 63 | ) 64 | 65 | response = _ComponentController.as_view()(request) 66 | 67 | assert isinstance(response, HttpResponse) 68 | assert response.status_code == HTTPStatus.OK, response.content 69 | assert response.headers == {'Content-Type': 'application/json'} 70 | assert json.loads(response.content) == f'{first_name} {last_name}' 71 | -------------------------------------------------------------------------------- /tests/test_unit/test_components/test_type_params.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_modern_rest import Body, Controller, Headers, Query 4 | from django_modern_rest.exceptions import EndpointMetadataError 5 | from django_modern_rest.plugins.pydantic import PydanticSerializer 6 | 7 | 8 | def test_validate_components_type_params() -> None: 9 | """Ensure that we need at least one type param for component.""" 10 | for component_cls in (Headers, Body, Query): 11 | with pytest.raises(TypeError): 12 | component_cls[*()] # pyright: ignore[reportInvalidTypeArguments] 13 | 14 | for component_cls in (Headers, Body, Query): 15 | with pytest.raises(TypeError): 16 | component_cls[int, str] # pyright: ignore[reportInvalidTypeArguments] 17 | 18 | 19 | def test_validate_headers_zero_params() -> None: 20 | """Ensure that we need at least one type param for component.""" 21 | with pytest.raises(EndpointMetadataError, match='_WrongHeaders'): 22 | 23 | class _WrongHeaders( 24 | Headers, # type: ignore[type-arg] 25 | Controller[PydanticSerializer], 26 | ): 27 | def get(self) -> dict[str, str]: 28 | raise NotImplementedError 29 | 30 | 31 | def test_validate_body_zero_params() -> None: 32 | """Ensure that we need at least one type param for component.""" 33 | with pytest.raises(EndpointMetadataError, match='_WrongBody'): 34 | 35 | class _WrongBody( 36 | Body, # type: ignore[type-arg] 37 | Controller[PydanticSerializer], 38 | ): 39 | def get(self) -> dict[str, str]: 40 | raise NotImplementedError 41 | 42 | 43 | def test_validate_query_zero_params() -> None: 44 | """Ensure that we need at least one type param for component.""" 45 | with pytest.raises(EndpointMetadataError, match='_WrongQuery'): 46 | 47 | class _WrongQuery( 48 | Query, # type: ignore[type-arg] 49 | Controller[PydanticSerializer], 50 | ): 51 | def get(self) -> dict[str, str]: 52 | raise NotImplementedError 53 | -------------------------------------------------------------------------------- /docs/examples/middleware/built_in_decorators.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import ClassVar, final 3 | 4 | from django.contrib.auth.decorators import login_required 5 | from django.http import HttpResponse 6 | 7 | from django_modern_rest import Controller, ResponseSpec 8 | from django_modern_rest.decorators import wrap_middleware 9 | from django_modern_rest.plugins.pydantic import PydanticSerializer 10 | from django_modern_rest.response import build_response 11 | 12 | 13 | @wrap_middleware( 14 | login_required, 15 | ResponseSpec( 16 | return_type=dict[str, str], 17 | status_code=HTTPStatus.FOUND, 18 | ), 19 | ResponseSpec( # Uses for proxy authed response with HTTPStatus.OK 20 | return_type=dict[str, str], 21 | status_code=HTTPStatus.OK, 22 | ), 23 | ) 24 | def login_required_json(response: HttpResponse) -> HttpResponse: 25 | """Convert Django's login_required redirect to JSON 401 response.""" 26 | if response.status_code == HTTPStatus.FOUND: 27 | return build_response( 28 | PydanticSerializer, 29 | raw_data={'detail': 'Authentication credentials were not provided'}, 30 | status_code=HTTPStatus.UNAUTHORIZED, 31 | ) 32 | return response 33 | 34 | 35 | @final 36 | @login_required_json 37 | class LoginRequiredController(Controller[PydanticSerializer]): 38 | """Controller that uses Django's login_required decorator. 39 | 40 | Demonstrates wrapping Django's built-in authentication decorators. 41 | Converts 302 redirect to JSON 401 response for REST API compatibility. 42 | """ 43 | 44 | responses: ClassVar[list[ResponseSpec]] = login_required_json.responses 45 | 46 | def get(self) -> dict[str, str]: 47 | """GET endpoint that requires Django authentication.""" 48 | # Access Django's authenticated user 49 | user = self.request.user 50 | username = user.username if user.is_authenticated else 'anonymous' # type: ignore[attr-defined] 51 | 52 | return { 53 | 'username': username, 54 | 'message': 'Successfully accessed protected resource', 55 | } 56 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/python-jsonschema/check-jsonschema 3 | rev: 0.35.0 4 | hooks: 5 | - id: check-dependabot 6 | - id: check-github-workflows 7 | - id: check-github-actions 8 | - id: check-readthedocs 9 | 10 | - repo: https://github.com/rhysd/actionlint 11 | rev: v1.7.9 12 | hooks: 13 | - id: actionlint 14 | additional_dependencies: 15 | - "github.com/wasilibs/go-shellcheck/cmd/shellcheck@latest" 16 | 17 | - repo: https://github.com/woodruffw/zizmor-pre-commit 18 | rev: v1.18.0 19 | hooks: 20 | - id: zizmor 21 | args: ["--no-progress", "--fix"] 22 | 23 | - repo: https://github.com/shellcheck-py/shellcheck-py 24 | rev: v0.11.0.1 25 | hooks: 26 | - id: shellcheck 27 | args: ["--severity=style"] 28 | - repo: https://github.com/astral-sh/ruff-pre-commit 29 | rev: v0.14.9 30 | hooks: 31 | - id: ruff 32 | args: ["--exit-non-zero-on-fix"] 33 | - id: ruff-format 34 | 35 | - repo: https://github.com/sphinx-contrib/sphinx-lint 36 | rev: v1.0.2 37 | hooks: 38 | - id: sphinx-lint 39 | args: ["--enable=all", "--disable=line-too-long"] 40 | files: ^docs/ 41 | 42 | - repo: https://github.com/EbodShojaei/bake 43 | rev: v1.4.3 44 | hooks: 45 | - id: mbake-format 46 | args: ["--config=.github/bake.toml"] 47 | - id: mbake-validate 48 | 49 | # Should be applied after all autofixes: 50 | - repo: https://github.com/pre-commit/pre-commit-hooks 51 | rev: v6.0.0 52 | hooks: 53 | - id: trailing-whitespace 54 | - id: end-of-file-fixer 55 | - id: check-yaml 56 | - id: check-toml 57 | - id: check-xml 58 | - id: check-merge-conflict 59 | - id: check-symlinks 60 | - id: check-illegal-windows-names 61 | - id: mixed-line-ending 62 | args: ["--fix=lf"] 63 | - id: check-case-conflict 64 | 65 | # Should be the last: 66 | - repo: meta 67 | hooks: 68 | - id: check-useless-excludes 69 | 70 | ci: 71 | autofix_prs: true 72 | autoupdate_schedule: weekly 73 | submodules: false 74 | -------------------------------------------------------------------------------- /django_modern_rest/validation/blueprint.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, get_args 2 | 3 | from django_modern_rest.components import ComponentParser 4 | from django_modern_rest.exceptions import EndpointMetadataError 5 | from django_modern_rest.serialization import BaseSerializer 6 | from django_modern_rest.types import infer_bases 7 | 8 | if TYPE_CHECKING: 9 | from django_modern_rest.controller import Blueprint 10 | 11 | 12 | class BlueprintValidator: 13 | """ 14 | Validate blueprint type definition. 15 | 16 | Validates: 17 | 18 | - Meta mixins 19 | - Components definition 20 | 21 | We don't validate complex stuff before creating a controller. 22 | """ 23 | 24 | __slots__ = () 25 | 26 | def __call__(self, blueprint: type['Blueprint[BaseSerializer]']) -> None: 27 | """Run the validation.""" 28 | self._validate_meta_mixins(blueprint) 29 | self._validate_components(blueprint) 30 | 31 | def _validate_meta_mixins( 32 | self, 33 | blueprint: type['Blueprint[BaseSerializer]'], 34 | ) -> None: 35 | from django_modern_rest.options_mixins import ( # noqa: PLC0415 36 | AsyncMetaMixin, 37 | MetaMixin, 38 | ) 39 | 40 | if ( 41 | issubclass(blueprint, MetaMixin) # type: ignore[unreachable] 42 | and issubclass(blueprint, AsyncMetaMixin) # type: ignore[unreachable] 43 | ): 44 | raise EndpointMetadataError( 45 | f'Use only one mixin, not both meta mixins in {blueprint!r}', 46 | ) 47 | 48 | def _validate_components( 49 | self, 50 | blueprint: type['Blueprint[BaseSerializer]'], 51 | ) -> None: 52 | possible_violations = infer_bases( 53 | blueprint, 54 | ComponentParser, 55 | use_origin=False, 56 | ) 57 | for component_cls in possible_violations: 58 | if not get_args(component_cls): 59 | raise EndpointMetadataError( 60 | f'Component {component_cls!r} in {blueprint!r} ' 61 | 'must have 1 type argument, given 0', 62 | ) 63 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | ## Dependencies 4 | 5 | We use [poetry](https://github.com/python-poetry/poetry) to manage the dependencies. 6 | 7 | To install them you would need to run `install` command: 8 | 9 | ```bash 10 | poetry install --all-extras --all-groups 11 | ``` 12 | 13 | To activate your `virtualenv` run `eval "$(poetry env activate)"` or `source .venv/bin/activate`. 14 | 15 | ## One magic command 16 | 17 | Run `make test` to run everything we have! 18 | 19 | ## Tests 20 | 21 | To run tests: 22 | 23 | ```bash 24 | make unit 25 | ``` 26 | 27 | To run linting: 28 | 29 | ```bash 30 | make lint 31 | ``` 32 | 33 | These steps are mandatory during the CI. 34 | 35 | ## Submitting your code 36 | 37 | We use [trunk based](https://trunkbaseddevelopment.com/) 38 | development (we also sometimes call it `wemake-git-flow`). 39 | 40 | What the point of this method? 41 | 42 | 1. We use protected `master` branch, 43 | so the only way to push your code is via pull request 44 | 2. We use issue branches: to implement a new feature or to fix a bug 45 | create a new branch named `issue-$TASKNUMBER` 46 | 3. Then create a pull request to `master` branch 47 | 4. We use `git tag`s to make releases, so we can track what has changed 48 | since the latest release 49 | 50 | So, this way we achieve an easy and scalable development process 51 | which frees us from merging hell and long-living branches. 52 | 53 | In this method, the latest version of the app is always in the `master` branch. 54 | 55 | ### Before submitting 56 | 57 | Before submitting your code please do the following steps: 58 | 59 | 1. Run `make test` to make sure everything was working before 60 | 2. Add any changes you want 61 | 3. Add tests for the new changes 62 | 4. Edit documentation if you have changed something significant 63 | 5. Update `CHANGELOG.md` with a quick summary of your changes 64 | 6. Run `make test` again to make sure it is still working 65 | 66 | ## Other help 67 | 68 | You can contribute by spreading a word about this library. 69 | It would also be a huge contribution to write 70 | a short article on how you are using this project. 71 | You can also share your best practices with us. 72 | -------------------------------------------------------------------------------- /django_test_app/server/apps/middlewares/middleware.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from collections.abc import Callable 3 | from http import HTTPStatus 4 | from typing import Any, TypeAlias 5 | 6 | from django.http import HttpRequest, HttpResponse 7 | 8 | from django_modern_rest.plugins.pydantic import PydanticSerializer 9 | from django_modern_rest.response import build_response 10 | 11 | _CallableAny: TypeAlias = Callable[..., Any] 12 | 13 | 14 | def custom_header_middleware( 15 | get_response: Callable[[HttpRequest], HttpResponse], 16 | ) -> _CallableAny: 17 | """Simple middleware that adds a custom header to response.""" 18 | 19 | def decorator(request: HttpRequest) -> Any: 20 | response = get_response(request) 21 | response['X-Custom-Header'] = 'CustomValue' 22 | return response 23 | 24 | return decorator 25 | 26 | 27 | def rate_limit_middleware( 28 | get_response: Callable[[HttpRequest], HttpResponse], 29 | ) -> _CallableAny: 30 | """Middleware that simulates rate limiting.""" 31 | 32 | def decorator(request: HttpRequest) -> Any: 33 | if request.headers.get('X-Rate-Limited') == 'true': 34 | return build_response( 35 | PydanticSerializer, 36 | raw_data={'detail': 'Rate limit exceeded'}, 37 | status_code=HTTPStatus.TOO_MANY_REQUESTS, 38 | ) 39 | return get_response(request) 40 | 41 | return decorator 42 | 43 | 44 | def add_request_id_middleware( 45 | get_response: Callable[[HttpRequest], HttpResponse], 46 | ) -> _CallableAny: 47 | """Middleware that adds request_id to both request and response. 48 | 49 | This demonstrates the two-phase middleware pattern: 50 | 1. Process request BEFORE calling get_response (adds request.request_id) 51 | 2. Process response AFTER calling get_response (adds X-Request-ID header) 52 | """ 53 | 54 | def decorator(request: HttpRequest) -> Any: 55 | request_id = uuid.uuid4().hex 56 | request.request_id = request_id # type: ignore[attr-defined] 57 | 58 | response = get_response(request) 59 | 60 | response['X-Request-ID'] = request_id 61 | 62 | return response 63 | 64 | return decorator 65 | -------------------------------------------------------------------------------- /benchmarks/apps/fastapi.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import decimal 3 | import enum 4 | import uuid 5 | from typing import Annotated 6 | 7 | import fastapi 8 | import pydantic 9 | 10 | async_app = fastapi.FastAPI() 11 | sync_app = fastapi.FastAPI() 12 | 13 | 14 | class Level(enum.StrEnum): 15 | started = 'starter' 16 | mid = 'mid' 17 | pro = 'pro' 18 | 19 | 20 | class Skill(pydantic.BaseModel): 21 | name: str 22 | description: str 23 | optional: bool 24 | level: Level 25 | 26 | 27 | class Item(pydantic.BaseModel): 28 | name: str 29 | quality: int 30 | count: int 31 | rarety: int 32 | parts: list['Item'] 33 | 34 | 35 | class UserCreateModel(pydantic.BaseModel): 36 | email: str 37 | age: int 38 | height: float 39 | average_score: float 40 | balance: decimal.Decimal 41 | skills: list[Skill] 42 | aliases: dict[str, str | int] 43 | birthday: dt.datetime 44 | timezone_diff: dt.timedelta 45 | friends: list['UserModel'] 46 | best_friend: 'UserModel | None' 47 | promocodes: list[uuid.UUID] 48 | items: list[Item] 49 | 50 | 51 | class UserModel(UserCreateModel): 52 | uid: uuid.UUID 53 | 54 | 55 | class QueryModel(pydantic.BaseModel): 56 | per_page: int 57 | count: int 58 | page: int 59 | filter: list[str] 60 | 61 | 62 | class HeadersModel(pydantic.BaseModel): 63 | x_api_token: str 64 | x_request_origin: str 65 | 66 | 67 | @async_app.post('/async/user/') 68 | async def async_post( 69 | data: UserCreateModel, 70 | filters: Annotated[QueryModel, fastapi.Query()], 71 | headers: Annotated[HeadersModel, fastapi.Header()], 72 | ) -> UserModel: 73 | assert filters.filter[0] == 'fastapi', filters.filter 74 | return UserModel( 75 | uid=uuid.uuid4(), 76 | **data.model_dump(), 77 | ) 78 | 79 | 80 | @sync_app.post('/sync/user/') 81 | def sync_post( 82 | data: UserCreateModel, 83 | filters: Annotated[QueryModel, fastapi.Query()], 84 | headers: Annotated[HeadersModel, fastapi.Header()], 85 | ) -> UserModel: 86 | assert filters.filter[0] == 'fastapi', filters.filter 87 | return UserModel( 88 | uid=uuid.uuid4(), 89 | **data.model_dump(), 90 | ) 91 | -------------------------------------------------------------------------------- /django_modern_rest/validation/payload.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from collections.abc import Mapping, Set 3 | from http import HTTPStatus 4 | from typing import TYPE_CHECKING, TypeAlias 5 | 6 | from django_modern_rest.cookies import NewCookie 7 | from django_modern_rest.errors import AsyncErrorHandlerT, SyncErrorHandlerT 8 | from django_modern_rest.headers import NewHeader 9 | from django_modern_rest.response import ResponseSpec 10 | from django_modern_rest.settings import HttpSpec 11 | 12 | if TYPE_CHECKING: 13 | from django_modern_rest.openapi.objects import ( 14 | Callback, 15 | ExternalDocumentation, 16 | Reference, 17 | SecurityRequirement, 18 | Server, 19 | ) 20 | 21 | 22 | @dataclasses.dataclass(slots=True, frozen=True, kw_only=True, init=False) 23 | class _BasePayload: 24 | # OpenAPI stuff: 25 | summary: str | None = None 26 | description: str | None = None 27 | tags: list[str] | None = None 28 | operation_id: str | None = None 29 | deprecated: bool = False 30 | security: list['SecurityRequirement'] | None = None 31 | external_docs: 'ExternalDocumentation | None' = None 32 | callbacks: 'dict[str, Callback | Reference] | None' = None 33 | servers: list['Server'] | None = None 34 | 35 | # Common fields: 36 | validate_responses: bool | None = None 37 | error_handler: SyncErrorHandlerT | AsyncErrorHandlerT | None = None 38 | allow_custom_http_methods: bool = False 39 | no_validate_http_spec: Set[HttpSpec] | None = None 40 | 41 | 42 | @dataclasses.dataclass(slots=True, frozen=True, kw_only=True) 43 | class ValidateEndpointPayload(_BasePayload): 44 | """Payload created by ``@validate``.""" 45 | 46 | responses: list[ResponseSpec] 47 | 48 | 49 | @dataclasses.dataclass(slots=True, frozen=True, kw_only=True) 50 | class ModifyEndpointPayload(_BasePayload): 51 | """Payload created by ``@modify``.""" 52 | 53 | status_code: HTTPStatus | None 54 | headers: Mapping[str, NewHeader] | None 55 | cookies: Mapping[str, NewCookie] | None 56 | responses: list[ResponseSpec] | None 57 | 58 | 59 | #: Alias for different payload types: 60 | PayloadT: TypeAlias = ValidateEndpointPayload | ModifyEndpointPayload | None 61 | -------------------------------------------------------------------------------- /tests/test_unit/test_plugins/test_msgspec/test_serializers.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import final 3 | 4 | import pytest 5 | from django.http import HttpResponse 6 | from faker import Faker 7 | 8 | try: 9 | import msgspec 10 | except ImportError: # pragma: no cover 11 | pytest.skip(reason='msgspec is not installed', allow_module_level=True) 12 | 13 | 14 | from django_modern_rest import Body, Controller 15 | from django_modern_rest.plugins.msgspec import MsgspecSerializer 16 | from django_modern_rest.test import DMRRequestFactory 17 | 18 | 19 | class _ForTestError(Exception): 20 | """Testing as custom error from built-in exception.""" 21 | 22 | 23 | class _ForTestMsgSpecError(msgspec.ValidationError): 24 | """Testing as custom error from msgspec.ValidationError.""" 25 | 26 | 27 | class _MsgSpecUserModel(msgspec.Struct): 28 | email: str 29 | 30 | 31 | @final 32 | class _UserController(Controller[MsgspecSerializer], Body[_MsgSpecUserModel]): 33 | def get(self) -> _MsgSpecUserModel: 34 | return _MsgSpecUserModel(email='email@test.edu') 35 | 36 | def post(self) -> _MsgSpecUserModel: 37 | return _MsgSpecUserModel(email=self.parsed_body.email) 38 | 39 | 40 | def test_serializer_via_endpoint( 41 | dmr_rf: DMRRequestFactory, 42 | faker: Faker, 43 | ) -> None: 44 | """Try to serialize via endpoint.""" 45 | email = faker.email() 46 | post_request = dmr_rf.post('/whatever/', data={'email': email}) 47 | response = _UserController.as_view()(post_request) 48 | 49 | assert isinstance(response, HttpResponse) 50 | assert response.status_code == HTTPStatus.CREATED, f'post: {response=}' 51 | 52 | 53 | @pytest.mark.parametrize( 54 | ('err', 'is_raise'), 55 | [ 56 | ('', False), 57 | (Exception(), True), 58 | (_ForTestError(), True), 59 | (msgspec.ValidationError(), False), 60 | (_ForTestMsgSpecError(), False), 61 | (1, True), 62 | ], 63 | ) 64 | def test_serialize_errors_types( 65 | err: str | Exception, 66 | is_raise: bool, # noqa: FBT001 67 | ) -> None: 68 | """Ensures that MsgspecSerializer can serialize errors.""" 69 | if is_raise: 70 | with pytest.raises(NotImplementedError): 71 | MsgspecSerializer().error_serialize(err) 72 | else: 73 | assert MsgspecSerializer().error_serialize(err) 74 | -------------------------------------------------------------------------------- /tests/test_unit/test_openapi/test_views.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import Final 3 | 4 | import pytest 5 | from django.http import HttpResponse 6 | 7 | from django_modern_rest.openapi.converter import ConvertedSchema 8 | from django_modern_rest.openapi.objects.open_api import _OPENAPI_VERSION 9 | from django_modern_rest.openapi.renderers import JsonRenderer 10 | from django_modern_rest.openapi.views import OpenAPIView 11 | from django_modern_rest.test import DMRRequestFactory 12 | 13 | _TEST_SCHEMA: Final[ConvertedSchema] = { # noqa: WPS407 14 | 'openapi': _OPENAPI_VERSION, 15 | 'info': {'title': 'Test', 'version': '1.0.0'}, 16 | 'paths': {}, 17 | } 18 | _TEST_PATH: Final[str] = 'test/' 19 | 20 | 21 | def test_get_with_valid_renderer(dmr_rf: DMRRequestFactory) -> None: 22 | """Ensure that GET request works with valid renderer and schema.""" 23 | view = OpenAPIView.as_view( 24 | renderer=JsonRenderer(path=_TEST_PATH), 25 | schema=_TEST_SCHEMA, 26 | ) 27 | response = view(dmr_rf.get(_TEST_PATH)) 28 | 29 | assert isinstance(response, HttpResponse) 30 | assert response.status_code == HTTPStatus.OK 31 | 32 | 33 | def test_invalid_renderer_raises_error(dmr_rf: DMRRequestFactory) -> None: 34 | """Ensure that GET request raises TypeError when renderer is invalid.""" 35 | view_cls = OpenAPIView 36 | view_cls.renderer = 'not a renderer' # type: ignore[assignment] 37 | view_cls.schema = _TEST_SCHEMA 38 | 39 | with pytest.raises( 40 | TypeError, 41 | match="Renderer must be a 'BaseRenderer' instance", 42 | ): 43 | view_cls().get(dmr_rf.get(_TEST_PATH)) 44 | 45 | 46 | @pytest.mark.parametrize( 47 | 'http_method', 48 | [ 49 | 'post', 50 | 'put', 51 | 'patch', 52 | 'delete', 53 | 'trace', 54 | ], 55 | ) 56 | def test_only_get_method_allowed( 57 | dmr_rf: DMRRequestFactory, 58 | *, 59 | http_method: str, 60 | ) -> None: 61 | """Ensure that only GET method is allowed.""" 62 | view = OpenAPIView.as_view( 63 | renderer=JsonRenderer(path=_TEST_PATH), 64 | schema=_TEST_SCHEMA, 65 | ) 66 | request_factory_method = getattr(dmr_rf, http_method) 67 | request = request_factory_method(_TEST_PATH) 68 | response = view(request) 69 | 70 | assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED 71 | -------------------------------------------------------------------------------- /tests/test_unit/test_controllers/test_collect_metadata.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from inline_snapshot import snapshot 4 | 5 | from django_modern_rest import ( 6 | Blueprint, 7 | Controller, 8 | ResponseSpec, 9 | modify, 10 | ) 11 | from django_modern_rest.plugins.pydantic import PydanticSerializer 12 | 13 | 14 | class _Blueprint(Blueprint[PydanticSerializer]): 15 | responses = [ 16 | ResponseSpec(float, status_code=HTTPStatus.RESET_CONTENT), 17 | ] 18 | 19 | @modify( 20 | extra_responses=[ResponseSpec(bool, status_code=HTTPStatus.CREATED)], 21 | ) 22 | def put(self) -> str: 23 | raise NotImplementedError 24 | 25 | 26 | class _Controller(Controller[PydanticSerializer]): 27 | responses = [ 28 | ResponseSpec(int, status_code=HTTPStatus.ACCEPTED), 29 | ] 30 | blueprints = [_Blueprint] 31 | 32 | @modify( 33 | extra_responses=[ 34 | ResponseSpec( 35 | complex, 36 | status_code=HTTPStatus.NON_AUTHORITATIVE_INFORMATION, 37 | ), 38 | ], 39 | ) 40 | def get(self) -> str: 41 | raise NotImplementedError 42 | 43 | 44 | def test_collected_responses() -> None: 45 | """Ensure that responses are corrected correctly.""" 46 | assert _Controller.api_endpoints['PUT'].metadata.responses == snapshot({ 47 | HTTPStatus.OK: ResponseSpec(return_type=str, status_code=HTTPStatus.OK), 48 | HTTPStatus.CREATED: ResponseSpec( 49 | return_type=bool, 50 | status_code=HTTPStatus.CREATED, 51 | ), 52 | HTTPStatus.RESET_CONTENT: ResponseSpec( 53 | return_type=float, 54 | status_code=HTTPStatus.RESET_CONTENT, 55 | ), 56 | HTTPStatus.ACCEPTED: ResponseSpec( 57 | return_type=int, 58 | status_code=HTTPStatus.ACCEPTED, 59 | ), 60 | }) 61 | assert _Controller.api_endpoints['GET'].metadata.responses == snapshot({ 62 | HTTPStatus.OK: ResponseSpec(return_type=str, status_code=HTTPStatus.OK), 63 | HTTPStatus.NON_AUTHORITATIVE_INFORMATION: ResponseSpec( 64 | return_type=complex, 65 | status_code=HTTPStatus.NON_AUTHORITATIVE_INFORMATION, 66 | ), 67 | HTTPStatus.ACCEPTED: ResponseSpec( 68 | return_type=int, 69 | status_code=HTTPStatus.ACCEPTED, 70 | ), 71 | }) 72 | -------------------------------------------------------------------------------- /tests/test_unit/test_decorators/test_endpoint_decorator.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import final 3 | 4 | import pytest 5 | from django.contrib.auth.decorators import login_required 6 | from django.contrib.auth.models import AnonymousUser, User 7 | from django.http import HttpResponse 8 | 9 | from django_modern_rest import ( 10 | Controller, 11 | HeaderSpec, 12 | ResponseSpec, 13 | modify, 14 | ) 15 | from django_modern_rest.decorators import endpoint_decorator 16 | from django_modern_rest.plugins.pydantic import PydanticSerializer 17 | from django_modern_rest.test import DMRRequestFactory 18 | 19 | 20 | @final 21 | class _MyController(Controller[PydanticSerializer]): 22 | @endpoint_decorator(login_required()) 23 | @modify( 24 | extra_responses=[ 25 | ResponseSpec( 26 | None, 27 | status_code=HTTPStatus.FOUND, 28 | headers={'Location': HeaderSpec()}, 29 | ), 30 | ], 31 | ) 32 | def get(self) -> str: 33 | return 'Logged in!' 34 | 35 | def put(self) -> str: 36 | return 'No login' 37 | 38 | 39 | @pytest.mark.parametrize( 40 | ('user', 'status_code'), 41 | [ 42 | (AnonymousUser(), HTTPStatus.FOUND), 43 | (User(), HTTPStatus.OK), 44 | ], 45 | ) 46 | def test_login_required_get( 47 | dmr_rf: DMRRequestFactory, 48 | *, 49 | user: User | AnonymousUser, 50 | status_code: HTTPStatus, 51 | ) -> None: 52 | """Ensures that ``get`` works and authed user is required.""" 53 | request = dmr_rf.get('/whatever/') 54 | request.user = user 55 | 56 | response = _MyController.as_view()(request) 57 | 58 | assert isinstance(response, HttpResponse) 59 | assert response.status_code == status_code, response.content 60 | 61 | 62 | @pytest.mark.parametrize( 63 | ('user', 'status_code'), 64 | [ 65 | (AnonymousUser(), HTTPStatus.OK), 66 | (User(), HTTPStatus.OK), 67 | ], 68 | ) 69 | def test_login_not_required_put( 70 | dmr_rf: DMRRequestFactory, 71 | *, 72 | user: User | AnonymousUser, 73 | status_code: HTTPStatus, 74 | ) -> None: 75 | """Ensures that ``put`` works and authed user is not required.""" 76 | request = dmr_rf.put('/whatever/') 77 | request.user = user 78 | 79 | response = _MyController.as_view()(request) 80 | 81 | assert isinstance(response, HttpResponse) 82 | assert response.status_code == status_code, response.content 83 | -------------------------------------------------------------------------------- /tests/test_unit/test_endpoint/test_request_invalid.py: -------------------------------------------------------------------------------- 1 | import json 2 | from http import HTTPStatus 3 | from typing import final 4 | 5 | import pydantic 6 | import pytest 7 | from django.http import HttpResponse 8 | from django.test import RequestFactory 9 | from faker import Faker 10 | from inline_snapshot import snapshot 11 | 12 | from django_modern_rest import Body, Controller 13 | from django_modern_rest.exceptions import UnsolvableAnnotationsError 14 | from django_modern_rest.plugins.pydantic import PydanticSerializer 15 | 16 | 17 | @final 18 | class _MyPydanticModel(pydantic.BaseModel): 19 | age: int 20 | 21 | 22 | @final 23 | class _WrongPydanticBodyController( 24 | Controller[PydanticSerializer], 25 | Body[_MyPydanticModel], 26 | ): 27 | """All body of these methods are not correct.""" 28 | 29 | def post(self) -> str: # pragma: no cover 30 | """Does not respect a body type.""" 31 | return 'done' # not an exception for a better test clarity 32 | 33 | 34 | def test_invalid_request_body(rf: RequestFactory, faker: Faker) -> None: 35 | """Ensures that request body validation works for default settings.""" 36 | request = rf.post( 37 | '/whatever/', 38 | data={'age': faker.random_int()}, # wrong content-type 39 | ) 40 | 41 | response = _WrongPydanticBodyController.as_view()(request) 42 | 43 | assert isinstance(response, HttpResponse) 44 | assert response.status_code == HTTPStatus.BAD_REQUEST, response.content 45 | assert json.loads(response.content) == snapshot({ 46 | 'detail': ([ 47 | { 48 | 'type': 'value_error', 49 | 'loc': [], 50 | 'msg': ( 51 | 'Value error, Cannot parse request body with content type ' 52 | "'multipart/form-data', expected 'application/json'" 53 | ), 54 | 'input': '', 55 | 'ctx': { 56 | 'error': ( 57 | 'Cannot parse request body with content type ' 58 | "'multipart/form-data', expected 'application/json'" 59 | ), 60 | }, 61 | }, 62 | ]), 63 | }) 64 | 65 | 66 | def test_missing_function_return_annotation() -> None: 67 | """Ensure that they are required.""" 68 | with pytest.raises( 69 | UnsolvableAnnotationsError, 70 | match='return type annotation', 71 | ): 72 | 73 | class _MissingReturnController(Controller[PydanticSerializer]): 74 | def get(self): # type: ignore[no-untyped-def] 75 | """Does not respect a body type.""" 76 | -------------------------------------------------------------------------------- /django_modern_rest/openapi/spec.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | 3 | from django.urls import URLPattern 4 | 5 | from django_modern_rest.openapi.config import OpenAPIConfig 6 | from django_modern_rest.openapi.converter import ( 7 | ConvertedSchema, 8 | SchemaConverter, 9 | ) 10 | from django_modern_rest.openapi.core.builder import OpenApiBuilder 11 | from django_modern_rest.openapi.core.context import OpenAPIContext 12 | from django_modern_rest.openapi.renderers import BaseRenderer 13 | from django_modern_rest.openapi.views import OpenAPIView 14 | from django_modern_rest.routing import Router, path 15 | 16 | 17 | def openapi_spec( 18 | router: Router, 19 | renderers: Sequence[BaseRenderer], 20 | config: OpenAPIConfig | None = None, 21 | app_name: str = 'openapi', 22 | namespace: str = 'docs', 23 | ) -> tuple[list[URLPattern], str, str]: 24 | """ 25 | Generate OpenAPI specification for API documentation. 26 | 27 | Rendering OpenAPI documentation using the provided renderers. 28 | The function generates an OpenAPI schema from the router's endpoints 29 | and creates views for each renderer. 30 | """ 31 | if len(renderers) == 0: 32 | raise ValueError( 33 | 'Empty renderers sequence provided to `openapi_spec()`. ' 34 | 'At least one renderer must be specified to ' 35 | 'render the API documentation.', 36 | ) 37 | 38 | schema = _build_schema(config or _default_config(), router) 39 | 40 | urlpatterns: list[URLPattern] = [] 41 | for renderer in renderers: 42 | view = OpenAPIView.as_view(renderer=renderer, schema=schema) 43 | if renderer.decorators: 44 | for decorator in renderer.decorators: 45 | view = decorator(view) 46 | 47 | urlpatterns.append(path(renderer.path, view, name=renderer.name)) 48 | 49 | return (urlpatterns, app_name, namespace) 50 | 51 | 52 | def _default_config() -> OpenAPIConfig: 53 | from django_modern_rest.settings import ( # noqa: PLC0415 54 | Settings, 55 | resolve_setting, 56 | ) 57 | 58 | config = resolve_setting(Settings.openapi_config) 59 | if not isinstance(config, OpenAPIConfig): 60 | raise TypeError( 61 | 'OpenAPI config is not set. Please, set the ' 62 | f'{str(Settings.openapi_config)!r} setting.', 63 | ) 64 | return config 65 | 66 | 67 | def _build_schema(config: OpenAPIConfig, router: Router) -> ConvertedSchema: 68 | # TODO: refactor 69 | context = OpenAPIContext(config=config) 70 | schema = OpenApiBuilder(context).build(router) 71 | return SchemaConverter.convert(schema) 72 | --------------------------------------------------------------------------------