├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md ├── dependabot.yml └── workflows │ ├── build_docs.yml │ ├── docs.yml │ ├── publish.yml │ ├── test.yml │ └── test_full.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── docs ├── docs │ ├── differences.md │ ├── extra.css │ ├── guides │ │ ├── api-docs.md │ │ ├── async-support.md │ │ ├── authentication.md │ │ ├── errors.md │ │ ├── input │ │ │ ├── body.md │ │ │ ├── file-params.md │ │ │ ├── filtering.md │ │ │ ├── form-params.md │ │ │ ├── operations.md │ │ │ ├── path-params.md │ │ │ ├── query-params.md │ │ │ └── request-parsers.md │ │ ├── response │ │ │ ├── config-pydantic.md │ │ │ ├── django-pydantic-create-schema.md │ │ │ ├── django-pydantic.md │ │ │ ├── index.md │ │ │ ├── pagination.md │ │ │ ├── response-renderers.md │ │ │ └── temporal_response.md │ │ ├── routers.md │ │ ├── testing.md │ │ ├── throttling.md │ │ ├── urls.md │ │ └── versioning.md │ ├── help.md │ ├── img │ │ ├── auth-swagger-ui-prompt.png │ │ ├── auth-swagger-ui.png │ │ ├── benchmark.png │ │ ├── body-editor.gif │ │ ├── body-schema-doc.png │ │ ├── body-schema-doc2.png │ │ ├── deprecated.png │ │ ├── docs-logo.png │ │ ├── favicon.png │ │ ├── github-star.png │ │ ├── hero.png │ │ ├── index-swagger-ui.png │ │ ├── logo-big.png │ │ ├── nested-routers-swagger.png │ │ ├── operation_description.png │ │ ├── operation_description_docstring.png │ │ ├── operation_summary.png │ │ ├── operation_summary_default.png │ │ ├── operation_tags.png │ │ ├── servers.png │ │ ├── simple-routers-swagger.png │ │ └── tutorial-path-swagger.png │ ├── index.md │ ├── motivation.md │ ├── proposals │ │ ├── cbv.md │ │ ├── index.md │ │ └── v1.md │ ├── reference │ │ ├── api.md │ │ ├── csrf.md │ │ ├── management-commands.md │ │ ├── operations-parameters.md │ │ └── settings.md │ ├── releases.md │ ├── tutorial │ │ ├── index.md │ │ ├── other │ │ │ ├── crud.md │ │ │ └── video.md │ │ ├── step2.md │ │ └── step3.md │ └── whatsnew_v1.md ├── mkdocs.yml ├── requirements.txt └── src │ ├── index001.py │ └── tutorial │ ├── authentication │ ├── apikey01.py │ ├── apikey02.py │ ├── apikey03.py │ ├── basic01.py │ ├── bearer01.py │ ├── bearer02.py │ ├── code001.py │ ├── code002.py │ ├── global01.py │ ├── multiple01.py │ └── schema01.py │ ├── body │ ├── code01.py │ ├── code02.py │ └── code03.py │ ├── form │ ├── code01.py │ ├── code02.py │ └── code03.py │ ├── path │ ├── code01.py │ ├── code010.py │ └── code02.py │ └── query │ ├── code01.py │ ├── code010.py │ ├── code02.py │ └── code03.py ├── mypy.ini ├── ninja ├── __init__.py ├── compatibility │ ├── __init__.py │ └── util.py ├── conf.py ├── constants.py ├── decorators.py ├── enum.py ├── errors.py ├── files.py ├── filter_schema.py ├── main.py ├── management │ ├── __init__.py │ ├── commands │ │ ├── __init__.py │ │ └── export_openapi_schema.py │ └── utils.py ├── openapi │ ├── __init__.py │ ├── docs.py │ ├── schema.py │ ├── urls.py │ └── views.py ├── operation.py ├── orm │ ├── __init__.py │ ├── factory.py │ ├── fields.py │ ├── metaclass.py │ └── shortcuts.py ├── pagination.py ├── params │ ├── __init__.py │ ├── functions.py │ └── models.py ├── parser.py ├── patch_dict.py ├── py.typed ├── renderers.py ├── responses.py ├── router.py ├── schema.py ├── security │ ├── __init__.py │ ├── apikey.py │ ├── base.py │ ├── http.py │ └── session.py ├── signature │ ├── __init__.py │ ├── details.py │ └── utils.py ├── static │ └── ninja │ │ ├── favicon.png │ │ ├── redoc.standalone.js │ │ ├── redoc.standalone.js.map │ │ ├── swagger-ui-bundle.js │ │ ├── swagger-ui-bundle.js.map │ │ ├── swagger-ui-init.js │ │ ├── swagger-ui.css │ │ └── swagger-ui.css.map ├── templates │ └── ninja │ │ ├── redoc.html │ │ ├── redoc_cdn.html │ │ ├── swagger.html │ │ └── swagger_cdn.html ├── testing │ ├── __init__.py │ └── client.py ├── throttling.py ├── types.py └── utils.py ├── pyproject.toml ├── scripts └── build-docs.sh └── tests ├── conftest.py ├── demo_project ├── demo │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── manage.py ├── multi_param │ ├── __init__.py │ ├── api.py │ ├── asgi.py │ ├── manage.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py └── someapp │ ├── __init__.py │ ├── admin.py │ ├── api.py │ ├── models.py │ └── views.py ├── env-matrix ├── Dockerfile ├── Dockerfile.backup ├── README.md ├── create_docker.py ├── docker-compose.yml ├── install_env.sh └── run.sh ├── main.py ├── mypy_test.py ├── pytest.ini ├── test_alias.py ├── test_annotated.py ├── test_api_instance.py ├── test_app.py ├── test_async.py ├── test_auth.py ├── test_auth_async.py ├── test_auth_global.py ├── test_auth_inheritance_routers.py ├── test_auth_routers.py ├── test_body.py ├── test_conf.py ├── test_csrf.py ├── test_csrf_async.py ├── test_decorators.py ├── test_django_models.py ├── test_docs ├── __init__.py ├── test_auth.py ├── test_body.py ├── test_form.py ├── test_index.py ├── test_path.py └── test_query.py ├── test_enum.py ├── test_errors.py ├── test_exceptions.py ├── test_export_openapi_schema.py ├── test_files.py ├── test_filter_schema.py ├── test_forms.py ├── test_forms_and_files.py ├── test_inheritance_routers.py ├── test_lists.py ├── test_misc.py ├── test_models.py ├── test_openapi_docs.py ├── test_openapi_extra.py ├── test_openapi_params.py ├── test_openapi_schema.py ├── test_orm_metaclass.py ├── test_orm_relations.py ├── test_orm_schemas.py ├── test_pagination.py ├── test_pagination_async.py ├── test_pagination_router.py ├── test_parser.py ├── test_patch_dict.py ├── test_path.py ├── test_pydantic_migrate.py ├── test_query.py ├── test_query_schema.py ├── test_renderer.py ├── test_request.py ├── test_response.py ├── test_response_cookies.py ├── test_response_multiple.py ├── test_response_params.py ├── test_reverse.py ├── test_router_add_router.py ├── test_router_defaults.py ├── test_router_path_params.py ├── test_schema.py ├── test_schema_context.py ├── test_serialization_context.py ├── test_server.py ├── test_signature_details.py ├── test_test_client.py ├── test_throttling.py ├── test_union.py ├── test_utils.py ├── test_with_django ├── __init__.py ├── schema_fixtures │ ├── test-multi-body-file.json │ ├── test-multi-body-form-file.json │ ├── test-multi-body-form.json │ ├── test-multi-body.json │ ├── test-multi-cookie.json │ ├── test-multi-form-body-file.json │ ├── test-multi-form-body.json │ ├── test-multi-form-file.json │ ├── test-multi-form.json │ ├── test-multi-header.json │ ├── test-multi-path.json │ └── test-multi-query.json └── test_multi_param_parsing.py ├── test_wraps.py └── util.py /.dockerignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .venv* 3 | .vscode 4 | .mypy_cache 5 | .coverage 6 | htmlcov 7 | 8 | dist 9 | test.py 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] " 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Versions (please complete the following information):** 14 | - Python version: [e.g. 3.6] 15 | - Django version: [e.g. 4.0] 16 | - Django-Ninja version: [e.g. 0.16.2] 17 | - Pydantic version: [e.g. 1.9.0] 18 | 19 | Note you can quickly get this by runninng in `./manage.py shell` this line: 20 | ``` 21 | import django; import pydantic; import ninja; django.__version__; ninja.__version__; pydantic.__version__ 22 | ``` 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Having troubles implementing something ? 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Please describe what you are trying to achieve 11 | 12 | Please include code examples (like models code, schemes code, view function) to help understand the issue 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: monthly 8 | -------------------------------------------------------------------------------- /.github/workflows/build_docs.yml: -------------------------------------------------------------------------------- 1 | name: Build Docs 2 | on: 3 | push: 4 | branches: 5 | - docs 6 | 7 | permissions: 8 | contents: write 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: 3.x 17 | cache: pip 18 | cache-dependency-path: docs/requirements.txt 19 | - name: Install requirements 20 | run: pip install -r docs/requirements.txt 21 | - name: Build and deploy docs 22 | run: | 23 | cd docs 24 | mkdocs gh-deploy --force -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | docs: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | - name: Docs update 15 | run: git push origin master:docs 16 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | publish: 10 | permissions: 11 | id-token: write 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | - name: Install Flit 18 | run: pip install flit 19 | - name: Install Dependencies 20 | run: flit install --symlink 21 | - name: mint API token 22 | id: mint-token 23 | run: | 24 | # retrieve the ambient OIDC token 25 | resp=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ 26 | "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=pypi") 27 | oidc_token=$(jq -r '.value' <<< "${resp}") 28 | 29 | # exchange the OIDC token for an API token 30 | resp=$(curl -X POST https://pypi.org/_/oidc/mint-token -d "{\"token\": \"${oidc_token}\"}") 31 | api_token=$(jq -r '.token' <<< "${resp}") 32 | 33 | # mask the newly minted API token, so that we don't accidentally leak it 34 | echo "::add-mask::${api_token}" 35 | 36 | # see the next step in the workflow for an example of using this step output 37 | echo "api-token=${api_token}" >> "${GITHUB_OUTPUT}" 38 | - name: Publish 39 | env: 40 | # FLIT_USERNAME: ${{ secrets.FLIT_USERNAME }} 41 | # FLIT_PASSWORD: ${{ secrets.FLIT_PASSWORD }} 42 | FLIT_USERNAME: __token__ 43 | FLIT_PASSWORD: ${{ steps.mint-token.outputs.api-token }} 44 | run: flit publish 45 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test Coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | test_coverage: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: 3.9 18 | - name: Install Flit 19 | run: pip install flit 20 | - name: Install Dependencies 21 | run: flit install --symlink 22 | - name: Test 23 | run: pytest --cov=ninja --cov-report=xml tests 24 | - name: Coverage 25 | uses: codecov/codecov-action@v5.4.0 26 | -------------------------------------------------------------------------------- /.github/workflows/test_full.yml: -------------------------------------------------------------------------------- 1 | name: Full Test 2 | 3 | on: 4 | push: 5 | workflow_dispatch: 6 | pull_request: 7 | types: [assigned, opened, synchronize, reopened] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-22.04 12 | strategy: 13 | matrix: 14 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] 15 | django-version: ['<3.2', '<3.3', '<4.2', '<4.3', '<5.1', '<5.2'] 16 | exclude: 17 | - python-version: '3.7' 18 | django-version: '<5.1' 19 | - python-version: '3.8' 20 | django-version: '<5.1' 21 | - python-version: '3.9' 22 | django-version: '<5.1' 23 | - python-version: '3.12' 24 | django-version: '<3.2' 25 | - python-version: '3.12' 26 | django-version: '<3.3' 27 | - python-version: '3.13' 28 | django-version: '<3.2' 29 | - python-version: '3.13' 30 | django-version: '<3.3' 31 | steps: 32 | - uses: actions/checkout@v4 33 | - name: Set up Python 34 | uses: actions/setup-python@v5 35 | with: 36 | python-version: ${{ matrix.python-version }} 37 | - name: Install core 38 | run: pip install --pre "Django${{ matrix.django-version }}" "pydantic<3" 39 | - name: Install tests 40 | run: pip install pytest pytest-asyncio pytest-django psycopg2-binary 41 | - name: Test 42 | run: pytest 43 | 44 | coverage: 45 | runs-on: ubuntu-latest 46 | 47 | steps: 48 | - uses: actions/checkout@v4 49 | - name: Set up Python 50 | uses: actions/setup-python@v5 51 | with: 52 | python-version: 3.9 53 | - name: Install Flit 54 | run: pip install flit 55 | - name: Install Dependencies 56 | run: flit install --symlink 57 | - name: Test 58 | run: pytest --cov=ninja 59 | 60 | codestyle: 61 | runs-on: ubuntu-latest 62 | steps: 63 | - uses: actions/checkout@v4 64 | - name: Set up Python 65 | uses: actions/setup-python@v5 66 | with: 67 | python-version: 3.9 68 | - name: Install Flit 69 | run: pip install flit 70 | - name: Install Dependencies 71 | run: flit install --symlink 72 | - name: Ruff format 73 | run: ruff format --preview --check ninja tests 74 | - name: Ruff lint 75 | run: ruff check --preview ninja tests 76 | - name: mypy 77 | run: mypy ninja tests/mypy_test.py 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .venv* 3 | .vscode 4 | .mypy_cache 5 | .coverage 6 | htmlcov 7 | /coverage.xml 8 | 9 | dist 10 | test.py 11 | 12 | docs/site 13 | 14 | .DS_Store 15 | .idea 16 | .python-version 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.2.0 4 | hooks: 5 | - id: check-yaml 6 | # - id: end-of-file-fixer 7 | # - id: trailing-whitespace 8 | - repo: https://github.com/pre-commit/mirrors-mypy 9 | rev: v1.7.1 10 | hooks: 11 | - id: mypy 12 | additional_dependencies: ["django-stubs", "pydantic"] 13 | exclude: (tests|docs)/ 14 | - repo: https://github.com/astral-sh/ruff-pre-commit 15 | rev: v0.5.7 16 | hooks: 17 | - id: ruff-format 18 | args: [--preview] 19 | - id: ruff 20 | args: [--fix, --exit-non-zero-on-fix] 21 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Django Shinobi uses Flit to build, package and publish the project. 4 | 5 | to install it use: 6 | 7 | ``` 8 | pip install flit 9 | ``` 10 | 11 | Once you have it - to install all dependencies required for development and testing use this command: 12 | 13 | 14 | ``` 15 | flit install --deps develop --symlink 16 | ``` 17 | 18 | Once done you can check if all works with 19 | 20 | ``` 21 | pytest . 22 | ``` 23 | 24 | or using Makefile: 25 | 26 | ``` 27 | make test 28 | ``` 29 | 30 | Now you are ready to make your contribution 31 | 32 | 33 | When you're done please make sure you to test your functionality 34 | and check the coverage of your contribution. 35 | 36 | ``` 37 | pytest --cov=ninja --cov-report term-missing tests 38 | ``` 39 | 40 | or using Makefile: 41 | 42 | ``` 43 | make test-cov 44 | ``` 45 | 46 | ## Code style 47 | 48 | Django Shinobi uses `ruff`, and `mypy` for style checks. 49 | 50 | Run `pre-commit install` to create a git hook to fix your styles before you commit. 51 | 52 | Alternatively, manually check your code with: 53 | 54 | ``` 55 | ruff format --check ninja tests 56 | ruff check ninja tests 57 | mypy ninja 58 | ``` 59 | 60 | or using Makefile: 61 | 62 | ``` 63 | make lint 64 | ``` 65 | 66 | Or reformat your code with: 67 | 68 | ``` 69 | ruff format ninja tests 70 | ruff check ninja tests --fix 71 | ``` 72 | 73 | or using Makefile: 74 | 75 | ``` 76 | make fmt 77 | ``` 78 | 79 | ## Docs 80 | Please do not forget to document your contribution 81 | 82 | Django Shinobi uses `mkdocs`: 83 | 84 | ``` 85 | cd docs/ 86 | mkdocs serve 87 | ``` 88 | and go to browser to see changes in real time 89 | 90 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Vitaliy Kucheryaviy 4 | Copyright (c) 2025 Peter DeVita, Django Shinobi Contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | .PHONY: help 4 | help: 5 | @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 6 | 7 | .PHONY: install 8 | install: ## Install dependencies 9 | flit install --deps develop --symlink 10 | 11 | .PHONY: lint 12 | lint: ## Run code linters 13 | ruff format --preview --check ninja tests 14 | ruff check --preview ninja tests 15 | mypy ninja 16 | 17 | .PHONY: fmt format 18 | fmt format: ## Run code formatters 19 | ruff format --preview ninja tests 20 | ruff check --preview --fix ninja tests 21 | 22 | .PHONY: test 23 | test: ## Run tests 24 | pytest . 25 | 26 | .PHONY: test-cov 27 | test-cov: ## Run tests with coverage 28 | pytest --cov=ninja --cov-report term-missing tests 29 | 30 | .PHONY: docs 31 | docs: ## Serve documentation locally 32 | pip install -r docs/requirements.txt 33 | cd docs && mkdocs serve -a localhost:8090 34 | -------------------------------------------------------------------------------- /docs/docs/extra.css: -------------------------------------------------------------------------------- 1 | .doc-module code { 2 | white-space: nowrap; 3 | } 4 | -------------------------------------------------------------------------------- /docs/docs/guides/input/form-params.md: -------------------------------------------------------------------------------- 1 | # Form data 2 | 3 | **Django Ninja** also allows you to parse and validate `request.POST` data 4 | (aka `application/x-www-form-urlencoded` or `multipart/form-data`). 5 | 6 | ## Form Data as params 7 | 8 | ```python hl_lines="1 4" 9 | from ninja import NinjaAPI, Form 10 | 11 | @api.post("/login") 12 | def login(request, username: Form[str], password: Form[str]): 13 | return {'username': username, 'password': '*****'} 14 | ``` 15 | 16 | Note the following: 17 | 18 | 1) You need to import the `Form` class from `ninja` 19 | ```python 20 | from ninja import Form 21 | ``` 22 | 23 | 2) Use `Form` as default value for your parameter: 24 | ```python 25 | username: Form[str] 26 | ``` 27 | 28 | ## Using a Schema 29 | 30 | In a similar manner to [Body](body.md#declare-it-as-a-parameter), you can use 31 | a Schema to organize your parameters. 32 | 33 | ```python hl_lines="12" 34 | {!./src/tutorial/form/code01.py!} 35 | ``` 36 | 37 | ## Request form + path + query parameters 38 | 39 | In a similar manner to [Body](body.md#request-body-path-query-parameters), you can use 40 | Form data in combination with other parameter sources. 41 | 42 | You can declare query **and** path **and** form field, **and** etc... parameters at the same time. 43 | 44 | **Django Ninja** will recognize that the function parameters that match path 45 | parameters should be **taken from the path**, and that function parameters that 46 | are declared with `Form(...)` should be **taken from the request form fields**, etc. 47 | 48 | ```python hl_lines="12" 49 | {!./src/tutorial/form/code02.py!} 50 | ``` 51 | ## Mapping Empty Form Field to Default 52 | 53 | Form fields that are optional, are often sent with an empty value. This value is 54 | interpreted as an empty string, and thus may fail validation for fields such as `int` or `bool`. 55 | 56 | This can be fixed, as described in the Pydantic docs, by using 57 | [Generic Classes as Types](https://pydantic-docs.helpmanual.io/usage/types/#generic-classes-as-types). 58 | 59 | ```python hl_lines="15 16 23-25" 60 | {!./src/tutorial/form/code03.py!} 61 | ``` 62 | -------------------------------------------------------------------------------- /docs/docs/guides/input/operations.md: -------------------------------------------------------------------------------- 1 | # HTTP Methods 2 | 3 | ## Defining operations 4 | 5 | An `operation` can be one of the following [HTTP methods](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods): 6 | 7 | - GET 8 | - POST 9 | - PUT 10 | - DELETE 11 | - PATCH 12 | 13 | **Django Ninja** comes with a decorator for each operation: 14 | 15 | ```python hl_lines="1 5 9 13 17" 16 | @api.get("/path") 17 | def get_operation(request): 18 | ... 19 | 20 | @api.post("/path") 21 | def post_operation(request): 22 | ... 23 | 24 | @api.put("/path") 25 | def put_operation(request): 26 | ... 27 | 28 | @api.delete("/path") 29 | def delete_operation(request): 30 | ... 31 | 32 | @api.patch("/path") 33 | def patch_operation(request): 34 | ... 35 | ``` 36 | 37 | See the [operations parameters](../../reference/operations-parameters.md) 38 | reference docs for information on what you can pass to any of these decorators. 39 | 40 | ## Handling multiple methods 41 | 42 | If you need to handle multiple methods with a single function for a given path, 43 | you can use the `api_operation` decorator: 44 | 45 | ```python hl_lines="1" 46 | @api.api_operation(["POST", "PATCH"], "/path") 47 | def mixed_operation(request): 48 | ... 49 | ``` 50 | 51 | This feature can also be used to implement other HTTP methods that don't have 52 | corresponding **Django Ninja** methods, such as `HEAD` or `OPTIONS`. 53 | 54 | ```python hl_lines="1" 55 | @api.api_operation(["HEAD", "OPTIONS"], "/path") 56 | def mixed_operation(request): 57 | ... 58 | ``` 59 | -------------------------------------------------------------------------------- /docs/docs/guides/input/request-parsers.md: -------------------------------------------------------------------------------- 1 | # Request parsers 2 | 3 | In most cases, the default content type for REST API's is JSON, but in case you need to work with 4 | other content types (like YAML, XML, CSV) or use faster JSON parsers, **Django Ninja** provides a `parser` configuration. 5 | 6 | ```python 7 | api = NinjaAPI(parser=MyYamlParser()) 8 | ``` 9 | 10 | To create your own parser, you need to extend the `ninja.parser.Parser` class, and override the `parse_body` method. 11 | 12 | 13 | ## Example YAML Parser 14 | 15 | Let's create our custom YAML parser: 16 | 17 | ```python hl_lines="4 8 9" 18 | import yaml 19 | from typing import List 20 | from ninja import NinjaAPI 21 | from ninja.parser import Parser 22 | 23 | 24 | class MyYamlParser(Parser): 25 | def parse_body(self, request): 26 | return yaml.safe_load(request.body) 27 | 28 | 29 | api = NinjaAPI(parser=MyYamlParser()) 30 | 31 | 32 | class Payload(Schema): 33 | ints: List[int] 34 | string: str 35 | f: float 36 | 37 | 38 | @api.post('/yaml') 39 | def operation(request, payload: Payload): 40 | return payload.dict() 41 | 42 | 43 | ``` 44 | 45 | If you now send YAML like this as the request body: 46 | 47 | ```YAML 48 | ints: 49 | - 0 50 | - 1 51 | string: hello 52 | f: 3.14 53 | ``` 54 | 55 | it will be correctly parsed, and you should have JSON output like this: 56 | 57 | 58 | ```JSON 59 | { 60 | "ints": [ 61 | 0, 62 | 1 63 | ], 64 | "string": "hello", 65 | "f": 3.14 66 | } 67 | ``` 68 | 69 | 70 | ## Example ORJSON Parser 71 | 72 | [orjson](https://github.com/ijl/orjson#orjson) is a fast, accurate JSON library for Python. It benchmarks as the fastest Python library for JSON and is more accurate than the standard `json` library or other third-party libraries. 73 | 74 | ``` 75 | pip install orjson 76 | ``` 77 | 78 | Parser code: 79 | 80 | ```python hl_lines="1 8 9" 81 | import orjson 82 | from ninja import NinjaAPI 83 | from ninja.parser import Parser 84 | 85 | 86 | class ORJSONParser(Parser): 87 | def parse_body(self, request): 88 | return orjson.loads(request.body) 89 | 90 | 91 | api = NinjaAPI(parser=ORJSONParser()) 92 | ``` 93 | 94 | -------------------------------------------------------------------------------- /docs/docs/guides/response/config-pydantic.md: -------------------------------------------------------------------------------- 1 | # Overriding Pydantic Config 2 | 3 | There are many customizations available for a **Django Ninja `Schema`**, via the schema's 4 | [Pydantic `Config` class](https://pydantic-docs.helpmanual.io/usage/model_config/). 5 | 6 | !!! info 7 | Under the hood **Django Ninja** uses [Pydantic Models](https://pydantic-docs.helpmanual.io/usage/models/) 8 | with all their power and benefits. The alias `Schema` was chosen to avoid confusion in code 9 | when using Django models, as Pydantic's model class is called Model by default, and conflicts with 10 | Django's Model class. 11 | 12 | ## Automatic Camel Case Aliases 13 | 14 | One useful `Config` attribute is [`alias_generator`](https://pydantic-docs.helpmanual.io/usage/model_config/#alias-generator). 15 | We can use it to automatically generate aliases for field names with a given function. This is mostly commonly used to create 16 | an API that uses camelCase for its property names. 17 | Using Pydantic's example in **Django Ninja** can look something like: 18 | 19 | ```python hl_lines="9 10" 20 | from ninja import Schema 21 | from pydantic.alias_generators import to_camel 22 | 23 | 24 | class CamelModelSchema(Schema): 25 | str_field_name: str 26 | float_field_name: float 27 | 28 | class Config(Schema.Config): 29 | alias_generator = to_camel 30 | ``` 31 | 32 | !!! note 33 | When overriding the schema's `Config`, it is necessary to inherit from the base `Schema.Config` class. 34 | 35 | To alias `ModelSchema`'s field names, you'll also need to set `populate_by_name` on the `Schema` config and 36 | enable `by_alias` in all endpoints using the model. 37 | 38 | ```python hl_lines="4 11" 39 | class UserSchema(ModelSchema): 40 | class Config(Schema.Config): 41 | alias_generator = to_camel 42 | populate_by_name = True # !!!!!! <-------- 43 | 44 | class Meta: 45 | model = User 46 | model_fields = ["id", "email", "created_date"] 47 | 48 | 49 | @api.get("/users", response=list[UserSchema], by_alias=True) # !!!!!! <-------- by_alias 50 | def get_users(request): 51 | return User.objects.all() 52 | 53 | ``` 54 | 55 | results: 56 | 57 | ```JSON 58 | [ 59 | { 60 | "id": 1, 61 | "email": "tim@apple.com", 62 | "createdDate": "2011-08-24" 63 | }, 64 | { 65 | "id": 2, 66 | "email": "sarah@smith.com", 67 | "createdDate": "2012-03-06" 68 | }, 69 | ... 70 | ] 71 | 72 | ``` 73 | 74 | ## Custom Config from Django Model 75 | 76 | When using [`create_schema`](django-pydantic-create-schema.md#create_schema), the resulting 77 | schema can be used to build another class with a custom config like: 78 | 79 | ```python hl_lines="10" 80 | from django.contrib.auth.models import User 81 | from ninja.orm import create_schema 82 | 83 | 84 | BaseUserSchema = create_schema(User) 85 | 86 | 87 | class UserSchema(BaseUserSchema): 88 | 89 | class Config(BaseUserSchema.Config): 90 | ... 91 | ``` 92 | -------------------------------------------------------------------------------- /docs/docs/guides/response/temporal_response.md: -------------------------------------------------------------------------------- 1 | # Altering the Response 2 | 3 | Sometimes you'll want to change the response just before it gets served, for example, to add a header or alter a cookie. 4 | 5 | To do this, simply declare a function parameter with a type of `HttpResponse`: 6 | 7 | ```python 8 | from django.http import HttpRequest, HttpResponse 9 | 10 | @api.get("/cookie/") 11 | def feed_cookiemonster(request: HttpRequest, response: HttpResponse): 12 | # Set a cookie. 13 | response.set_cookie("cookie", "delicious") 14 | # Set a header. 15 | response["X-Cookiemonster"] = "blue" 16 | return {"cookiemonster_happy": True} 17 | ``` 18 | 19 | 20 | ## Temporal response object 21 | 22 | This response object is used for the base of all responses built by Django Ninja, including error responses. This object is *not* used if a Django `HttpResponse` object is returned directly by an operation. 23 | 24 | Obviously this response object won't contain the content yet, but it does have the `content_type` set (but you probably don't want to be changing it). 25 | 26 | The `status_code` will get overridden depending on the return value (200 by default, or the status code if a two-part tuple is returned). 27 | 28 | 29 | ## Changing the base response object 30 | 31 | You can alter this temporal response object by overriding the `NinjaAPI.create_temporal_response` method. 32 | 33 | ```python 34 | def create_temporal_response(self, request: HttpRequest) -> HttpResponse: 35 | response = super().create_temporal_response(request) 36 | # Do your magic here... 37 | return response 38 | ``` -------------------------------------------------------------------------------- /docs/docs/guides/testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | **Django Ninja** is fully compatible with standard [django test client](https://docs.djangoproject.com/en/dev/topics/testing/tools/) , but also provides a test client to make it easy to test just APIs without middleware/url-resolver layer making tests run faster. 4 | 5 | To test the following API: 6 | ```python 7 | from ninja import NinjaAPI, Schema 8 | 9 | api = NinjaAPI() 10 | router = Router() 11 | 12 | class HelloResponse(Schema): 13 | msg: str 14 | 15 | @router.get("/hello", response=HelloResponse) 16 | def hello(request): 17 | return {"msg": "Hello World"} 18 | 19 | api.add_router("", router) 20 | ``` 21 | 22 | You can use the Django test class: 23 | ```python 24 | from django.test import TestCase 25 | from ninja.testing import TestClient 26 | 27 | class HelloTest(TestCase): 28 | def test_hello(self): 29 | # don't forget to import router from code above 30 | client = TestClient(router) 31 | response = client.get("/hello") 32 | 33 | self.assertEqual(response.status_code, 200) 34 | self.assertEqual(response.json(), {"msg": "Hello World"}) 35 | ``` 36 | 37 | It is also possible to access the deserialized data using the `data` property: 38 | ```python 39 | self.assertEqual(response.data, {"msg": "Hello World"}) 40 | ``` 41 | 42 | ## Attributes 43 | Arbitrary attributes can be added to the request object by passing keyword arguments to the client request methods: 44 | ```python 45 | class HelloTest(TestCase): 46 | def test_hello(self): 47 | client = TestClient(router) 48 | # request.company_id will now be set within the view 49 | response = client.get("/hello", company_id=1) 50 | ``` 51 | 52 | ### Headers 53 | It is also possible to specify headers, both from the TestCase instantiation and the actual request: 54 | ```python 55 | client = TestClient(router, headers={"A": "a", "B": "b"}) 56 | # The request will be made with {"A": "na", "B": "b", "C": "nc"} headers 57 | response = client.get("/test-headers", headers={"A": "na", "C": "nc"}) 58 | ``` 59 | 60 | ### Cookies 61 | It is also possible to specify cookies, both from the TestCase instantiation and the actual request: 62 | ```python 63 | client = TestClient(router, COOKIES={"A": "a", "B": "b"}) 64 | # The request will be made with {"A": "na", "B": "b", "C": "nc"} cookies 65 | response = client.get("/test-cookies", COOKIES={"A": "na", "C": "nc"}) 66 | ``` 67 | 68 | ### Users 69 | It is also possible to specify a User for the request: 70 | ```python 71 | user = User.objects.create(...) 72 | client = TestClient(router) 73 | # The request will be made with user logged in 74 | response = client.get("/test-with-user", user=user) 75 | ``` 76 | 77 | ## Testing async operations 78 | 79 | To test operations in async context use `TestAsyncClient`: 80 | 81 | ```python 82 | from ninja.testing import TestAsyncClient 83 | 84 | client = TestAsyncClient(router) 85 | response = await client.post("/test/") 86 | 87 | ``` 88 | -------------------------------------------------------------------------------- /docs/docs/guides/urls.md: -------------------------------------------------------------------------------- 1 | # Reverse Resolution of URLS 2 | 3 | A reverse URL name is generated for each method in a Django Ninja Schema (or `Router`). 4 | 5 | ## How URLs are generated 6 | 7 | The URLs are all contained within a namespace, which defaults to `"api-1.0.0"`, and each URL name matches the function it is decorated. 8 | 9 | For example: 10 | 11 | ```python 12 | api = NinjaAPI() 13 | 14 | @api.get("/") 15 | def index(request): 16 | ... 17 | 18 | index_url = reverse_lazy("api-1.0.0:index") 19 | ``` 20 | 21 | This implicit URL name will only be set for the first operation for each API path. If you *don't* want any implicit reverse URL name generated, just explicitly specify `url_name=""` (an empty string) on the method decorator. 22 | 23 | ### Changing the URL name 24 | 25 | Rather than using the default URL name, you can specify it explicitly as a property on the method decorator. 26 | 27 | ```python 28 | @api.get("/users", url_name="user_list") 29 | def users(request): 30 | ... 31 | 32 | users_url = reverse_lazy("api-1.0.0:user_list") 33 | ``` 34 | 35 | This will override any implicit URL name to this API path. 36 | 37 | 38 | #### Overriding default url names 39 | 40 | You can also override implicit url naming by overwriting the `get_operation_url_name` method: 41 | 42 | ```python 43 | class MyAPI(NinjaAPI): 44 | def get_operation_url_name(self, operation, router): 45 | return operation.view_func.__name__ + '_my_extra_suffix' 46 | 47 | api = MyAPI() 48 | ``` 49 | 50 | ### Customizing the namespace 51 | 52 | The default URL namespace is built by prepending the Schema's version with `"api-"`, however you can explicitly specify the namespace by overriding the `urls_namespace` attribute of the `NinjaAPI` Schema class. 53 | 54 | ```python 55 | 56 | api = NinjaAPI(auth=token_auth, version='2') 57 | api_private = NinjaAPI(auth=session_auth, urls_namespace='private_api') 58 | 59 | api_users_url = reverse_lazy("api-2:users") 60 | private_api_admins_url = reverse_lazy("private_api:admins") 61 | ``` 62 | -------------------------------------------------------------------------------- /docs/docs/guides/versioning.md: -------------------------------------------------------------------------------- 1 | # Versioning 2 | 3 | ## Different API version numbers 4 | 5 | With **Django Ninja** it's easy to run multiple API versions from a single Django project. 6 | 7 | All you have to do is create two or more NinjaAPI instances with different `version` arguments: 8 | 9 | 10 | **api_v1.py**: 11 | 12 | ```python hl_lines="4" 13 | from ninja import NinjaAPI 14 | 15 | 16 | api = NinjaAPI(version='1.0.0') 17 | 18 | @api.get('/hello') 19 | def hello(request): 20 | return {'message': 'Hello from V1'} 21 | 22 | ``` 23 | 24 | 25 | api_**v2**.py: 26 | 27 | ```python hl_lines="4" 28 | from ninja import NinjaAPI 29 | 30 | 31 | api = NinjaAPI(version='2.0.0') 32 | 33 | @api.get('/hello') 34 | def hello(request): 35 | return {'message': 'Hello from V2'} 36 | ``` 37 | 38 | 39 | and then in **urls.py**: 40 | 41 | ```python hl_lines="8 9" 42 | ... 43 | from api_v1 import api as api_v1 44 | from api_v2 import api as api_v2 45 | 46 | 47 | urlpatterns = [ 48 | ... 49 | path('api/v1/', api_v1.urls), 50 | path('api/v2/', api_v2.urls), 51 | ] 52 | 53 | ``` 54 | 55 | 56 | Now you can go to different OpenAPI docs pages for each version: 57 | 58 | - http://127.0.0.1/api/**v1**/docs 59 | - http://127.0.0.1/api/**v2**/docs 60 | 61 | 62 | 63 | ## Different business logic 64 | 65 | In the same way, you can define a different API for different components or areas: 66 | 67 | ```python hl_lines="4 7" 68 | ... 69 | 70 | 71 | api = NinjaAPI(auth=token_auth, urls_namespace='public_api') 72 | ... 73 | 74 | api_private = NinjaAPI(auth=session_auth, urls_namespace='private_api') 75 | ... 76 | 77 | 78 | urlpatterns = [ 79 | ... 80 | path('api/', api.urls), 81 | path('internal-api/', api_private.urls), 82 | ] 83 | 84 | ``` 85 | !!! note 86 | If you use different **NinjaAPI** instances, you need to define different `version`s or different `urls_namespace`s. 87 | -------------------------------------------------------------------------------- /docs/docs/help.md: -------------------------------------------------------------------------------- 1 | # Help / Get Help 2 | 3 | ## Do you like Django Shinobi? 4 | 5 | If you like this project, there is a tiny thing you can do to let us know that we're moving in the right direction. 6 | 7 | Simply give django-shinobi a star on github ![github star](img/github-star.png) 8 | 9 | or share this URL on social media: 10 | ``` 11 | https://pmdevita.github.io/django-shinobi/ 12 | ``` 13 | Join the chat on Discord https://discord.gg/ntFTXu7NNv 14 | 15 | ## Do you want to help us? 16 | 17 | Pull requests are always welcome. 18 | 19 | You can inspect our docs for typos and spelling mistakes, and create pull requests or open an issue. 20 | 21 | If you have any suggestions to improve **Django Shinobi**, please create them as issues on GitHub. 22 | 23 | 24 | ## Do you need help? 25 | 26 | Do not hesitate. Go to GitHub issues and describe your question or problem. We'll attempt to address them quickly. 27 | 28 | Join the chat at our Discord server. 29 | -------------------------------------------------------------------------------- /docs/docs/img/auth-swagger-ui-prompt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/auth-swagger-ui-prompt.png -------------------------------------------------------------------------------- /docs/docs/img/auth-swagger-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/auth-swagger-ui.png -------------------------------------------------------------------------------- /docs/docs/img/benchmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/benchmark.png -------------------------------------------------------------------------------- /docs/docs/img/body-editor.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/body-editor.gif -------------------------------------------------------------------------------- /docs/docs/img/body-schema-doc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/body-schema-doc.png -------------------------------------------------------------------------------- /docs/docs/img/body-schema-doc2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/body-schema-doc2.png -------------------------------------------------------------------------------- /docs/docs/img/deprecated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/deprecated.png -------------------------------------------------------------------------------- /docs/docs/img/docs-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/docs-logo.png -------------------------------------------------------------------------------- /docs/docs/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/favicon.png -------------------------------------------------------------------------------- /docs/docs/img/github-star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/github-star.png -------------------------------------------------------------------------------- /docs/docs/img/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/hero.png -------------------------------------------------------------------------------- /docs/docs/img/index-swagger-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/index-swagger-ui.png -------------------------------------------------------------------------------- /docs/docs/img/logo-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/logo-big.png -------------------------------------------------------------------------------- /docs/docs/img/nested-routers-swagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/nested-routers-swagger.png -------------------------------------------------------------------------------- /docs/docs/img/operation_description.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/operation_description.png -------------------------------------------------------------------------------- /docs/docs/img/operation_description_docstring.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/operation_description_docstring.png -------------------------------------------------------------------------------- /docs/docs/img/operation_summary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/operation_summary.png -------------------------------------------------------------------------------- /docs/docs/img/operation_summary_default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/operation_summary_default.png -------------------------------------------------------------------------------- /docs/docs/img/operation_tags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/operation_tags.png -------------------------------------------------------------------------------- /docs/docs/img/servers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/servers.png -------------------------------------------------------------------------------- /docs/docs/img/simple-routers-swagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/simple-routers-swagger.png -------------------------------------------------------------------------------- /docs/docs/img/tutorial-path-swagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/docs/img/tutorial-path-swagger.png -------------------------------------------------------------------------------- /docs/docs/proposals/index.md: -------------------------------------------------------------------------------- 1 | # Enhancement Proposals 2 | 3 | Enhancement Proposals are a formal way of proposing large feature additions to the **Django Ninja Framework**. 4 | 5 | You can create a proposal by making a pull request with a new page under [`docs/proposals`](https://github.com/vitalik/django-ninja/tree/master/docs/docs/proposals), or by creating an [issue on github](https://github.com/vitalik/django-ninja/issues). 6 | 7 | Please see the current proposals: 8 | 9 | - [Class Based Operations](cbv.md) 10 | -------------------------------------------------------------------------------- /docs/docs/proposals/v1.md: -------------------------------------------------------------------------------- 1 | # Potential v1 changes 2 | 3 | Django Ninja is already used by tens of companies and by the visitors and downloads stats it's growing. 4 | 5 | At this point introducing changes that will force current users to change their code (or break it) is not 6 | acceptable. 7 | 8 | On the other hand some decisions that where initially made does not work well. These breaking changes will be 9 | introduced in version 1.0.0 10 | 11 | ## Changes that most likely be in v1 12 | 13 | - **auth** will be class interface instead of callable (to support async authenticators) 14 | - **responses** to support **codes/headers/content** (like general Response class) 15 | - **routers paths** currently automatically **joined with "/"** - which might not needed on some cases where router prefix will act like a prefix and not subfolder 16 | 17 | ## Your thoughts/proposals 18 | 19 | Please give you thoughts/likes/dislikes in the [github issue](https://github.com/vitalik/django-ninja/issues/146). 20 | 21 | -------------------------------------------------------------------------------- /docs/docs/reference/api.md: -------------------------------------------------------------------------------- 1 | # NinjaAPI 2 | 3 | ::: ninja.main.NinjaAPI 4 | rendering: 5 | show_signature: False 6 | group_by_category: False 7 | -------------------------------------------------------------------------------- /docs/docs/reference/management-commands.md: -------------------------------------------------------------------------------- 1 | # Management Commands 2 | 3 | Management commands require **Django Ninja** to be installed in Django's 4 | `INSTALLED_APPS` setting: 5 | 6 | ```python 7 | INSTALLED_APPS = [ 8 | ... 9 | 'ninja', 10 | ] 11 | ``` 12 | 13 | ::: ninja.management.commands 14 | selection: 15 | filters: 16 | - "![A-Z]" 17 | rendering: 18 | show_root_toc_entry: False 19 | -------------------------------------------------------------------------------- /docs/docs/reference/settings.md: -------------------------------------------------------------------------------- 1 | # Django Settings 2 | 3 | ::: ninja.conf.Settings 4 | rendering: 5 | show_source: False 6 | show_root_toc_entry: False -------------------------------------------------------------------------------- /docs/docs/releases.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | Follow and subscribe for new releases on GitHub: 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /docs/docs/tutorial/index.md: -------------------------------------------------------------------------------- 1 | # Tutorial - First Steps 2 | 3 | This tutorial shows you how to use **Django Shinobi** with most of its features. 4 | 5 | This tutorial assumes that you know at least some basics of the Django Framework, like how to create a project and run it. 6 | 7 | ## Installation 8 | 9 | ```console 10 | pip install django-shinobi 11 | ``` 12 | 13 | !!! note 14 | 15 | It is not required, but you can also put `ninja` to `INSTALLED_APPS`. 16 | In that case the OpenAPI/Swagger UI (or Redoc) will be loaded (faster) from the included JavaScript bundle (otherwise the JavaScript bundle comes from a CDN). 17 | 18 | ## Create a Django project 19 | 20 | Start a new Django project (or if you already have an existing Django project, skip to the next step). 21 | 22 | ``` 23 | django-admin startproject myproject 24 | ``` 25 | 26 | ## Create the API 27 | 28 | Let's create a module for our API. Create an `api.py` file in the same directory location as your Django project's root `urls.py`: 29 | 30 | !!! note 31 | 32 | For the sake of backwards compatibility, Shinobi still uses the `ninja` package name. 33 | 34 | ```python 35 | from ninja import NinjaAPI 36 | 37 | api = NinjaAPI() 38 | ``` 39 | 40 | Now go to `urls.py` and add the following: 41 | 42 | ```python hl_lines="3 7" 43 | from django.contrib import admin 44 | from django.urls import path 45 | from .api import api 46 | 47 | urlpatterns = [ 48 | path("admin/", admin.site.urls), 49 | path("api/", api.urls), 50 | ] 51 | ``` 52 | 53 | ## Our first operation 54 | 55 | **Django Shinobi** comes with a decorator for each HTTP method (`GET`, `POST`, 56 | `PUT`, etc). In our `api.py` file, let's add in a simple "hello world" 57 | operation. 58 | 59 | ```python hl_lines="5-7" 60 | from ninja import NinjaAPI 61 | 62 | api = NinjaAPI() 63 | 64 | @api.get("/hello") 65 | def hello(request): 66 | return "Hello world" 67 | ``` 68 | 69 | Now browsing to localhost:8000/api/hello will return a simple JSON 71 | response: 72 | ```json 73 | "Hello world" 74 | ``` 75 | 76 | !!! success 77 | 78 | Continue on to **[Parsing input](step2.md)**. -------------------------------------------------------------------------------- /docs/docs/tutorial/other/video.md: -------------------------------------------------------------------------------- 1 | # Video Tutorials 2 | 3 | ## Sneaky REST APIs With Django Ninja 4 | 5 | [realpython.com/lessons/sneaky-rest-apis-with-django-ninja-overview/](https://realpython.com/lessons/sneaky-rest-apis-with-django-ninja-overview/) 6 | 7 | 8 | ## Creating a CRUD API with Django-Ninja by BugBytes (English) 9 | 10 | -------------------------------------------------------------------------------- /docs/docs/tutorial/step3.md: -------------------------------------------------------------------------------- 1 | # Tutorial - Handling Responses 2 | 3 | ## Define a response Schema 4 | 5 | **Django Ninja** allows you to define the schema of your responses both for validation and documentation purposes. 6 | 7 | We'll create a third operation that will return information about the current Django user. 8 | 9 | ```python 10 | from ninja import Schema 11 | 12 | class UserSchema(Schema): 13 | username: str 14 | is_authenticated: bool 15 | # Unauthenticated users don't have the following fields, so provide defaults. 16 | email: str = None 17 | first_name: str = None 18 | last_name: str = None 19 | 20 | @api.get("/me", response=UserSchema) 21 | def me(request): 22 | return request.user 23 | ``` 24 | 25 | This will convert the Django `User` object into a dictionary of only the defined fields. 26 | 27 | ### Multiple response types 28 | 29 | Let's return a different response if the current user is not authenticated. 30 | 31 | ```python hl_lines="2-5 7-8 10 12-13" 32 | class UserSchema(Schema): 33 | username: str 34 | email: str 35 | first_name: str 36 | last_name: str 37 | 38 | class Error(Schema): 39 | message: str 40 | 41 | @api.get("/me", response={200: UserSchema, 403: Error}) 42 | def me(request): 43 | if not request.user.is_authenticated: 44 | return 403, {"message": "Please sign in first"} 45 | return request.user 46 | ``` 47 | 48 | As you see, you can return a 2-part tuple which will be interpreted as the HTTP response code and the data. 49 | 50 | !!! success 51 | 52 | That concludes the tutorial! Check out the **Other Tutorials** or the **How-to Guides** for more information. -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.5.3 2 | mkdocs-material==9.5.4 3 | markdown-include==0.8.1 4 | mkdocstrings[python]==0.27.0 5 | -------------------------------------------------------------------------------- /docs/src/index001.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | from ninja import NinjaAPI 4 | 5 | api = NinjaAPI() 6 | 7 | 8 | @api.get("/add") 9 | def add(request, a: int, b: int): 10 | return {"result": a + b} 11 | 12 | 13 | urlpatterns = [ 14 | path("admin/", admin.site.urls), 15 | path("api/", api.urls), 16 | ] 17 | -------------------------------------------------------------------------------- /docs/src/tutorial/authentication/apikey01.py: -------------------------------------------------------------------------------- 1 | from ninja.security import APIKeyQuery 2 | from someapp.models import Client 3 | 4 | 5 | class ApiKey(APIKeyQuery): 6 | param_name = "api_key" 7 | 8 | def authenticate(self, request, key): 9 | try: 10 | return Client.objects.get(key=key) 11 | except Client.DoesNotExist: 12 | pass 13 | 14 | 15 | api_key = ApiKey() 16 | 17 | 18 | @api.get("/apikey", auth=api_key) 19 | def apikey(request): 20 | assert isinstance(request.auth, Client) 21 | return f"Hello {request.auth}" 22 | -------------------------------------------------------------------------------- /docs/src/tutorial/authentication/apikey02.py: -------------------------------------------------------------------------------- 1 | from ninja.security import APIKeyHeader 2 | 3 | 4 | class ApiKey(APIKeyHeader): 5 | param_name = "X-API-Key" 6 | 7 | def authenticate(self, request, key): 8 | if key == "supersecret": 9 | return key 10 | 11 | 12 | header_key = ApiKey() 13 | 14 | 15 | @api.get("/headerkey", auth=header_key) 16 | def apikey(request): 17 | return f"Token = {request.auth}" 18 | -------------------------------------------------------------------------------- /docs/src/tutorial/authentication/apikey03.py: -------------------------------------------------------------------------------- 1 | from ninja.security import APIKeyCookie 2 | 3 | 4 | class CookieKey(APIKeyCookie): 5 | def authenticate(self, request, key): 6 | if key == "supersecret": 7 | return key 8 | 9 | 10 | cookie_key = CookieKey() 11 | 12 | 13 | @api.get("/cookiekey", auth=cookie_key) 14 | def apikey(request): 15 | return f"Token = {request.auth}" 16 | -------------------------------------------------------------------------------- /docs/src/tutorial/authentication/basic01.py: -------------------------------------------------------------------------------- 1 | from ninja.security import HttpBasicAuth 2 | 3 | 4 | class BasicAuth(HttpBasicAuth): 5 | def authenticate(self, request, username, password): 6 | if username == "admin" and password == "secret": 7 | return username 8 | 9 | 10 | @api.get("/basic", auth=BasicAuth()) 11 | def basic(request): 12 | return {"httpuser": request.auth} 13 | -------------------------------------------------------------------------------- /docs/src/tutorial/authentication/bearer01.py: -------------------------------------------------------------------------------- 1 | from ninja.security import HttpBearer 2 | 3 | 4 | class AuthBearer(HttpBearer): 5 | def authenticate(self, request, token): 6 | if token == "supersecret": 7 | return token 8 | 9 | 10 | @api.get("/bearer", auth=AuthBearer()) 11 | def bearer(request): 12 | return {"token": request.auth} 13 | -------------------------------------------------------------------------------- /docs/src/tutorial/authentication/bearer02.py: -------------------------------------------------------------------------------- 1 | from ninja import NinjaAPI 2 | from ninja.security import HttpBearer 3 | 4 | api = NinjaAPI() 5 | 6 | class InvalidToken(Exception): 7 | pass 8 | 9 | @api.exception_handler(InvalidToken) 10 | def on_invalid_token(request, exc): 11 | return api.create_response(request, {"detail": "Invalid token supplied"}, status=401) 12 | 13 | class AuthBearer(HttpBearer): 14 | def authenticate(self, request, token): 15 | if token == "supersecret": 16 | return token 17 | raise InvalidToken 18 | 19 | 20 | @api.get("/bearer", auth=AuthBearer()) 21 | def bearer(request): 22 | return {"token": request.auth} 23 | -------------------------------------------------------------------------------- /docs/src/tutorial/authentication/code001.py: -------------------------------------------------------------------------------- 1 | from ninja import NinjaAPI 2 | from ninja.security import django_auth 3 | 4 | api = NinjaAPI(csrf=True) 5 | 6 | 7 | @api.get("/pets", auth=django_auth) 8 | def pets(request): 9 | return f"Authenticated user {request.auth}" 10 | -------------------------------------------------------------------------------- /docs/src/tutorial/authentication/code002.py: -------------------------------------------------------------------------------- 1 | def ip_whitelist(request): 2 | if request.META["REMOTE_ADDR"] == "8.8.8.8": 3 | return "8.8.8.8" 4 | 5 | 6 | @api.get("/ipwhitelist", auth=ip_whitelist) 7 | def ipwhitelist(request): 8 | return f"Authenticated client, IP = {request.auth}" 9 | -------------------------------------------------------------------------------- /docs/src/tutorial/authentication/global01.py: -------------------------------------------------------------------------------- 1 | from ninja import NinjaAPI, Form 2 | from ninja.security import HttpBearer 3 | 4 | 5 | class GlobalAuth(HttpBearer): 6 | def authenticate(self, request, token): 7 | if token == "supersecret": 8 | return token 9 | 10 | 11 | api = NinjaAPI(auth=GlobalAuth()) 12 | 13 | # @api.get(...) 14 | # def ... 15 | # @api.post(...) 16 | # def ... 17 | 18 | 19 | @api.post("/token", auth=None) # < overriding global auth 20 | def get_token(request, username: str = Form(...), password: str = Form(...)): 21 | if username == "admin" and password == "giraffethinnknslong": 22 | return {"token": "supersecret"} 23 | -------------------------------------------------------------------------------- /docs/src/tutorial/authentication/multiple01.py: -------------------------------------------------------------------------------- 1 | from ninja.security import APIKeyQuery, APIKeyHeader 2 | 3 | 4 | class AuthCheck: 5 | def authenticate(self, request, key): 6 | if key == "supersecret": 7 | return key 8 | 9 | 10 | class QueryKey(AuthCheck, APIKeyQuery): 11 | pass 12 | 13 | 14 | class HeaderKey(AuthCheck, APIKeyHeader): 15 | pass 16 | 17 | 18 | @api.get("/multiple", auth=[QueryKey(), HeaderKey()]) 19 | def multiple(request): 20 | return f"Token = {request.auth}" 21 | -------------------------------------------------------------------------------- /docs/src/tutorial/authentication/schema01.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/docs/src/tutorial/authentication/schema01.py -------------------------------------------------------------------------------- /docs/src/tutorial/body/code01.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from ninja import Schema 3 | 4 | 5 | class Item(Schema): 6 | name: str 7 | description: Optional[str] = None 8 | price: float 9 | quantity: int 10 | 11 | 12 | @api.post("/items") 13 | def create(request, item: Item): 14 | return item 15 | -------------------------------------------------------------------------------- /docs/src/tutorial/body/code02.py: -------------------------------------------------------------------------------- 1 | from ninja import Schema 2 | 3 | 4 | class Item(Schema): 5 | name: str 6 | description: str = None 7 | price: float 8 | quantity: int 9 | 10 | 11 | @api.put("/items/{item_id}") 12 | def update(request, item_id: int, item: Item): 13 | return {"item_id": item_id, "item": item.dict()} 14 | -------------------------------------------------------------------------------- /docs/src/tutorial/body/code03.py: -------------------------------------------------------------------------------- 1 | from ninja import Schema 2 | 3 | 4 | class Item(Schema): 5 | name: str 6 | description: str = None 7 | price: float 8 | quantity: int 9 | 10 | 11 | @api.post("/items/{item_id}") 12 | def update(request, item_id: int, item: Item, q: str): 13 | return {"item_id": item_id, "item": item.dict(), "q": q} 14 | -------------------------------------------------------------------------------- /docs/src/tutorial/form/code01.py: -------------------------------------------------------------------------------- 1 | from ninja import Form, Schema 2 | 3 | 4 | class Item(Schema): 5 | name: str 6 | description: str = None 7 | price: float 8 | quantity: int 9 | 10 | 11 | @api.post("/items") 12 | def create(request, item: Form[Item]): 13 | return item 14 | -------------------------------------------------------------------------------- /docs/src/tutorial/form/code02.py: -------------------------------------------------------------------------------- 1 | from ninja import Form, Schema 2 | 3 | 4 | class Item(Schema): 5 | name: str 6 | description: str = None 7 | price: float 8 | quantity: int 9 | 10 | 11 | @api.post("/items/{item_id}") 12 | def update(request, item_id: int, q: str, item: Form[Item]): 13 | return {"item_id": item_id, "item": item.dict(), "q": q} 14 | -------------------------------------------------------------------------------- /docs/src/tutorial/form/code03.py: -------------------------------------------------------------------------------- 1 | from ninja import Form, Schema 2 | from typing import Annotated, TypeVar 3 | from pydantic import WrapValidator 4 | from pydantic_core import PydanticUseDefault 5 | 6 | 7 | def _empty_str_to_default(v, handler, info): 8 | if isinstance(v, str) and v == '': 9 | raise PydanticUseDefault 10 | return handler(v) 11 | 12 | 13 | T = TypeVar('T') 14 | EmptyStrToDefault = Annotated[T, WrapValidator(_empty_str_to_default)] 15 | 16 | 17 | class Item(Schema): 18 | name: str 19 | description: str = None 20 | price: EmptyStrToDefault[float] = 0.0 21 | quantity: EmptyStrToDefault[int] = 0 22 | in_stock: EmptyStrToDefault[bool] = True 23 | 24 | 25 | @api.post("/items-blank-default") 26 | def update(request, item: Form[Item]): 27 | return item.dict() 28 | -------------------------------------------------------------------------------- /docs/src/tutorial/path/code01.py: -------------------------------------------------------------------------------- 1 | @api.get("/items/{item_id}") 2 | def read_item(request, item_id): 3 | return {"item_id": item_id} 4 | -------------------------------------------------------------------------------- /docs/src/tutorial/path/code010.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from ninja import Schema, Path 3 | 4 | 5 | class PathDate(Schema): 6 | year: int 7 | month: int 8 | day: int 9 | 10 | def value(self): 11 | return datetime.date(self.year, self.month, self.day) 12 | 13 | 14 | @api.get("/events/{year}/{month}/{day}") 15 | def events(request, date: Path[PathDate]): 16 | return {"date": date.value()} 17 | -------------------------------------------------------------------------------- /docs/src/tutorial/path/code02.py: -------------------------------------------------------------------------------- 1 | @api.get("/items/{item_id}") 2 | def read_item(request, item_id: int): 3 | return {"item_id": item_id} 4 | -------------------------------------------------------------------------------- /docs/src/tutorial/query/code01.py: -------------------------------------------------------------------------------- 1 | weapons = ["Ninjato", "Shuriken", "Katana", "Kama", "Kunai", "Naginata", "Yari"] 2 | 3 | 4 | @api.get("/weapons") 5 | def list_weapons(request, limit: int = 10, offset: int = 0): 6 | return weapons[offset: offset + limit] 7 | -------------------------------------------------------------------------------- /docs/src/tutorial/query/code010.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import List 3 | 4 | from pydantic import Field 5 | 6 | from ninja import Query, Schema 7 | 8 | 9 | class Filters(Schema): 10 | limit: int = 100 11 | offset: int = None 12 | query: str = None 13 | category__in: List[str] = Field(None, alias="categories") 14 | 15 | 16 | @api.get("/filter") 17 | def events(request, filters: Query[Filters]): 18 | return {"filters": filters.dict()} 19 | -------------------------------------------------------------------------------- /docs/src/tutorial/query/code02.py: -------------------------------------------------------------------------------- 1 | weapons = ["Ninjato", "Shuriken", "Katana", "Kama", "Kunai", "Naginata", "Yari"] 2 | 3 | 4 | @api.get("/weapons/search") 5 | def search_weapons(request, q: str, offset: int = 0): 6 | results = [w for w in weapons if q in w.lower()] 7 | return results[offset : offset + 10] 8 | -------------------------------------------------------------------------------- /docs/src/tutorial/query/code03.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | 4 | @api.get("/example") 5 | def example(request, s: str = None, b: bool = None, d: date = None, i: int = None): 6 | return [s, b, d, i] 7 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.8 3 | 4 | show_column_numbers = True 5 | show_error_codes = True 6 | 7 | follow_imports = normal 8 | ignore_missing_imports = True 9 | 10 | # be strict 11 | disallow_untyped_calls = True 12 | warn_return_any = True 13 | strict_optional = True 14 | warn_no_return = True 15 | warn_redundant_casts = True 16 | warn_unused_ignores = True 17 | 18 | disallow_untyped_defs = True 19 | check_untyped_defs = True 20 | no_implicit_reexport = True 21 | 22 | [mypy-ninja.compatibility.*] 23 | ignore_errors = True 24 | -------------------------------------------------------------------------------- /ninja/__init__.py: -------------------------------------------------------------------------------- 1 | """Django Ninja - Fast Django REST framework""" 2 | 3 | __version__ = "1.4.0a" 4 | 5 | 6 | from pydantic import Field 7 | 8 | from ninja.files import UploadedFile 9 | from ninja.filter_schema import FilterSchema 10 | from ninja.main import NinjaAPI 11 | from ninja.openapi.docs import Redoc, Swagger 12 | from ninja.orm import ModelSchema 13 | from ninja.params import ( 14 | Body, 15 | BodyEx, 16 | Cookie, 17 | CookieEx, 18 | File, 19 | FileEx, 20 | Form, 21 | FormEx, 22 | Header, 23 | HeaderEx, 24 | P, 25 | Path, 26 | PathEx, 27 | Query, 28 | QueryEx, 29 | ) 30 | from ninja.patch_dict import PatchDict 31 | from ninja.router import Router 32 | from ninja.schema import Schema 33 | 34 | __all__ = [ 35 | "Field", 36 | "UploadedFile", 37 | "NinjaAPI", 38 | "Body", 39 | "Cookie", 40 | "File", 41 | "Form", 42 | "Header", 43 | "Path", 44 | "Query", 45 | "BodyEx", 46 | "CookieEx", 47 | "FileEx", 48 | "FormEx", 49 | "HeaderEx", 50 | "PathEx", 51 | "QueryEx", 52 | "Router", 53 | "P", 54 | "Schema", 55 | "ModelSchema", 56 | "FilterSchema", 57 | "Swagger", 58 | "Redoc", 59 | "PatchDict", 60 | ] 61 | -------------------------------------------------------------------------------- /ninja/compatibility/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/ninja/compatibility/__init__.py -------------------------------------------------------------------------------- /ninja/compatibility/util.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | __all__ = ["UNION_TYPES"] 4 | 5 | 6 | # python3.10+ syntax of creating a union or optional type (with str | int) 7 | # UNION_TYPES allows to check both universes if types are a union 8 | try: 9 | from types import UnionType 10 | 11 | UNION_TYPES = (Union, UnionType) 12 | except ImportError: 13 | UNION_TYPES = (Union,) 14 | -------------------------------------------------------------------------------- /ninja/conf.py: -------------------------------------------------------------------------------- 1 | from math import inf 2 | from typing import Dict, Optional 3 | 4 | from django.conf import settings as django_settings 5 | from pydantic import BaseModel, Field 6 | 7 | 8 | class Settings(BaseModel): 9 | # Pagination 10 | PAGINATION_CLASS: str = Field( 11 | "ninja.pagination.LimitOffsetPagination", alias="NINJA_PAGINATION_CLASS" 12 | ) 13 | PAGINATION_PER_PAGE: int = Field(100, alias="NINJA_PAGINATION_PER_PAGE") 14 | PAGINATION_MAX_LIMIT: int = Field(inf, alias="NINJA_PAGINATION_MAX_LIMIT") # type: ignore 15 | 16 | # Throttling 17 | NUM_PROXIES: Optional[int] = Field(None, alias="NINJA_NUM_PROXIES") 18 | DEFAULT_THROTTLE_RATES: Dict[str, Optional[str]] = Field( 19 | { 20 | "auth": "10000/day", 21 | "user": "10000/day", 22 | "anon": "1000/day", 23 | }, 24 | alias="NINJA_DEFAULT_THROTTLE_RATES", 25 | ) 26 | 27 | class Config: 28 | from_attributes = True 29 | 30 | 31 | settings = Settings.model_validate(django_settings) 32 | 33 | if hasattr(django_settings, "NINJA_DOCS_VIEW"): 34 | raise Exception( 35 | "NINJA_DOCS_VIEW is removed. Use NinjaAPI(docs=...) instead" 36 | ) # pragma: no cover 37 | -------------------------------------------------------------------------------- /ninja/constants.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | __all__ = ["NOT_SET"] 4 | 5 | 6 | class NOT_SET_TYPE: 7 | def __repr__(self) -> str: # pragma: no cover 8 | return f"{__name__}.{self.__class__.__name__}" 9 | 10 | def __copy__(self) -> Any: 11 | return NOT_SET 12 | 13 | def __deepcopy__(self, memodict: Optional[Dict] = None) -> Any: 14 | return NOT_SET 15 | 16 | 17 | NOT_SET = NOT_SET_TYPE() 18 | -------------------------------------------------------------------------------- /ninja/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import Any, Callable, Tuple 3 | 4 | from ninja.operation import Operation 5 | from ninja.types import TCallable 6 | from ninja.utils import contribute_operation_callback 7 | 8 | # Since @api.method decorator is applied to function 9 | # that is not always returns a HttpResponse object 10 | # there is no way to apply some standard decorators form 11 | # django stdlib or public plugins 12 | # 13 | # @decorate_view allows to apply any view decorator to Ninja api operation 14 | # 15 | # @api.get("/some") 16 | # @decorate_view(cache_page(60 * 15)) # <------- 17 | # def some(request): 18 | # ... 19 | # 20 | 21 | 22 | def decorate_view(*decorators: Callable[..., Any]) -> Callable[[TCallable], TCallable]: 23 | def outer_wrapper(op_func: TCallable) -> TCallable: 24 | if hasattr(op_func, "_ninja_operation"): 25 | # Means user used decorate_view on top of @api.method 26 | _apply_decorators(decorators, op_func._ninja_operation) # type: ignore 27 | else: 28 | # Means user used decorate_view after(bottom) of @api.method 29 | contribute_operation_callback( 30 | op_func, partial(_apply_decorators, decorators) 31 | ) 32 | 33 | return op_func 34 | 35 | return outer_wrapper 36 | 37 | 38 | def _apply_decorators( 39 | decorators: Tuple[Callable[..., Any]], operation: Operation 40 | ) -> None: 41 | for deco in decorators: 42 | operation.run = deco(operation.run) # type: ignore 43 | -------------------------------------------------------------------------------- /ninja/enum.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Tuple, TypeVar 2 | 3 | import django 4 | 5 | # Because this isn't supported with Django <5, we need to ignore coverage for 6 | # unsupported versions. 7 | 8 | if django.VERSION[0] < 5: # pragma: no cover 9 | 10 | class NinjaChoicesType(type): ... 11 | 12 | class ChoicesMixin: ... 13 | 14 | else: # pragma: no cover 15 | from django.db.models.enums import ChoicesType 16 | 17 | class NinjaChoicesType(ChoicesType): # type: ignore[no-redef] 18 | @property 19 | def choices(self) -> "List[Tuple[Any, str]]": 20 | return NinjaChoicesList(super().choices, choices_enum=self) 21 | 22 | class ChoicesMixin(metaclass=NinjaChoicesType): # type: ignore[no-redef] 23 | pass 24 | 25 | 26 | ListMemberType = TypeVar("ListMemberType") 27 | 28 | 29 | class NinjaChoicesList(List[ListMemberType]): 30 | def __init__( 31 | self, *args: Any, choices_enum: NinjaChoicesType, **kwargs: Any 32 | ) -> None: # pragma: no cover 33 | self.enum = choices_enum 34 | super().__init__(*args, **kwargs) 35 | -------------------------------------------------------------------------------- /ninja/files.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Dict 2 | 3 | from django.core.files.uploadedfile import UploadedFile as DjangoUploadedFile 4 | from pydantic_core import core_schema 5 | 6 | __all__ = ["UploadedFile"] 7 | 8 | 9 | class UploadedFile(DjangoUploadedFile): 10 | @classmethod 11 | def __get_pydantic_json_schema__( 12 | cls, core_schema: Any, handler: Callable[..., Any] 13 | ) -> Dict: 14 | # calling handler(core_schema) here raises an exception 15 | json_schema: Dict[str, str] = {} 16 | json_schema.update(type="string", format="binary") 17 | return json_schema 18 | 19 | @classmethod 20 | def _validate(cls, v: Any, _: Any) -> Any: 21 | if not isinstance(v, DjangoUploadedFile): 22 | raise ValueError(f"Expected UploadFile, received: {type(v)}") 23 | return v 24 | 25 | @classmethod 26 | def __get_pydantic_core_schema__( 27 | cls, source: Any, handler: Callable[..., Any] 28 | ) -> Any: 29 | return core_schema.with_info_plain_validator_function(cls._validate) 30 | -------------------------------------------------------------------------------- /ninja/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/ninja/management/__init__.py -------------------------------------------------------------------------------- /ninja/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/ninja/management/commands/__init__.py -------------------------------------------------------------------------------- /ninja/management/commands/export_openapi_schema.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import Any, Optional 4 | 5 | from django.core.management.base import BaseCommand, CommandError, CommandParser 6 | from django.urls.base import resolve 7 | from django.utils.module_loading import import_string 8 | 9 | from ninja.main import NinjaAPI 10 | from ninja.management.utils import command_docstring 11 | from ninja.responses import NinjaJSONEncoder 12 | 13 | 14 | class Command(BaseCommand): 15 | """ 16 | Example: 17 | 18 | ```terminal 19 | python manage.py export_openapi_schema 20 | ``` 21 | 22 | ```terminal 23 | python manage.py export_openapi_schema --api project.urls.api 24 | ``` 25 | """ 26 | 27 | help = "Exports Open API schema" 28 | 29 | def _get_api_instance(self, api_path: Optional[str] = None) -> NinjaAPI: 30 | if not api_path: 31 | try: 32 | return resolve("/api/").func.keywords["api"] # type: ignore 33 | except AttributeError: 34 | raise CommandError( 35 | "No NinjaAPI instance found; please specify one with --api" 36 | ) from None 37 | 38 | try: 39 | api = import_string(api_path) 40 | except ImportError: 41 | raise CommandError( 42 | f"Module or attribute for {api_path} not found!" 43 | ) from None 44 | 45 | if not isinstance(api, NinjaAPI): 46 | raise CommandError(f"{api_path} is not instance of NinjaAPI!") 47 | 48 | return api 49 | 50 | def add_arguments(self, parser: CommandParser) -> None: 51 | parser.add_argument( 52 | "--api", 53 | dest="api", 54 | default=None, 55 | type=str, 56 | help="Specify api instance module", 57 | ) 58 | parser.add_argument( 59 | "--output", 60 | dest="output", 61 | default=None, 62 | type=str, 63 | help="Output schema to a file (outputs to stdout if omitted).", 64 | ) 65 | parser.add_argument( 66 | "--indent", dest="indent", default=None, type=int, help="JSON indent" 67 | ) 68 | parser.add_argument( 69 | "--sorted", 70 | dest="sort_keys", 71 | default=False, 72 | action="store_true", 73 | help="Sort Json keys", 74 | ) 75 | 76 | def handle(self, *args: Any, **options: Any) -> None: 77 | api = self._get_api_instance(options["api"]) 78 | schema = api.get_openapi_schema() 79 | result = json.dumps( 80 | schema, 81 | cls=NinjaJSONEncoder, 82 | indent=options["indent"], 83 | sort_keys=options["sort_keys"], 84 | ) 85 | 86 | if options["output"]: 87 | with Path(options["output"]).open("wb") as f: 88 | f.write(result.encode()) 89 | else: 90 | self.stdout.write(result) 91 | 92 | 93 | __doc__ = command_docstring(Command) 94 | -------------------------------------------------------------------------------- /ninja/management/utils.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | from typing import Type 3 | 4 | from django.core.management.base import BaseCommand 5 | 6 | 7 | def command_docstring(cmd: Type[BaseCommand]) -> str: 8 | base_args = [] 9 | if cmd is not BaseCommand: # pragma: no branch 10 | base_parser = cmd().create_parser("base", "") 11 | for group in base_parser._action_groups: 12 | for action in group._group_actions: 13 | base_args.append(",".join(action.option_strings)) 14 | parser = cmd().create_parser("command", "") 15 | doc = parser.description or "" 16 | 17 | if cmd.__doc__: # pragma: no branch 18 | if doc: # pragma: no branch 19 | doc += "\n\n" 20 | doc += textwrap.dedent(cmd.__doc__) 21 | args = [] 22 | for group in parser._action_groups: 23 | for action in group._group_actions: 24 | if "--help" in action.option_strings: 25 | continue 26 | name = ",".join(action.option_strings) 27 | action_type = action.type 28 | if not action_type and action.nargs != 0: 29 | action_type = str 30 | if action_type: 31 | if isinstance(action_type, type): # pragma: no branch 32 | action_type = action_type.__name__ 33 | name += f" ({action_type})" 34 | help = action.help or "" 35 | if help and not action.required and action.nargs != 0: 36 | if not help.endswith("."): 37 | help += "." 38 | if action.default is not None: 39 | help += f" Defaults to {action.default}." 40 | else: 41 | help += " Optional." 42 | args.append((name, help)) 43 | # Sort args from this class first, then base args. 44 | args.sort(key=lambda o: (o[0] in base_args, o[0])) 45 | if args: # pragma: no branch 46 | doc += "\n\nAttributes:" 47 | for name, description in args: 48 | doc += f"\n {name}: {description}" 49 | return doc 50 | -------------------------------------------------------------------------------- /ninja/openapi/__init__.py: -------------------------------------------------------------------------------- 1 | from ninja.openapi.schema import get_schema 2 | 3 | __all__ = ["get_schema"] 4 | -------------------------------------------------------------------------------- /ninja/openapi/urls.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import TYPE_CHECKING, Any, List 3 | 4 | from django.urls import path 5 | 6 | from .views import default_home, openapi_json, openapi_view 7 | 8 | if TYPE_CHECKING: 9 | from ninja import NinjaAPI # pragma: no cover 10 | 11 | __all__ = ["get_openapi_urls", "get_root_url"] 12 | 13 | 14 | def get_openapi_urls(api: "NinjaAPI") -> List[Any]: 15 | result = [] 16 | 17 | if api.openapi_url: 18 | view = partial(openapi_json, api=api) 19 | if api.docs_decorator: 20 | view = api.docs_decorator(view) # type: ignore 21 | result.append( 22 | path(api.openapi_url.lstrip("/"), view, name="openapi-json"), 23 | ) 24 | 25 | assert ( 26 | api.openapi_url != api.docs_url 27 | ), "Please use different urls for openapi_url and docs_url" 28 | 29 | if api.docs_url: 30 | view = partial(openapi_view, api=api) 31 | if api.docs_decorator: 32 | view = api.docs_decorator(view) # type: ignore 33 | result.append( 34 | path(api.docs_url.lstrip("/"), view, name="openapi-view"), 35 | ) 36 | 37 | return result 38 | 39 | 40 | def get_root_url(api: "NinjaAPI") -> Any: 41 | return path("", partial(default_home, api=api), name="api-root") 42 | -------------------------------------------------------------------------------- /ninja/openapi/views.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, NoReturn 2 | 3 | from django.http import Http404, HttpRequest, HttpResponse 4 | 5 | from ninja.openapi.docs import DocsBase 6 | from ninja.responses import Response 7 | 8 | if TYPE_CHECKING: 9 | # if anyone knows a cleaner way to make mypy happy - welcome 10 | from ninja import NinjaAPI # pragma: no cover 11 | 12 | 13 | def default_home(request: HttpRequest, api: "NinjaAPI", **kwargs: Any) -> NoReturn: 14 | "This view is mainly needed to determine the full path for API operations" 15 | docs_url = f"{request.path}{api.docs_url}".replace("//", "/") 16 | raise Http404(f"docs_url = {docs_url}") 17 | 18 | 19 | def openapi_json(request: HttpRequest, api: "NinjaAPI", **kwargs: Any) -> HttpResponse: 20 | schema = api.get_openapi_schema(path_params=kwargs) 21 | return Response(schema) 22 | 23 | 24 | def openapi_view(request: HttpRequest, api: "NinjaAPI", **kwargs: Any) -> HttpResponse: 25 | docs: DocsBase = api.docs 26 | return docs.render_page(request, api, **kwargs) 27 | -------------------------------------------------------------------------------- /ninja/orm/__init__.py: -------------------------------------------------------------------------------- 1 | from ninja.orm.factory import create_schema 2 | from ninja.orm.fields import register_field 3 | from ninja.orm.metaclass import ModelSchema 4 | 5 | __all__ = ["create_schema", "register_field", "ModelSchema"] 6 | -------------------------------------------------------------------------------- /ninja/orm/shortcuts.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Type 2 | 3 | from ninja import Schema 4 | from ninja.orm.factory import create_schema 5 | 6 | __all__ = ["S", "L"] 7 | 8 | 9 | # GOAL: 10 | # from ninja.orm import S, L 11 | # S(Job) -> JobSchema? Job? 12 | # S(Job) -> should reuse already created schema 13 | # S(Job, fields='xxx') -> new schema ? how to name Job1 , 2, 3 and so on ? 14 | # L(Job) -> List[Job] 15 | 16 | 17 | def S(model: Any, **kwargs: Any) -> Type[Schema]: 18 | return create_schema(model, **kwargs) 19 | 20 | 21 | def L(model: Any, **kwargs: Any) -> List[Any]: 22 | schema = S(model, **kwargs) 23 | return List[schema] # type: ignore 24 | -------------------------------------------------------------------------------- /ninja/parser.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import List, cast 3 | 4 | from django.http import HttpRequest 5 | from django.utils.datastructures import MultiValueDict 6 | 7 | from ninja.types import DictStrAny 8 | 9 | __all__ = ["Parser"] 10 | 11 | 12 | class Parser: 13 | "Default json parser" 14 | 15 | def parse_body(self, request: HttpRequest) -> DictStrAny: 16 | return cast(DictStrAny, json.loads(request.body)) 17 | 18 | def parse_querydict( 19 | self, data: MultiValueDict, list_fields: List[str], request: HttpRequest 20 | ) -> DictStrAny: 21 | result: DictStrAny = {} 22 | for key in data.keys(): 23 | if key in list_fields: 24 | result[key] = data.getlist(key) 25 | else: 26 | result[key] = data[key] 27 | return result 28 | -------------------------------------------------------------------------------- /ninja/patch_dict.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Dict, Optional, Type 2 | 3 | from pydantic_core import core_schema 4 | from typing_extensions import Annotated 5 | 6 | from ninja import Body 7 | from ninja.utils import is_optional_type 8 | 9 | 10 | class ModelToDict(dict): 11 | _wrapped_model: Any = None 12 | _wrapped_model_dump_params: Dict[str, Any] = {} 13 | 14 | @classmethod 15 | def __get_pydantic_core_schema__(cls, _source: Any, _handler: Any) -> Any: 16 | return core_schema.no_info_after_validator_function( 17 | cls._validate, 18 | cls._wrapped_model.__pydantic_core_schema__, 19 | ) 20 | 21 | @classmethod 22 | def _validate(cls, input_value: Any) -> Any: 23 | return input_value.model_dump(**cls._wrapped_model_dump_params) 24 | 25 | 26 | def create_patch_schema(schema_cls: Type[Any]) -> Type[ModelToDict]: 27 | values, annotations = {}, {} 28 | for f in schema_cls.__fields__.keys(): 29 | t = schema_cls.__annotations__[f] 30 | if not is_optional_type(t): 31 | values[f] = getattr(schema_cls, f, None) 32 | annotations[f] = Optional[t] 33 | values["__annotations__"] = annotations 34 | OptionalSchema = type(f"{schema_cls.__name__}Patch", (schema_cls,), values) 35 | 36 | class OptionalDictSchema(ModelToDict): 37 | _wrapped_model = OptionalSchema 38 | _wrapped_model_dump_params = {"exclude_unset": True} 39 | 40 | return OptionalDictSchema 41 | 42 | 43 | class PatchDictUtil: 44 | def __getitem__(self, schema_cls: Any) -> Any: 45 | new_cls = create_patch_schema(schema_cls) 46 | return Body[new_cls] # type: ignore 47 | 48 | 49 | if TYPE_CHECKING: # pragma: nocover 50 | PatchDict = Annotated[dict, ""] 51 | else: 52 | PatchDict = PatchDictUtil() 53 | -------------------------------------------------------------------------------- /ninja/py.typed: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ninja/renderers.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Mapping, Optional, Type 3 | 4 | from django.http import HttpRequest 5 | 6 | from ninja.responses import NinjaJSONEncoder 7 | 8 | __all__ = ["BaseRenderer", "JSONRenderer"] 9 | 10 | 11 | class BaseRenderer: 12 | media_type: Optional[str] = None 13 | charset: str = "utf-8" 14 | 15 | def render(self, request: HttpRequest, data: Any, *, response_status: int) -> Any: 16 | raise NotImplementedError("Please implement .render() method") 17 | 18 | 19 | class JSONRenderer(BaseRenderer): 20 | media_type = "application/json" 21 | encoder_class: Type[json.JSONEncoder] = NinjaJSONEncoder 22 | json_dumps_params: Mapping[str, Any] = {} 23 | 24 | def render(self, request: HttpRequest, data: Any, *, response_status: int) -> Any: 25 | return json.dumps(data, cls=self.encoder_class, **self.json_dumps_params) 26 | -------------------------------------------------------------------------------- /ninja/responses.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network 3 | from typing import Any, FrozenSet 4 | 5 | from django.core.serializers.json import DjangoJSONEncoder 6 | from django.http import JsonResponse 7 | from pydantic import BaseModel 8 | from pydantic_core import Url 9 | 10 | __all__ = [ 11 | "NinjaJSONEncoder", 12 | "Response", 13 | "codes_1xx", 14 | "codes_2xx", 15 | "codes_3xx", 16 | "codes_4xx", 17 | "codes_5xx", 18 | ] 19 | 20 | 21 | class NinjaJSONEncoder(DjangoJSONEncoder): 22 | def default(self, o: Any) -> Any: 23 | if isinstance(o, BaseModel): 24 | return o.model_dump() 25 | if isinstance(o, Url): 26 | return str(o) 27 | if isinstance(o, (IPv4Address, IPv4Network, IPv6Address, IPv6Network)): 28 | return str(o) 29 | if isinstance(o, Enum): 30 | return str(o) 31 | return super().default(o) 32 | 33 | 34 | class Response(JsonResponse): 35 | def __init__(self, data: Any, **kwargs: Any) -> None: 36 | super().__init__(data, encoder=NinjaJSONEncoder, safe=False, **kwargs) 37 | 38 | 39 | def resp_codes(from_code: int, to_code: int) -> FrozenSet[int]: 40 | return frozenset(range(from_code, to_code + 1)) 41 | 42 | 43 | # most common http status codes 44 | codes_1xx = resp_codes(100, 101) 45 | codes_2xx = resp_codes(200, 206) 46 | codes_3xx = resp_codes(300, 308) 47 | codes_4xx = resp_codes(400, 412) | frozenset({416, 418, 425, 429, 451}) 48 | codes_5xx = resp_codes(500, 504) 49 | -------------------------------------------------------------------------------- /ninja/security/__init__.py: -------------------------------------------------------------------------------- 1 | from ninja.security.apikey import APIKeyCookie, APIKeyHeader, APIKeyQuery 2 | from ninja.security.http import HttpBasicAuth, HttpBearer 3 | from ninja.security.session import SessionAuth, SessionAuthSuperUser 4 | 5 | __all__ = [ 6 | "APIKeyCookie", 7 | "APIKeyHeader", 8 | "APIKeyQuery", 9 | "HttpBasicAuth", 10 | "HttpBearer", 11 | "SessionAuth", 12 | "SessionAuthSuperUser", 13 | "django_auth", 14 | "django_auth_superuser", 15 | ] 16 | 17 | django_auth = SessionAuth() 18 | django_auth_superuser = SessionAuthSuperUser() 19 | -------------------------------------------------------------------------------- /ninja/security/apikey.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any, Optional 3 | 4 | from django.http import HttpRequest 5 | 6 | from ninja.errors import HttpError 7 | from ninja.security.base import AuthBase 8 | from ninja.utils import check_csrf 9 | 10 | __all__ = ["APIKeyBase", "APIKeyQuery", "APIKeyCookie", "APIKeyHeader"] 11 | 12 | 13 | class APIKeyBase(AuthBase, ABC): 14 | openapi_type: str = "apiKey" 15 | param_name: str = "key" 16 | 17 | def __init__(self) -> None: 18 | self.openapi_name = self.param_name # this sets the name of the security schema 19 | super().__init__() 20 | 21 | def __call__(self, request: HttpRequest) -> Optional[Any]: 22 | key = self._get_key(request) 23 | return self.authenticate(request, key) 24 | 25 | @abstractmethod 26 | def _get_key(self, request: HttpRequest) -> Optional[str]: 27 | pass # pragma: no cover 28 | 29 | @abstractmethod 30 | def authenticate(self, request: HttpRequest, key: Optional[str]) -> Optional[Any]: 31 | pass # pragma: no cover 32 | 33 | 34 | class APIKeyQuery(APIKeyBase, ABC): 35 | openapi_in: str = "query" 36 | 37 | def _get_key(self, request: HttpRequest) -> Optional[str]: 38 | return request.GET.get(self.param_name) 39 | 40 | 41 | class APIKeyCookie(APIKeyBase, ABC): 42 | openapi_in: str = "cookie" 43 | 44 | def __init__(self, csrf: bool = True) -> None: 45 | self.csrf = csrf 46 | super().__init__() 47 | 48 | def _get_key(self, request: HttpRequest) -> Optional[str]: 49 | if self.csrf: 50 | error_response = check_csrf(request) 51 | if error_response: 52 | raise HttpError(403, "CSRF check Failed") 53 | return request.COOKIES.get(self.param_name) 54 | 55 | 56 | class APIKeyHeader(APIKeyBase, ABC): 57 | openapi_in: str = "header" 58 | 59 | def _get_key(self, request: HttpRequest) -> Optional[str]: 60 | headers = request.headers 61 | return headers.get(self.param_name) 62 | -------------------------------------------------------------------------------- /ninja/security/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any, Optional 3 | 4 | from django.http import HttpRequest 5 | 6 | from ninja.errors import ConfigError 7 | from ninja.utils import is_async_callable 8 | 9 | __all__ = ["SecuritySchema", "AuthBase"] 10 | 11 | 12 | class SecuritySchema(dict): 13 | def __init__(self, type: str, **kwargs: Any) -> None: 14 | super().__init__(type=type, **kwargs) 15 | 16 | 17 | class AuthBase(ABC): 18 | def __init__(self) -> None: 19 | if not hasattr(self, "openapi_type"): 20 | raise ConfigError("If you extend AuthBase you need to define openapi_type") 21 | 22 | kwargs = {} 23 | for attr in dir(self): 24 | if attr.startswith("openapi_"): 25 | name = attr.replace("openapi_", "", 1) 26 | kwargs[name] = getattr(self, attr) 27 | self.openapi_security_schema = SecuritySchema(**kwargs) 28 | 29 | self.is_async = False 30 | if hasattr(self, "authenticate"): # pragma: no branch 31 | self.is_async = is_async_callable(self.authenticate) 32 | 33 | @abstractmethod 34 | def __call__(self, request: HttpRequest) -> Optional[Any]: 35 | pass # pragma: no cover 36 | -------------------------------------------------------------------------------- /ninja/security/http.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC, abstractmethod 3 | from base64 import b64decode 4 | from typing import Any, Optional, Tuple 5 | from urllib.parse import unquote 6 | 7 | from django.conf import settings 8 | from django.http import HttpRequest 9 | 10 | from ninja.security.base import AuthBase 11 | 12 | __all__ = ["HttpAuthBase", "HttpBearer", "DecodeError", "HttpBasicAuth"] 13 | 14 | 15 | logger = logging.getLogger("django") 16 | 17 | 18 | class HttpAuthBase(AuthBase, ABC): 19 | openapi_type: str = "http" 20 | 21 | 22 | class HttpBearer(HttpAuthBase, ABC): 23 | openapi_scheme: str = "bearer" 24 | header: str = "Authorization" 25 | 26 | def __call__(self, request: HttpRequest) -> Optional[Any]: 27 | headers = request.headers 28 | auth_value = headers.get(self.header) 29 | if not auth_value: 30 | return None 31 | parts = auth_value.split(" ") 32 | 33 | if parts[0].lower() != self.openapi_scheme: 34 | if settings.DEBUG: 35 | logger.error(f"Unexpected auth - '{auth_value}'") 36 | return None 37 | token = " ".join(parts[1:]) 38 | return self.authenticate(request, token) 39 | 40 | @abstractmethod 41 | def authenticate(self, request: HttpRequest, token: str) -> Optional[Any]: 42 | pass # pragma: no cover 43 | 44 | 45 | class DecodeError(Exception): 46 | pass 47 | 48 | 49 | class HttpBasicAuth(HttpAuthBase, ABC): # TODO: maybe HttpBasicAuthBase 50 | openapi_scheme = "basic" 51 | header = "Authorization" 52 | 53 | def __call__(self, request: HttpRequest) -> Optional[Any]: 54 | headers = request.headers 55 | auth_value = headers.get(self.header) 56 | if not auth_value: 57 | return None 58 | 59 | try: 60 | username, password = self.decode_authorization(auth_value) 61 | except DecodeError as e: 62 | if settings.DEBUG: 63 | logger.exception(e) 64 | return None 65 | return self.authenticate(request, username, password) 66 | 67 | @abstractmethod 68 | def authenticate( 69 | self, request: HttpRequest, username: str, password: str 70 | ) -> Optional[Any]: 71 | pass # pragma: no cover 72 | 73 | def decode_authorization(self, value: str) -> Tuple[str, str]: 74 | parts = value.split(" ") 75 | if len(parts) == 1: 76 | user_pass_encoded = parts[0] 77 | elif len(parts) == 2 and parts[0].lower() == "basic": 78 | user_pass_encoded = parts[1] 79 | else: 80 | raise DecodeError("Invalid Authorization header") 81 | 82 | try: 83 | username, password = b64decode(user_pass_encoded).decode().split(":", 1) 84 | return unquote(username), unquote(password) 85 | except Exception as e: # dear contributors please do not change to valueerror - here can be multiple exceptions 86 | raise DecodeError("Invalid Authorization header") from e 87 | -------------------------------------------------------------------------------- /ninja/security/session.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from django.conf import settings 4 | from django.http import HttpRequest 5 | 6 | from ninja.security.apikey import APIKeyCookie 7 | 8 | __all__ = ["SessionAuth", "SessionAuthSuperUser"] 9 | 10 | 11 | class SessionAuth(APIKeyCookie): 12 | "Reusing Django session authentication" 13 | 14 | param_name: str = settings.SESSION_COOKIE_NAME 15 | 16 | def authenticate(self, request: HttpRequest, key: Optional[str]) -> Optional[Any]: 17 | if request.user.is_authenticated: 18 | return request.user 19 | 20 | return None 21 | 22 | 23 | class SessionAuthSuperUser(APIKeyCookie): 24 | "Reusing Django session authentication & verify that the user is a super user" 25 | 26 | param_name: str = settings.SESSION_COOKIE_NAME 27 | 28 | def authenticate(self, request: HttpRequest, key: Optional[str]) -> Optional[Any]: 29 | is_superuser = getattr(request.user, "is_superuser", None) 30 | if request.user.is_authenticated and is_superuser: 31 | return request.user 32 | 33 | return None 34 | -------------------------------------------------------------------------------- /ninja/signature/__init__.py: -------------------------------------------------------------------------------- 1 | from ninja.signature.details import ViewSignature 2 | from ninja.signature.utils import is_async 3 | 4 | __all__ = ["ViewSignature", "is_async"] 5 | -------------------------------------------------------------------------------- /ninja/static/ninja/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/ninja/static/ninja/favicon.png -------------------------------------------------------------------------------- /ninja/static/ninja/swagger-ui-init.js: -------------------------------------------------------------------------------- 1 | /**JS file for handling the SwaggerUIBundle and avoid inline script */ 2 | const csrfSettings = document.querySelector("body").dataset 3 | const configJson = document.getElementById("swagger-settings").textContent; 4 | const configObject = JSON.parse(configJson); 5 | 6 | configObject.dom_id = "#swagger-ui"; 7 | configObject.presets = [ 8 | SwaggerUIBundle.presets.apis, 9 | SwaggerUIBundle.SwaggerUIStandalonePreset 10 | ]; 11 | 12 | if (csrfSettings.apiCsrf && csrfSettings.csrfToken) { 13 | configObject.requestInterceptor = (req) => { 14 | req.headers['X-CSRFToken'] = csrfSettings.csrfToken 15 | return req; 16 | }; 17 | }; 18 | 19 | 20 | // {% if add_csrf %} 21 | // configObject.requestInterceptor = (req) => { 22 | // req.headers['X-CSRFToken'] = "{{csrf_token}}"; 23 | // return req; 24 | // }; 25 | // {% endif %} 26 | 27 | const ui = SwaggerUIBundle(configObject); 28 | 29 | 30 | 31 | // SwaggerUIBundle({ 32 | // url: swaggerUi.dataset.openapiUrl, 33 | // dom_id: '#swagger-ui', 34 | // presets: [ 35 | // SwaggerUIBundle.presets.apis, 36 | // SwaggerUIBundle.SwaggerUIStandalonePreset 37 | // ], 38 | // layout: "BaseLayout", 39 | // requestInterceptor: (req) => { 40 | // if (swaggerUi.dataset.apiCsrf && swaggerUi.dataset.csrfToken) { 41 | // req.headers['X-CSRFToken'] = swaggerUi.dataset.csrfToken 42 | // } 43 | // return req; 44 | // }, 45 | // deepLinking: true 46 | // }) -------------------------------------------------------------------------------- /ninja/templates/ninja/redoc.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | {{ api.title }} 7 | 8 | 13 | 14 | 15 |
16 | 17 | 18 | 21 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /ninja/templates/ninja/redoc_cdn.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ api.title }} 6 | 7 | 12 | 13 | 14 |
15 | 16 | 17 | 20 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /ninja/templates/ninja/swagger.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | {{ api.title }} 8 | 9 | 12 | 13 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /ninja/templates/ninja/swagger_cdn.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ api.title }} 7 | 8 | 9 |
10 |
11 | 12 | 13 | 16 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /ninja/testing/__init__.py: -------------------------------------------------------------------------------- 1 | from ninja.testing.client import TestAsyncClient, TestClient 2 | 3 | __all__ = ["TestClient", "TestAsyncClient"] 4 | -------------------------------------------------------------------------------- /ninja/types.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Dict, TypeVar 2 | 3 | __all__ = ["DictStrAny", "TCallable"] 4 | 5 | DictStrAny = Dict[str, Any] 6 | 7 | TCallable = TypeVar("TCallable", bound=Callable[..., Any]) 8 | 9 | 10 | # unfortunately this doesn't work yet, see 11 | # https://github.com/python/mypy/issues/3924 12 | # Decorator = Callable[[TCallable], TCallable] 13 | -------------------------------------------------------------------------------- /ninja/utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Any, Callable, Optional, Type 3 | 4 | from django.conf import settings 5 | from django.http import HttpRequest, HttpResponseForbidden 6 | from django.middleware.csrf import CsrfViewMiddleware 7 | 8 | __all__ = [ 9 | "check_csrf", 10 | "is_debug_server", 11 | "normalize_path", 12 | "contribute_operation_callback", 13 | ] 14 | 15 | 16 | def replace_path_param_notation(path: str) -> str: 17 | return path.replace("{", "<").replace("}", ">") 18 | 19 | 20 | def normalize_path(path: str) -> str: 21 | while "//" in path: 22 | path = path.replace("//", "/") 23 | return path 24 | 25 | 26 | def _no_view() -> None: 27 | pass # pragma: no cover 28 | 29 | 30 | def check_csrf( 31 | request: HttpRequest, callback: Callable = _no_view 32 | ) -> Optional[HttpResponseForbidden]: 33 | mware = CsrfViewMiddleware(lambda x: HttpResponseForbidden()) # pragma: no cover 34 | request.csrf_processing_done = False # type: ignore 35 | mware.process_request(request) 36 | return mware.process_view(request, callback, (), {}) 37 | 38 | 39 | def is_debug_server() -> bool: 40 | """Check if running under the Django Debug Server""" 41 | return settings.DEBUG and any( 42 | s.filename.endswith("runserver.py") and s.function == "run" 43 | for s in inspect.stack(0)[1:] 44 | ) 45 | 46 | 47 | def is_async_callable(f: Callable[..., Any]) -> bool: 48 | return inspect.iscoroutinefunction(f) or inspect.iscoroutinefunction( 49 | getattr(f, "__call__", None) 50 | ) 51 | 52 | 53 | def is_optional_type(t: Type[Any]) -> bool: 54 | try: 55 | return type(None) in t.__args__ 56 | except AttributeError: 57 | return False 58 | 59 | 60 | def contribute_operation_callback( 61 | func: Callable[..., Any], callback: Callable[..., Any] 62 | ) -> None: 63 | if not hasattr(func, "_ninja_contribute_to_operation"): 64 | func._ninja_contribute_to_operation = [] # type: ignore 65 | func._ninja_contribute_to_operation.append(callback) # type: ignore 66 | 67 | 68 | def contribute_operation_args( 69 | func: Callable[..., Any], arg_name: str, arg_type: Type, arg_source: Any 70 | ) -> None: 71 | if not hasattr(func, "_ninja_contribute_args"): 72 | func._ninja_contribute_args = [] # type: ignore 73 | func._ninja_contribute_args.append((arg_name, arg_type, arg_source)) # type: ignore 74 | -------------------------------------------------------------------------------- /scripts/build-docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -x 3 | set -e 4 | 5 | pip install -r docs/requirements.txt 6 | 7 | cd docs 8 | PYTHONPATH=../ mkdocs build 9 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from pathlib import Path 4 | 5 | ROOT = Path(__file__).parent.parent.resolve() 6 | 7 | sys.path.insert(0, str(ROOT)) 8 | sys.path.insert(0, str(ROOT / "tests/demo_project")) 9 | 10 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings") 11 | 12 | import django # noqa 13 | 14 | django.setup() 15 | 16 | 17 | def pytest_generate_tests(metafunc): 18 | os.environ["NINJA_SKIP_REGISTRY"] = "yes" 19 | -------------------------------------------------------------------------------- /tests/demo_project/demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/tests/demo_project/demo/__init__.py -------------------------------------------------------------------------------- /tests/demo_project/demo/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for demo 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/3.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | import sys 12 | 13 | from django.core.asgi import get_asgi_application 14 | 15 | sys.path.insert(0, "../../") 16 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings") 17 | 18 | application = get_asgi_application() 19 | -------------------------------------------------------------------------------- /tests/demo_project/demo/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | BASE_DIR = Path(__file__).parent.parent 4 | 5 | 6 | SECRET_KEY = "NOT-SUPER-SECRET-DO-NOT-USE-ME" 7 | 8 | 9 | DEBUG = True 10 | 11 | ALLOWED_HOSTS = [] 12 | 13 | 14 | INSTALLED_APPS = [ 15 | "django.contrib.admin", 16 | "django.contrib.auth", 17 | "django.contrib.contenttypes", 18 | "django.contrib.sessions", 19 | "django.contrib.messages", 20 | "django.contrib.staticfiles", 21 | "ninja", 22 | "someapp", 23 | "multi_param", 24 | ] 25 | 26 | MIDDLEWARE = [ 27 | "django.middleware.security.SecurityMiddleware", 28 | "django.contrib.sessions.middleware.SessionMiddleware", 29 | "django.middleware.common.CommonMiddleware", 30 | "django.middleware.csrf.CsrfViewMiddleware", 31 | "django.contrib.auth.middleware.AuthenticationMiddleware", 32 | "django.contrib.messages.middleware.MessageMiddleware", 33 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 34 | ] 35 | 36 | ROOT_URLCONF = "demo.urls" 37 | 38 | TEMPLATES = [ 39 | { 40 | "BACKEND": "django.template.backends.django.DjangoTemplates", 41 | "DIRS": [], 42 | "APP_DIRS": True, 43 | "OPTIONS": { 44 | "context_processors": [ 45 | "django.template.context_processors.debug", 46 | "django.template.context_processors.request", 47 | "django.contrib.auth.context_processors.auth", 48 | "django.contrib.messages.context_processors.messages", 49 | ], 50 | }, 51 | }, 52 | ] 53 | 54 | WSGI_APPLICATION = "demo.wsgi.application" 55 | 56 | 57 | DATABASES = { 58 | "default": { 59 | "ENGINE": "django.db.backends.sqlite3", 60 | "NAME": BASE_DIR / "db.sqlite3", 61 | } 62 | } 63 | 64 | 65 | LANGUAGE_CODE = "en-us" 66 | 67 | TIME_ZONE = "UTC" 68 | 69 | USE_I18N = True 70 | 71 | USE_TZ = True 72 | 73 | 74 | STATIC_URL = "/static/" 75 | -------------------------------------------------------------------------------- /tests/demo_project/demo/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | from ninja import NinjaAPI 5 | 6 | api_v1 = NinjaAPI() 7 | api_v1.add_router("events", "someapp.api.router") 8 | # TODO: check ^ for possible mistakes like `/events` `events/`` 9 | 10 | 11 | api_v2 = NinjaAPI(version="2.0.0") 12 | 13 | 14 | @api_v2.get("events") 15 | def newevents2(request): 16 | return "events are gone" 17 | 18 | 19 | api_v3 = NinjaAPI(version="3.0.0") 20 | 21 | 22 | @api_v3.get("events") 23 | def newevents3(request): 24 | return "events are gone 3" 25 | 26 | 27 | @api_v3.get("foobar") 28 | def foobar(request): 29 | return "foobar" 30 | 31 | 32 | @api_v3.post("foobar") 33 | def post_foobar(request): 34 | return "foobar" 35 | 36 | 37 | @api_v3.put("foobar", url_name="foobar_put") 38 | def put_foobar(request): 39 | return "foobar" 40 | 41 | 42 | api_multi_param = NinjaAPI(version="1.0.1") 43 | api_multi_param.add_router("", "multi_param.api.router") 44 | 45 | urlpatterns = [ 46 | path("admin/", admin.site.urls), 47 | path("api/", api_v1.urls), 48 | path("api/v2/", api_v2.urls), 49 | path("api/v3/", api_v3.urls), 50 | path("api/mp/", api_multi_param.urls), 51 | ] 52 | -------------------------------------------------------------------------------- /tests/demo_project/demo/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for demo 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/3.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | import sys 12 | 13 | from django.core.wsgi import get_wsgi_application 14 | 15 | sys.path.insert(0, "../../") 16 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings") 17 | 18 | application = get_wsgi_application() 19 | -------------------------------------------------------------------------------- /tests/demo_project/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(): 9 | sys.path.insert(0, "../../") 10 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings") 11 | try: 12 | from django.core.management import execute_from_command_line 13 | except ImportError as exc: 14 | raise ImportError( 15 | "Couldn't import Django. Are you sure it's installed and " 16 | "available on your PYTHONPATH environment variable? Did you " 17 | "forget to activate a virtual environment?" 18 | ) from exc 19 | execute_from_command_line(sys.argv) 20 | 21 | 22 | if __name__ == "__main__": 23 | main() 24 | -------------------------------------------------------------------------------- /tests/demo_project/multi_param/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/tests/demo_project/multi_param/__init__.py -------------------------------------------------------------------------------- /tests/demo_project/multi_param/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for multi_param 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/3.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault( 15 | "DJANGO_SETTINGS_MODULE", "tests.demo_project.multi_param.settings" 16 | ) 17 | 18 | application = get_asgi_application() 19 | -------------------------------------------------------------------------------- /tests/demo_project/multi_param/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(): 9 | """Run administrative tasks.""" 10 | os.environ.setdefault( 11 | "DJANGO_SETTINGS_MODULE", "tests.demo_project.multi_param.settings" 12 | ) 13 | try: 14 | from django.core.management import execute_from_command_line 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/demo_project/multi_param/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for multi_param project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1.13. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "NOT-SUPER-SECRET-DO-NOT-USE-ME" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | # Application definition 31 | 32 | INSTALLED_APPS = [ 33 | "django.contrib.auth", 34 | "django.contrib.contenttypes", 35 | "django.contrib.sessions", 36 | "django.contrib.messages", 37 | "django.contrib.staticfiles", 38 | ] 39 | 40 | MIDDLEWARE = [ 41 | "django.middleware.security.SecurityMiddleware", 42 | "django.contrib.sessions.middleware.SessionMiddleware", 43 | "django.middleware.common.CommonMiddleware", 44 | "django.middleware.csrf.CsrfViewMiddleware", 45 | "django.contrib.auth.middleware.AuthenticationMiddleware", 46 | "django.contrib.messages.middleware.MessageMiddleware", 47 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 48 | ] 49 | 50 | ROOT_URLCONF = "tests.demo_project.multi_param.urls" 51 | 52 | TEMPLATES = [ 53 | { 54 | "BACKEND": "django.template.backends.django.DjangoTemplates", 55 | "DIRS": [], 56 | "APP_DIRS": True, 57 | "OPTIONS": { 58 | "context_processors": [ 59 | "django.template.context_processors.debug", 60 | "django.template.context_processors.request", 61 | "django.contrib.auth.context_processors.auth", 62 | "django.contrib.messages.context_processors.messages", 63 | ], 64 | }, 65 | }, 66 | ] 67 | 68 | WSGI_APPLICATION = "tests.demo_project.multi_param.wsgi.application" 69 | DATABASES = {"default": {}} 70 | AUTH_PASSWORD_VALIDATORS = [] 71 | 72 | LANGUAGE_CODE = "en-us" 73 | TIME_ZONE = "UTC" 74 | USE_I18N = True 75 | USE_TZ = False 76 | STATIC_URL = "/static/" 77 | -------------------------------------------------------------------------------- /tests/demo_project/multi_param/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from ninja import NinjaAPI 4 | 5 | from .api import router 6 | 7 | api_multi_param = NinjaAPI(version="1.0.1") 8 | api_multi_param.add_router("", router) 9 | 10 | urlpatterns = [ 11 | path("api/", api_multi_param.urls), 12 | ] 13 | -------------------------------------------------------------------------------- /tests/demo_project/multi_param/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for multi_param 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/3.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault( 15 | "DJANGO_SETTINGS_MODULE", "tests.demo_project.multi_param.settings" 16 | ) 17 | 18 | application = get_wsgi_application() 19 | -------------------------------------------------------------------------------- /tests/demo_project/someapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/tests/demo_project/someapp/__init__.py -------------------------------------------------------------------------------- /tests/demo_project/someapp/admin.py: -------------------------------------------------------------------------------- 1 | # Register your models here. 2 | -------------------------------------------------------------------------------- /tests/demo_project/someapp/api.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from typing import List 3 | 4 | from django.shortcuts import get_object_or_404 5 | from pydantic import BaseModel 6 | 7 | from ninja import Router 8 | 9 | from .models import Event 10 | 11 | router = Router() 12 | 13 | 14 | class EventSchema(BaseModel): 15 | model_config = dict(from_attributes=True) 16 | 17 | title: str 18 | start_date: date 19 | end_date: date 20 | 21 | 22 | @router.post("/create", url_name="event-create-url-name") 23 | def create_event(request, event: EventSchema): 24 | Event.objects.create(**event.model_dump()) 25 | return event 26 | 27 | 28 | @router.get("", response=List[EventSchema]) 29 | def list_events(request): 30 | return list(Event.objects.all()) 31 | 32 | 33 | @router.delete("") 34 | def delete_events(request): 35 | Event.objects.all().delete() 36 | 37 | 38 | @router.get("/{id}", response=EventSchema) 39 | def get_event(request, id: int): 40 | event = get_object_or_404(Event, id=id) 41 | return event 42 | -------------------------------------------------------------------------------- /tests/demo_project/someapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Category(models.Model): 5 | title = models.CharField(max_length=100) 6 | 7 | 8 | class Event(models.Model): 9 | title = models.CharField(max_length=100) 10 | category = models.OneToOneField( 11 | Category, null=True, blank=True, on_delete=models.SET_NULL 12 | ) 13 | start_date = models.DateField() 14 | end_date = models.DateField() 15 | 16 | def __str__(self): 17 | return self.title 18 | 19 | 20 | class Client(models.Model): 21 | key = models.CharField(max_length=20, unique=True) 22 | -------------------------------------------------------------------------------- /tests/demo_project/someapp/views.py: -------------------------------------------------------------------------------- 1 | # Create your views here. 2 | -------------------------------------------------------------------------------- /tests/env-matrix/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | RUN apt install curl 4 | RUN curl https://pyenv.run | bash 5 | 6 | ENV HOME /root 7 | ENV PYENV_ROOT $HOME/.pyenv 8 | ENV PATH $PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH 9 | 10 | RUN eval "$(pyenv init -)" 11 | RUN eval "$(pyenv virtualenv-init -)" 12 | 13 | COPY tests/env-matrix/install_env.sh /install_env.sh 14 | RUN chmod u+x /install_env.sh 15 | RUN pyenv install 3.6.10 16 | RUN pyenv install 3.7.7 17 | RUN pyenv install 3.8.3 18 | 19 | # Django 2.1.15 20 | RUN /install_env.sh 3.6.10 2.1.15 env-36-21 21 | RUN /install_env.sh 3.7.7 2.1.15 env-37-21 22 | RUN /install_env.sh 3.8.3 2.1.15 env-38-21 23 | 24 | # Django 2.2.12 25 | RUN /install_env.sh 3.6.10 2.2.12 env-36-22 26 | RUN /install_env.sh 3.7.7 2.2.12 env-37-22 27 | RUN /install_env.sh 3.8.3 2.2.12 env-38-22 28 | 29 | # Django 3.0.6 30 | RUN /install_env.sh 3.6.10 3.0.6 env-36-30 31 | RUN /install_env.sh 3.7.7 3.0.6 env-37-30 32 | RUN /install_env.sh 3.8.3 3.0.6 env-38-30 33 | 34 | # Django 3.1 35 | RUN /install_env.sh 3.6.10 3.1 env-36-31 36 | RUN /install_env.sh 3.7.7 3.1 env-37-31 37 | RUN /install_env.sh 3.8.3 3.1 env-38-31 38 | 39 | COPY ninja /ninja 40 | COPY tests /tests 41 | COPY docs /docs 42 | COPY tests/env-matrix/run.sh /run.sh 43 | RUN chmod u+x /run.sh 44 | 45 | RUN echo 'Dependencies installed. Now running tests...' &&\ 46 | /run.sh env-36-21 &&\ 47 | /run.sh env-37-21 &&\ 48 | /run.sh env-38-21 &&\ 49 | /run.sh env-36-22 &&\ 50 | /run.sh env-37-22 &&\ 51 | /run.sh env-38-22 &&\ 52 | /run.sh env-36-30 &&\ 53 | /run.sh env-37-30 &&\ 54 | /run.sh env-38-30 &&\ 55 | /run.sh env-36-31 &&\ 56 | /run.sh env-37-31 &&\ 57 | /run.sh env-38-31 &&\ 58 | echo 'Done.' 59 | -------------------------------------------------------------------------------- /tests/env-matrix/Dockerfile.backup: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | RUN apt install curl 4 | RUN curl https://pyenv.run | bash 5 | 6 | RUN /root/.pyenv/bin/pyenv install --help 7 | 8 | RUN /root/.pyenv/bin/pyenv install 3.6.10 9 | RUN /root/.pyenv/bin/pyenv install 3.7.7 10 | RUN /root/.pyenv/bin/pyenv install 3.8.3 11 | RUN /root/.pyenv/bin/pyenv install 3.9.0b3 12 | 13 | ENV HOME /root 14 | ENV PYENV_ROOT $HOME/.pyenv 15 | ENV PATH $PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH 16 | 17 | COPY tests/env-matrix/install_env.sh /install_env.sh 18 | 19 | RUN bash /install_env.sh 3.6.10 2.0.13 env-36-20 20 | RUN bash /install_env.sh 3.6.10 2.1.15 env-36-21 21 | RUN bash /install_env.sh 3.6.10 2.2.12 env-36-22 22 | RUN bash /install_env.sh 3.6.10 3.0.6 env-36-30 23 | RUN bash /install_env.sh 3.6.10 3.1b1 env-36-31 24 | RUN bash /install_env.sh 3.7.7 2.0.13 env-37-20 25 | RUN bash /install_env.sh 3.7.7 2.1.15 env-37-21 26 | RUN bash /install_env.sh 3.7.7 2.2.12 env-37-22 27 | RUN bash /install_env.sh 3.7.7 3.0.6 env-37-30 28 | RUN bash /install_env.sh 3.7.7 3.1b1 env-37-31 29 | RUN bash /install_env.sh 3.8.3 2.0.13 env-38-20 30 | RUN bash /install_env.sh 3.8.3 2.1.15 env-38-21 31 | RUN bash /install_env.sh 3.8.3 2.2.12 env-38-22 32 | RUN bash /install_env.sh 3.8.3 3.0.6 env-38-30 33 | RUN bash /install_env.sh 3.8.3 3.1b1 env-38-31 34 | 35 | RUN bash /install_env.sh 3.9.0b3 3.0 env-39-30 36 | 37 | 38 | COPY ninja /ninja 39 | COPY tests /tests 40 | COPY docs /docs 41 | 42 | 43 | COPY tests/env-matrix/run.sh /run.sh 44 | 45 | WORKDIR / 46 | 47 | 48 | 49 | RUN bash /run.sh env-36-20 &&\ 50 | bash /run.sh env-36-21 &&\ 51 | bash /run.sh env-36-22 &&\ 52 | bash /run.sh env-36-30 &&\ 53 | bash /run.sh env-36-31 &&\ 54 | bash /run.sh env-37-20 &&\ 55 | bash /run.sh env-37-21 &&\ 56 | bash /run.sh env-37-22 &&\ 57 | bash /run.sh env-37-30 &&\ 58 | bash /run.sh env-37-31 &&\ 59 | bash /run.sh env-38-20 &&\ 60 | bash /run.sh env-38-21 &&\ 61 | bash /run.sh env-38-22 &&\ 62 | bash /run.sh env-38-30 &&\ 63 | bash /run.sh env-38-31 &&\ 64 | echo "done" 65 | 66 | RUN bash /run.sh env-39-30 67 | -------------------------------------------------------------------------------- /tests/env-matrix/README.md: -------------------------------------------------------------------------------- 1 | This `env-matrix` speeds up test execution across all environments (Python 3.[6,7,8], Django2.0,...,3.1) 2 | 3 | To execute 4 | 5 | `docker-compose up --build` 6 | 7 | First time it will take about half an hour (to install all) 8 | 9 | Every other time it should take less than a minute to test across all environments. 10 | -------------------------------------------------------------------------------- /tests/env-matrix/create_docker.py: -------------------------------------------------------------------------------- 1 | PYTHON = ["3.6.10", "3.7.7", "3.8.3"] # 3.9.0b3 2 | DJANGO = ["2.1.15", "2.2.12", "3.0.6", "3.1"] 3 | 4 | 5 | HEADER = """ 6 | FROM python:3.8 7 | 8 | RUN apt install curl 9 | RUN curl https://pyenv.run | bash 10 | 11 | ENV HOME /root 12 | ENV PYENV_ROOT $HOME/.pyenv 13 | ENV PATH $PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH 14 | 15 | RUN eval "$(pyenv init -)" 16 | RUN eval "$(pyenv virtualenv-init -)" 17 | 18 | COPY tests/env-matrix/install_env.sh /install_env.sh 19 | RUN chmod u+x /install_env.sh 20 | """.strip() 21 | 22 | 23 | def envname(py, dj): 24 | py = "".join(py.split(".")[:2]) 25 | dj = "".join(dj.split(".")[:2])[:2] 26 | return f"env-{py}-{dj}" 27 | 28 | 29 | print(HEADER) 30 | 31 | for py in PYTHON: 32 | print(f"RUN pyenv install {py}") 33 | 34 | 35 | for d in DJANGO: 36 | print() 37 | print(f"# Django {d}") 38 | for p in PYTHON: 39 | e = envname(p, d) 40 | print(f"RUN /install_env.sh {p:<7} {d:<7} {e}") 41 | 42 | 43 | print( 44 | """ 45 | COPY ninja /ninja 46 | COPY tests /tests 47 | COPY docs /docs 48 | COPY tests/env-matrix/run.sh /run.sh 49 | RUN chmod u+x /run.sh 50 | """ 51 | ) 52 | 53 | 54 | print("RUN echo 'Dependencies installed. Now running tests...' &&\\") 55 | 56 | for d in DJANGO: 57 | for p in PYTHON: 58 | e = envname(p, d) 59 | print(f" /run.sh {e} &&\\") 60 | 61 | print(" echo 'Done.'") 62 | -------------------------------------------------------------------------------- /tests/env-matrix/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | tester: 4 | build: 5 | context: ../../ 6 | dockerfile: tests/env-matrix/Dockerfile -------------------------------------------------------------------------------- /tests/env-matrix/install_env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | PYVER=$1 4 | DJANGO=$2 5 | ENVNAME=$3 6 | 7 | eval "$(pyenv init -)" 8 | eval "$(pyenv virtualenv-init -)" 9 | 10 | pyenv virtualenv $PYVER $ENVNAME 11 | pyenv shell $ENVNAME 12 | pip install django==$DJANGO pytest pytest-django pytest-asyncio pytest-cov pydantic==1.6 13 | -------------------------------------------------------------------------------- /tests/env-matrix/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ENVNAME=$1 4 | 5 | eval "$(pyenv init -)" 6 | eval "$(pyenv virtualenv-init -)" 7 | 8 | echo $ENVNAME 9 | 10 | pyenv shell $ENVNAME 11 | pytest tests 12 | -------------------------------------------------------------------------------- /tests/mypy_test.py: -------------------------------------------------------------------------------- 1 | # The goal of this file is to test that mypy "likes" all the combinations of parametrization 2 | from typing import Any 3 | 4 | from django.http import HttpRequest 5 | from typing_extensions import Annotated 6 | 7 | from ninja import Body, BodyEx, NinjaAPI, P, Schema 8 | 9 | 10 | class Payload(Schema): 11 | x: int 12 | y: float 13 | s: str 14 | 15 | 16 | api = NinjaAPI() 17 | 18 | 19 | @api.post("/old_way") 20 | def old_way(request: HttpRequest, data: Payload = Body()) -> Any: 21 | data.s.capitalize() 22 | 23 | 24 | @api.post("/annotated_way") 25 | def annotated_way(request: HttpRequest, data: Annotated[Payload, Body()]) -> Any: 26 | data.s.capitalize() 27 | 28 | 29 | @api.post("/new_way") 30 | def new_way(request: HttpRequest, data: Body[Payload]) -> Any: 31 | data.s.capitalize() 32 | 33 | 34 | @api.post("/new_way_ex") 35 | def new_way_ex(request: HttpRequest, data: BodyEx[Payload, P(title="A title")]) -> Any: 36 | data.s.find("") 37 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | # python_paths = ./ ./tests/demo_project 3 | addopts = --nomigrations 4 | ; --ds=demo_project.settings 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/test_api_instance.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from ninja import NinjaAPI, Router 6 | from ninja.errors import ConfigError 7 | 8 | api = NinjaAPI() 9 | router = Router() 10 | 11 | 12 | @api.get("/global") 13 | def global_op(request): 14 | pass 15 | 16 | 17 | @router.get("/router") 18 | def router_op(request): 19 | pass 20 | 21 | 22 | api.add_router("/", router) 23 | 24 | 25 | def test_api_instance(): 26 | assert len(api._routers) == 2 # default + extra 27 | for _path, rtr in api._routers: 28 | for path_ops in rtr.path_operations.values(): 29 | for op in path_ops.operations: 30 | assert op.api is api 31 | 32 | 33 | def test_reuse_router_error(): 34 | test_api = NinjaAPI() 35 | test_router = Router() 36 | test_api.add_router("/", test_router) 37 | 38 | # django debug server can attempt to import the urls twice when errors exist 39 | # verify we get the correct error reported 40 | match = "Router@'/another-path' has already been attached to API NinjaAPI:1.0.0" 41 | with pytest.raises(ConfigError, match=match): 42 | with mock.patch("ninja.main._imported_while_running_in_debug_server", False): 43 | test_api.add_router("/another-path", test_router) 44 | 45 | # The error should be ignored under debug server to allow other errors to be reported 46 | with mock.patch("ninja.main._imported_while_running_in_debug_server", True): 47 | test_api.add_router("/another-path", test_router) 48 | -------------------------------------------------------------------------------- /tests/test_async.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest 4 | 5 | from ninja import NinjaAPI 6 | from ninja.security import APIKeyQuery 7 | from ninja.testing import TestAsyncClient 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_asyncio_operations(): 12 | api = NinjaAPI() 13 | 14 | class KeyQuery(APIKeyQuery): 15 | def authenticate(self, request, key): 16 | if key == "secret": 17 | return key 18 | 19 | @api.get("/async", auth=KeyQuery()) 20 | async def async_view(request, payload: int): 21 | await asyncio.sleep(0) 22 | return {"async": True} 23 | 24 | @api.post("/async") 25 | def sync_post_to_async_view(request): 26 | return {"sync": True} 27 | 28 | client = TestAsyncClient(api) 29 | 30 | # Actual tests -------------------------------------------------- 31 | 32 | # without auth: 33 | res = await client.get("/async?payload=1") 34 | assert res.status_code == 401 35 | 36 | # async successful 37 | res = await client.get("/async?payload=1&key=secret") 38 | assert res.json() == {"async": True} 39 | 40 | # async innvalid input 41 | res = await client.get("/async?payload=str&key=secret") 42 | assert res.status_code == 422 43 | 44 | # async call to sync method for path that have async operations 45 | res = await client.post("/async") 46 | assert res.json() == {"sync": True} 47 | 48 | # invalid method 49 | res = await client.put("/async") 50 | assert res.status_code == 405 51 | -------------------------------------------------------------------------------- /tests/test_auth_global.py: -------------------------------------------------------------------------------- 1 | from ninja import NinjaAPI, Router 2 | from ninja.security import APIKeyQuery 3 | from ninja.testing import TestClient 4 | 5 | 6 | class KeyQuery1(APIKeyQuery): 7 | def authenticate(self, request, key): 8 | if key == "k1": 9 | return key 10 | 11 | 12 | class KeyQuery2(APIKeyQuery): 13 | def authenticate(self, request, key): 14 | if key == "k2": 15 | return key 16 | 17 | 18 | api = NinjaAPI(auth=KeyQuery1()) 19 | 20 | 21 | @api.get("/default") 22 | def default(request): 23 | return {"auth": request.auth} 24 | 25 | 26 | @api.api_operation(["POST", "PATCH"], "/multi-method-no-auth") 27 | def multi_no_auth(request): 28 | return {"auth": request.auth} 29 | 30 | 31 | @api.api_operation(["POST", "PATCH"], "/multi-method-auth", auth=KeyQuery2()) 32 | def multi_auth(request): 33 | return {"auth": request.auth} 34 | 35 | 36 | # ---- router ------------------------ 37 | 38 | router = Router() 39 | 40 | 41 | @router.get("/router-operation") # should come from global auth 42 | def router_operation(request): 43 | return {"auth": str(request.auth)} 44 | 45 | 46 | @router.get("/router-operation-auth", auth=KeyQuery2()) 47 | def router_operation_auth(request): 48 | return {"auth": str(request.auth)} 49 | 50 | 51 | api.add_router("", router) 52 | 53 | # ---- end router -------------------- 54 | 55 | client = TestClient(api) 56 | 57 | 58 | def test_multi(): 59 | assert client.get("/default").status_code == 401 60 | assert client.get("/default?key=k1").json() == {"auth": "k1"} 61 | 62 | assert client.post("/multi-method-no-auth").status_code == 401 63 | assert client.post("/multi-method-no-auth?key=k1").json() == {"auth": "k1"} 64 | 65 | assert client.patch("/multi-method-no-auth").status_code == 401 66 | assert client.patch("/multi-method-no-auth?key=k1").json() == {"auth": "k1"} 67 | 68 | assert client.post("/multi-method-auth?key=k1").status_code == 401 69 | assert client.patch("/multi-method-auth?key=k1").status_code == 401 70 | 71 | assert client.post("/multi-method-auth?key=k2").json() == {"auth": "k2"} 72 | assert client.patch("/multi-method-auth?key=k2").json() == {"auth": "k2"} 73 | 74 | 75 | def test_router_auth(): 76 | assert client.get("/router-operation").status_code == 401 77 | assert client.get("/router-operation?key=k1").json() == {"auth": "k1"} 78 | 79 | assert client.get("/router-operation-auth?key=k1").status_code == 401 80 | assert client.get("/router-operation-auth?key=k2").json() == {"auth": "k2"} 81 | -------------------------------------------------------------------------------- /tests/test_auth_inheritance_routers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ninja import NinjaAPI, Router 4 | from ninja.security import APIKeyQuery 5 | from ninja.testing import TestClient 6 | 7 | 8 | class Auth(APIKeyQuery): 9 | def __init__(self, secret): 10 | self.secret = secret 11 | super().__init__() 12 | 13 | def authenticate(self, request, key): 14 | if key == self.secret: 15 | return key 16 | 17 | 18 | api = NinjaAPI() 19 | 20 | r1 = Router() 21 | r2 = Router() 22 | r3 = Router() 23 | r4 = Router() 24 | 25 | api.add_router("/r1", r1, auth=Auth("r1_auth")) 26 | r1.add_router("/r2", r2) 27 | r2.add_router("/r3", r3) 28 | r3.add_router("/r4", r4, auth=Auth("r4_auth")) 29 | 30 | client = TestClient(api) 31 | 32 | 33 | @r1.get("/") 34 | def op1(request): 35 | return request.auth 36 | 37 | 38 | @r2.get("/") 39 | def op2(request): 40 | return request.auth 41 | 42 | 43 | @r3.get("/") 44 | def op3(request): 45 | return request.auth 46 | 47 | 48 | @r4.get("/") 49 | def op4(request): 50 | return request.auth 51 | 52 | 53 | @r3.get("/op5", auth=Auth("op5_auth")) 54 | def op5(request): 55 | return request.auth 56 | 57 | 58 | @pytest.mark.parametrize( 59 | "route, status_code", 60 | [ 61 | ("/r1/", 401), 62 | ("/r1/r2/", 401), 63 | ("/r1/r2/r3/", 401), 64 | ("/r1/r2/r3/r4/", 401), 65 | ("/r1/r2/r3/op5", 401), 66 | ("/r1/?key=r1_auth", 200), 67 | ("/r1/r2/?key=r1_auth", 200), 68 | ("/r1/r2/r3/?key=r1_auth", 200), 69 | ("/r1/r2/r3/r4/?key=r4_auth", 200), 70 | ("/r1/r2/r3/op5?key=op5_auth", 200), 71 | ("/r1/r2/r3/r4/?key=r1_auth", 401), 72 | ("/r1/r2/r3/op5?key=r1_auth", 401), 73 | ], 74 | ) 75 | def test_router_inheritance_auth(route, status_code): 76 | assert client.get(route).status_code == status_code 77 | -------------------------------------------------------------------------------- /tests/test_auth_routers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ninja import NinjaAPI, Router 4 | from ninja.security import APIKeyQuery 5 | from ninja.testing import TestClient 6 | 7 | 8 | class Auth(APIKeyQuery): 9 | def __init__(self, secret): 10 | self.secret = secret 11 | super().__init__() 12 | 13 | def authenticate(self, request, key): 14 | if key == self.secret: 15 | return key 16 | 17 | 18 | api = NinjaAPI() 19 | 20 | r1 = Router() 21 | r2 = Router() 22 | r2_1 = Router() 23 | 24 | 25 | @r1.get("/test") 26 | def operation1(request): 27 | return request.auth 28 | 29 | 30 | @r2.get("/test") 31 | def operation2(request): 32 | return request.auth 33 | 34 | 35 | @r2_1.get("/test") 36 | def operation3(request): 37 | return request.auth 38 | 39 | 40 | r2.add_router("/child", r2_1, auth=Auth("two-child")) 41 | api.add_router("/r1", r1, auth=Auth("one")) 42 | api.add_router("/r2", r2, auth=Auth("two")) 43 | 44 | 45 | client = TestClient(api) 46 | 47 | 48 | @pytest.mark.parametrize( 49 | "route, status_code", 50 | [ 51 | ("/r1/test", 401), 52 | ("/r2/test", 401), 53 | ("/r1/test?key=one", 200), 54 | ("/r2/test?key=two", 200), 55 | ("/r1/test?key=two", 401), 56 | ("/r2/test?key=one", 401), 57 | ("/r2/child/test", 401), 58 | ("/r2/child/test?key=two-child", 200), 59 | ], 60 | ) 61 | def test_router_auth(route, status_code): 62 | assert client.get(route).status_code == status_code 63 | -------------------------------------------------------------------------------- /tests/test_body.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic import field_validator 3 | 4 | from ninja import Body, Form, NinjaAPI, Schema 5 | from ninja.errors import ConfigError 6 | from ninja.testing import TestClient 7 | 8 | api = NinjaAPI() 9 | 10 | # testing Body marker: 11 | 12 | 13 | @api.post("/task") 14 | def create_task(request, start: int = Body(...), end: int = Body(...)): 15 | return [start, end] 16 | 17 | 18 | @api.post("/task2") 19 | def create_task2(request, start: int = Body(2), end: int = Form(1)): 20 | return [start, end] 21 | 22 | 23 | class UserIn(Schema): 24 | # for testing validation errors context 25 | email: str 26 | 27 | @field_validator("email") 28 | @classmethod 29 | def validate_email(cls, v): 30 | if "@" not in v: 31 | raise ValueError("invalid email") 32 | return v 33 | 34 | 35 | @api.post("/users") 36 | def create_user(request, payload: UserIn): 37 | return payload.dict() 38 | 39 | 40 | client = TestClient(api) 41 | 42 | 43 | def test_body(): 44 | assert client.post("/task", json={"start": 1, "end": 2}).json() == [1, 2] 45 | assert client.post("/task", json={"start": 1}).json() == { 46 | "detail": [{"type": "missing", "loc": ["body", "end"], "msg": "Field required"}] 47 | } 48 | 49 | 50 | def test_body_form(): 51 | data = client.post("/task2", POST={"start": "1", "end": "2"}).json() 52 | print(data) 53 | assert client.post("/task2", POST={"start": "1", "end": "2"}).json() == [1, 2] 54 | assert client.post("/task2").json() == [2, 1] 55 | 56 | 57 | def test_body_validation_error(): 58 | resp = client.post("/users", json={"email": "valid@email.com"}) 59 | assert resp.status_code == 200 60 | 61 | resp = client.post("/users", json={"email": "invalid.com"}) 62 | assert resp.status_code == 422 63 | assert resp.json()["detail"] == [ 64 | { 65 | "type": "value_error", 66 | "loc": ["body", "payload", "email"], 67 | "msg": "Value error, invalid email", 68 | "ctx": {"error": "invalid email"}, 69 | } 70 | ] 71 | 72 | 73 | def test_incorrect_annotation(): 74 | api = NinjaAPI() 75 | 76 | class Some(Schema): 77 | a: int 78 | 79 | with pytest.raises(ConfigError): 80 | 81 | @api.post("/some") 82 | def some(request, payload=Some): 83 | # ................. ^------ invalid usage assigning class instead of annotation 84 | return 42 85 | -------------------------------------------------------------------------------- /tests/test_conf.py: -------------------------------------------------------------------------------- 1 | from ninja.conf import settings 2 | 3 | 4 | def test_default_configuration(): 5 | assert settings.PAGINATION_CLASS == "ninja.pagination.LimitOffsetPagination" 6 | assert settings.PAGINATION_PER_PAGE == 100 7 | -------------------------------------------------------------------------------- /tests/test_csrf_async.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.conf import settings 3 | 4 | from ninja import NinjaAPI 5 | from ninja.testing import TestAsyncClient as BaseTestAsyncClient 6 | 7 | 8 | class TestAsyncClient(BaseTestAsyncClient): 9 | def _build_request(self, *args, **kwargs): 10 | request = super()._build_request(*args, **kwargs) 11 | request._dont_enforce_csrf_checks = False 12 | return request 13 | 14 | 15 | TOKEN = "1bcdefghij2bcdefghij3bcdefghij4bcdefghij5bcdefghij6bcdefghijABCD" 16 | COOKIES = {settings.CSRF_COOKIE_NAME: TOKEN} 17 | 18 | 19 | @pytest.mark.asyncio 20 | async def test_csrf_off(): 21 | csrf_OFF = NinjaAPI(urls_namespace="csrf_OFF") 22 | 23 | @csrf_OFF.post("/post") 24 | async def post_off(request): 25 | return {"success": True} 26 | 27 | client = TestAsyncClient(csrf_OFF) 28 | response = await client.post("/post", COOKIES=COOKIES) 29 | assert response.status_code == 200 30 | 31 | 32 | @pytest.mark.asyncio 33 | async def test_csrf_on(): 34 | csrf_ON = NinjaAPI(urls_namespace="csrf_ON", csrf=True) 35 | 36 | @csrf_ON.post("/post") 37 | async def post_on(request): 38 | return {"success": True} 39 | 40 | client = TestAsyncClient(csrf_ON) 41 | 42 | response = await client.post("/post", COOKIES=COOKIES) 43 | assert response.status_code == 403 44 | 45 | # check with token in formdata 46 | response = await client.post( 47 | "/post", {"csrfmiddlewaretoken": TOKEN}, COOKIES=COOKIES 48 | ) 49 | assert response.status_code == 200 50 | 51 | # check with headers 52 | response = await client.post( 53 | "/post", COOKIES=COOKIES, headers={"X-CSRFTOKEN": TOKEN} 54 | ) 55 | assert response.status_code == 200 56 | -------------------------------------------------------------------------------- /tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import List 3 | 4 | from ninja import NinjaAPI 5 | from ninja.decorators import decorate_view 6 | from ninja.pagination import paginate 7 | from ninja.testing import TestClient 8 | 9 | 10 | def some_decorator(view_func): 11 | @wraps(view_func) 12 | def wrapper(request, *args, **kwargs): 13 | response = view_func(request, *args) 14 | response["X-Decorator"] = "some_decorator" 15 | return response 16 | 17 | return wrapper 18 | 19 | 20 | def test_decorator_before(): 21 | api = NinjaAPI() 22 | 23 | @decorate_view(some_decorator) 24 | @api.get("/before") 25 | def dec_before(request): 26 | return 1 27 | 28 | client = TestClient(api) 29 | response = client.get("/before") 30 | assert response.status_code == 200 31 | assert response["X-Decorator"] == "some_decorator" 32 | 33 | 34 | def test_decorator_after(): 35 | api = NinjaAPI() 36 | 37 | @api.get("/after") 38 | @decorate_view(some_decorator) 39 | def dec_after(request): 40 | return 1 41 | 42 | client = TestClient(api) 43 | response = client.get("/after") 44 | assert response.status_code == 200 45 | assert response["X-Decorator"] == "some_decorator" 46 | 47 | 48 | def test_decorator_multiple(): 49 | api = NinjaAPI() 50 | 51 | @api.get("/multi", response=List[int]) 52 | @decorate_view(some_decorator) 53 | @paginate 54 | def dec_multi(request): 55 | return [1, 2, 3, 4] 56 | 57 | client = TestClient(api) 58 | response = client.get("/multi") 59 | assert response.status_code == 200 60 | assert response.json() == {"count": 4, "items": [1, 2, 3, 4]} 61 | assert response["X-Decorator"] == "some_decorator" 62 | -------------------------------------------------------------------------------- /tests/test_django_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.test import Client 3 | from django.urls import reverse 4 | from someapp.models import Event 5 | 6 | 7 | @pytest.mark.django_db 8 | def test_with_client(client: Client): 9 | assert Event.objects.count() == 0 10 | 11 | test_item = {"start_date": "2020-01-01", "end_date": "2020-01-02", "title": "test"} 12 | 13 | response = client.post("/api/events/create", **json_payload(test_item)) 14 | assert response.status_code == 200 15 | assert Event.objects.count() == 1 16 | 17 | response = client.get("/api/events") 18 | assert response.status_code == 200 19 | assert response.json() == [test_item] 20 | 21 | response = client.get("/api/events/1") 22 | assert response.status_code == 200 23 | assert response.json() == test_item 24 | 25 | 26 | def test_reverse(): 27 | """ 28 | Check that url reversing works. 29 | """ 30 | assert reverse("api-1.0.0:event-create-url-name") == "/api/events/create" 31 | 32 | 33 | def test_reverse_implicit(): 34 | """ 35 | Check that implicit url reversing works. 36 | """ 37 | assert reverse("api-1.0.0:list_events") == "/api/events" 38 | 39 | 40 | def json_payload(data): 41 | import json 42 | 43 | return dict(data=json.dumps(data), content_type="application/json") 44 | -------------------------------------------------------------------------------- /tests/test_docs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/tests/test_docs/__init__.py -------------------------------------------------------------------------------- /tests/test_docs/test_body.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from ninja import NinjaAPI 4 | from ninja.testing import TestClient 5 | 6 | 7 | def test_examples(): 8 | api = NinjaAPI() 9 | 10 | with patch("builtins.api", api, create=True): 11 | import docs.src.tutorial.body.code01 # noqa: F401 12 | import docs.src.tutorial.body.code02 # noqa: F401 13 | import docs.src.tutorial.body.code03 # noqa: F401 14 | 15 | client = TestClient(api) 16 | 17 | assert client.post( 18 | "/items", json={"name": "Katana", "price": 299.00, "quantity": 10} 19 | ).json() == { 20 | "name": "Katana", 21 | "description": None, 22 | "price": 299.0, 23 | "quantity": 10, 24 | } 25 | 26 | assert client.put( 27 | "/items/1", json={"name": "Katana", "price": 299.00, "quantity": 10} 28 | ).json() == { 29 | "item_id": 1, 30 | "item": { 31 | "name": "Katana", 32 | "description": None, 33 | "price": 299.0, 34 | "quantity": 10, 35 | }, 36 | } 37 | 38 | assert client.post( 39 | "/items/1?q=test", json={"name": "Katana", "price": 299.00, "quantity": 10} 40 | ).json() == { 41 | "item_id": 1, 42 | "q": "test", 43 | "item": { 44 | "name": "Katana", 45 | "description": None, 46 | "price": 299.0, 47 | "quantity": 10, 48 | }, 49 | } 50 | -------------------------------------------------------------------------------- /tests/test_docs/test_form.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | 6 | from ninja import NinjaAPI 7 | from ninja.testing import TestClient 8 | 9 | 10 | def test_examples(): 11 | api = NinjaAPI() 12 | 13 | with patch("builtins.api", api, create=True): 14 | import docs.src.tutorial.form.code01 # noqa: F401 15 | import docs.src.tutorial.form.code02 # noqa: F401 16 | 17 | client = TestClient(api) 18 | 19 | assert client.post( 20 | "/items", data={"name": "Katana", "price": 299.00, "quantity": 10} 21 | ).json() == { 22 | "name": "Katana", 23 | "description": None, 24 | "price": 299.0, 25 | "quantity": 10, 26 | } 27 | 28 | assert client.post( 29 | "/items/1?q=test", data={"name": "Katana", "price": 299.00, "quantity": 10} 30 | ).json() == { 31 | "item_id": 1, 32 | "q": "test", 33 | "item": { 34 | "name": "Katana", 35 | "description": None, 36 | "price": 299.0, 37 | "quantity": 10, 38 | }, 39 | } 40 | 41 | 42 | @pytest.mark.skipif(sys.version_info[:2] < (3, 9), reason="requires py3.9+") 43 | def test_examples_extra(): 44 | api = NinjaAPI() 45 | 46 | with patch("builtins.api", api, create=True): 47 | import docs.src.tutorial.form.code03 # noqa: F401 48 | 49 | client = TestClient(api) 50 | 51 | assert client.post( 52 | "/items-blank-default", 53 | data={"name": "Katana", "price": "", "quantity": "", "in_stock": ""}, 54 | ).json() == { 55 | "name": "Katana", 56 | "description": None, 57 | "in_stock": True, 58 | "price": 0.0, 59 | "quantity": 0, 60 | } 61 | -------------------------------------------------------------------------------- /tests/test_docs/test_index.py: -------------------------------------------------------------------------------- 1 | from docs.src.index001 import api 2 | from ninja.testing import TestClient 3 | 4 | client = TestClient(api) 5 | 6 | 7 | def test_api(): 8 | response = client.get("/add?a=1&b=2") 9 | assert response.json() == {"result": 3} 10 | -------------------------------------------------------------------------------- /tests/test_docs/test_path.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from ninja import NinjaAPI 4 | from ninja.testing import TestClient 5 | 6 | 7 | def test_examples(): 8 | api = NinjaAPI() 9 | 10 | with patch("builtins.api", api, create=True): 11 | import docs.src.tutorial.path.code01 # noqa: F401 12 | 13 | client = TestClient(api) 14 | 15 | response = client.get("/items/123") 16 | assert response.json() == {"item_id": "123"} 17 | 18 | api = NinjaAPI() 19 | 20 | with patch("builtins.api", api, create=True): 21 | import docs.src.tutorial.path.code010 # noqa: F401 22 | import docs.src.tutorial.path.code02 # noqa: F401 23 | 24 | client = TestClient(api) 25 | 26 | response = client.get("/items/123") 27 | assert response.json() == {"item_id": 123} 28 | 29 | response = client.get("/events/2020/1/1") 30 | assert response.json() == {"date": "2020-01-01"} 31 | schema = api.get_openapi_schema(path_prefix="") 32 | events_params = schema["paths"]["/events/{year}/{month}/{day}"]["get"][ 33 | "parameters" 34 | ] 35 | assert events_params == [ 36 | { 37 | "in": "path", 38 | "name": "year", 39 | "schema": {"title": "Year", "type": "integer"}, 40 | "required": True, 41 | }, 42 | { 43 | "in": "path", 44 | "name": "month", 45 | "schema": {"title": "Month", "type": "integer"}, 46 | "required": True, 47 | }, 48 | { 49 | "in": "path", 50 | "name": "day", 51 | "schema": {"title": "Day", "type": "integer"}, 52 | "required": True, 53 | }, 54 | ] 55 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | 3 | from ninja.errors import HttpError, ValidationError 4 | 5 | 6 | def test_validation_error_is_picklable_and_unpicklable(): 7 | error_to_serialize = ValidationError([{"testkey": "testvalue"}]) 8 | 9 | serialized = pickle.dumps(error_to_serialize) 10 | assert serialized # Not empty 11 | 12 | deserialized = pickle.loads(serialized) 13 | assert isinstance(deserialized, ValidationError) 14 | assert deserialized.errors == error_to_serialize.errors 15 | 16 | 17 | def test_http_error_is_picklable_and_unpicklable(): 18 | error_to_serialize = HttpError(500, "Test error") 19 | 20 | serialized = pickle.dumps(error_to_serialize) 21 | assert serialized # Not empty 22 | 23 | deserialized = pickle.loads(serialized) 24 | assert isinstance(deserialized, HttpError) 25 | assert deserialized.status_code == error_to_serialize.status_code 26 | assert deserialized.message == error_to_serialize.message 27 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.http import Http404 3 | 4 | from ninja import NinjaAPI, Schema 5 | from ninja.testing import TestAsyncClient, TestClient 6 | 7 | api = NinjaAPI() 8 | 9 | 10 | class CustomException(Exception): 11 | pass 12 | 13 | 14 | @api.exception_handler(CustomException) 15 | def on_custom_error(request, exc): 16 | return api.create_response(request, {"custom": True}, status=422) 17 | 18 | 19 | class Payload(Schema): 20 | test: int 21 | 22 | 23 | @api.post("/error/{code}") 24 | def err_thrower(request, code: str, payload: Payload = None): 25 | if code == "base": 26 | raise RuntimeError("test") 27 | if code == "404": 28 | raise Http404("test") 29 | if code == "custom": 30 | raise CustomException("test") 31 | 32 | 33 | client = TestClient(api) 34 | 35 | 36 | def test_default_handler(settings): 37 | settings.DEBUG = True 38 | 39 | response = client.post("/error/base") 40 | assert response.status_code == 500 41 | assert b"RuntimeError: test" in response.content 42 | 43 | response = client.post("/error/404") 44 | assert response.status_code == 404 45 | assert response.json() == {"detail": "Not Found: test"} 46 | 47 | response = client.post("/error/custom", body="invalid_json") 48 | assert response.status_code == 400 49 | assert response.json() == { 50 | "detail": "Cannot parse request body (Expecting value: line 1 column 1 (char 0))", 51 | } 52 | 53 | settings.DEBUG = False 54 | with pytest.raises(RuntimeError): 55 | response = client.post("/error/base") 56 | 57 | response = client.post("/error/custom", body="invalid_json") 58 | assert response.status_code == 400 59 | assert response.json() == {"detail": "Cannot parse request body"} 60 | 61 | 62 | @pytest.mark.parametrize( 63 | "route,status_code,json", 64 | [ 65 | ("/error/404", 404, {"detail": "Not Found"}), 66 | ("/error/custom", 422, {"custom": True}), 67 | ], 68 | ) 69 | def test_exceptions(route, status_code, json): 70 | response = client.post(route) 71 | assert response.status_code == status_code 72 | assert response.json() == json 73 | 74 | 75 | @pytest.mark.asyncio 76 | async def test_asyncio_exceptions(): 77 | api = NinjaAPI() 78 | 79 | @api.get("/error") 80 | async def thrower(request): 81 | raise Http404("test") 82 | 83 | client = TestAsyncClient(api) 84 | response = await client.get("/error") 85 | assert response.status_code == 404 86 | 87 | 88 | def test_no_handlers(): 89 | api = NinjaAPI() 90 | api._exception_handlers = {} 91 | 92 | @api.get("/error") 93 | def thrower(request): 94 | raise RuntimeError("test") 95 | 96 | client = TestClient(api) 97 | 98 | with pytest.raises(RuntimeError): 99 | client.get("/error") 100 | -------------------------------------------------------------------------------- /tests/test_export_openapi_schema.py: -------------------------------------------------------------------------------- 1 | import json 2 | import tempfile 3 | from io import StringIO 4 | from pathlib import Path 5 | from unittest.mock import patch 6 | 7 | import pytest 8 | from django.core.management import call_command 9 | from django.core.management.base import CommandError 10 | 11 | from ninja.management.commands.export_openapi_schema import Command as ExportCmd 12 | 13 | 14 | def test_export_default(): 15 | output = StringIO() 16 | call_command(ExportCmd(), stdout=output) 17 | json.loads(output.getvalue()) # if no exception, then OK 18 | assert len(output.getvalue().splitlines()) == 1 19 | 20 | 21 | def test_export_indent(): 22 | output = StringIO() 23 | call_command(ExportCmd(), indent=1, stdout=output) 24 | assert len(output.getvalue().splitlines()) > 1 25 | 26 | 27 | def test_export_to_file(): 28 | with tempfile.TemporaryDirectory() as tmp: 29 | output_file = Path(tmp) / "result.json" 30 | call_command(ExportCmd(), output=output_file) 31 | json.loads(Path(output_file).read_text()) 32 | 33 | 34 | def test_export_custom(): 35 | with pytest.raises(CommandError): 36 | call_command(ExportCmd(), api="something.that.doesnotexist") 37 | 38 | with pytest.raises(CommandError) as e: 39 | call_command(ExportCmd(), api="django.core.management.base.BaseCommand") 40 | assert ( 41 | str(e.value) 42 | == "django.core.management.base.BaseCommand is not instance of NinjaAPI!" 43 | ) 44 | 45 | call_command(ExportCmd(), api="demo.urls.api_v1") 46 | call_command(ExportCmd(), api="demo.urls.api_v2") 47 | 48 | 49 | @patch("ninja.management.commands.export_openapi_schema.resolve") 50 | def test_export_default_without_api_endpoint(mock): 51 | mock.side_effect = AttributeError() 52 | output = StringIO() 53 | with pytest.raises(CommandError) as e: 54 | call_command(ExportCmd(), stdout=output) 55 | assert str(e.value) == "No NinjaAPI instance found; please specify one with --api" 56 | -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ninja import Form, NinjaAPI, Schema 4 | from ninja.errors import ConfigError 5 | from ninja.testing import TestClient 6 | 7 | api = NinjaAPI() 8 | 9 | 10 | @api.post("/form") 11 | def form_operation(request, s: str = Form(...), i: int = Form(None)): 12 | return {"s": s, "i": i} 13 | 14 | 15 | client = TestClient(api) 16 | 17 | 18 | def test_form(): 19 | response = client.post("/form") # invalid 20 | assert response.status_code == 422 21 | 22 | response = client.post("/form", POST={"s": "text"}) 23 | assert response.status_code == 200 24 | assert response.json() == {"i": None, "s": "text"} 25 | 26 | response = client.post("/form", POST={"s": "text", "i": None}) 27 | assert response.status_code == 200 28 | assert response.json() == {"i": None, "s": "text"} 29 | 30 | response = client.post("/form", POST={"s": "text", "i": 2}) 31 | assert response.status_code == 200 32 | assert response.json() == {"i": 2, "s": "text"} 33 | 34 | 35 | def test_schema(): 36 | schema = api.get_openapi_schema() 37 | method = schema["paths"]["/api/form"]["post"] 38 | print(method["requestBody"]) 39 | assert method["requestBody"] == { 40 | "content": { 41 | "application/x-www-form-urlencoded": { 42 | "schema": { 43 | "properties": { 44 | "i": {"type": "integer", "title": "I"}, 45 | "s": {"type": "string", "title": "S"}, 46 | }, 47 | "required": ["s"], 48 | "title": "FormParams", 49 | "type": "object", 50 | } 51 | } 52 | }, 53 | "required": True, 54 | } 55 | 56 | 57 | def test_duplicate_names(): 58 | class TestData(Schema): 59 | p1: str 60 | 61 | match = "Duplicated name: 'p1' in params: 'p1' & 'data'" 62 | with pytest.raises(ConfigError, match=match): 63 | 64 | @api.post("/broken1") 65 | def broken1(request, p1: int = Form(...), data: TestData = Form(...)): 66 | pass 67 | 68 | match = "Duplicated name: 'p1' also in 'data'" 69 | with pytest.raises(ConfigError, match=match): 70 | 71 | @api.post("/broken2") 72 | def broken2(request, data: TestData = Form(...), p1: int = Form(...)): 73 | pass 74 | 75 | 76 | # TODO: Fix schema for this case: 77 | # class Credentials(Schema): 78 | # username: str 79 | # password: str 80 | 81 | 82 | # @api.post("/login") 83 | # def login(request, credentials: Credentials = Form(...)): 84 | # return {'username': credentials.username} 85 | -------------------------------------------------------------------------------- /tests/test_forms_and_files.py: -------------------------------------------------------------------------------- 1 | from django.core.files.uploadedfile import SimpleUploadedFile 2 | 3 | from ninja import File, Form, NinjaAPI, UploadedFile 4 | from ninja.testing import TestClient 5 | 6 | api = NinjaAPI() 7 | 8 | 9 | @api.post("/str_and_file") 10 | def str_and_file( 11 | request, 12 | title: str = Form(...), 13 | description: str = Form(""), 14 | file: UploadedFile = File(...), 15 | ): 16 | return {"title": title, "data": file.read().decode()} 17 | 18 | 19 | client = TestClient(api) 20 | 21 | 22 | def test_files(): 23 | file = SimpleUploadedFile("test.txt", b"data123") 24 | response = client.post( 25 | "/str_and_file", 26 | FILES={"file": file}, 27 | POST={"title": "hello"}, 28 | ) 29 | assert response.status_code == 200 30 | assert response.json() == {"title": "hello", "data": "data123"} 31 | 32 | schema = api.get_openapi_schema()["paths"]["/api/str_and_file"] 33 | r_body = schema["post"]["requestBody"] 34 | 35 | assert r_body == { 36 | "content": { 37 | "multipart/form-data": { 38 | "schema": { 39 | "title": "MultiPartBodyParams", 40 | "type": "object", 41 | "properties": { 42 | "title": {"title": "Title", "type": "string"}, 43 | "description": { 44 | "title": "Description", 45 | "default": "", 46 | "type": "string", 47 | }, 48 | "file": { 49 | "title": "File", 50 | "type": "string", 51 | "format": "binary", 52 | }, 53 | }, 54 | "required": ["title", "file"], 55 | } 56 | } 57 | }, 58 | "required": True, 59 | } 60 | -------------------------------------------------------------------------------- /tests/test_inheritance_routers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ninja import NinjaAPI, Router 4 | from ninja.testing import TestClient 5 | 6 | api = NinjaAPI() 7 | 8 | 9 | @api.get("/endpoint") 10 | # view->api 11 | def global_op(request): 12 | return "global" 13 | 14 | 15 | first_router = Router() 16 | 17 | 18 | @first_router.get("/endpoint_1") 19 | # view->router, router->api 20 | def router_op1(request): 21 | return "first 1" 22 | 23 | 24 | second_router_one = Router() 25 | 26 | 27 | @second_router_one.get("endpoint_1") 28 | # view->router2, router2->router1, router1->api 29 | def router_op2(request): 30 | return "second 1" 31 | 32 | 33 | second_router_two = Router() 34 | 35 | 36 | @second_router_two.get("endpoint_2") 37 | # view->router2, router2->router1, router1->api 38 | def router2_op3(request): 39 | return "second 2" 40 | 41 | 42 | first_router.add_router("/second", second_router_one, tags=["one"]) 43 | first_router.add_router("/second", second_router_two, tags=["two"]) 44 | api.add_router("/first", first_router, tags=["global"]) 45 | 46 | 47 | @first_router.get("endpoint_2") 48 | # router->api, view->router 49 | def router1_op1(request): 50 | return "first 2" 51 | 52 | 53 | @second_router_one.get("endpoint_3") 54 | # router2->router1, router1->api, view->router2 55 | def router21_op3(request, path_param: int = None): 56 | return "second 3" if path_param is None else f"second 3: {path_param}" 57 | 58 | 59 | second_router_three = Router() 60 | 61 | 62 | @second_router_three.get("endpoint_4") 63 | # router1->api, view->router2, router2->router1 64 | def router_op3(request, path_param: int = None): 65 | return "second 4" if path_param is None else f"second 4: {path_param}" 66 | 67 | 68 | first_router.add_router("/second", second_router_three, tags=["three"]) 69 | 70 | 71 | client = TestClient(api) 72 | 73 | 74 | @pytest.mark.parametrize( 75 | "path,expected_status,expected_response", 76 | [ 77 | ("/endpoint", 200, "global"), 78 | ("/first/endpoint_1", 200, "first 1"), 79 | ("/first/endpoint_2", 200, "first 2"), 80 | ("/first/second/endpoint_1", 200, "second 1"), 81 | ("/first/second/endpoint_2", 200, "second 2"), 82 | ("/first/second/endpoint_3", 200, "second 3"), 83 | ("/first/second/endpoint_4", 200, "second 4"), 84 | ], 85 | ) 86 | def test_inheritance_responses(path, expected_status, expected_response): 87 | response = client.get(path) 88 | assert response.status_code == expected_status, response.content 89 | assert response.json() == expected_response 90 | 91 | 92 | def test_tags(): 93 | schema = api.get_openapi_schema() 94 | # print(schema) 95 | glob = schema["paths"]["/api/first/endpoint_1"]["get"] 96 | assert glob["tags"] == ["global"] 97 | 98 | e1 = schema["paths"]["/api/first/second/endpoint_1"]["get"] 99 | assert e1["tags"] == ["one"] 100 | 101 | e2 = schema["paths"]["/api/first/second/endpoint_2"]["get"] 102 | assert e2["tags"] == ["two"] 103 | -------------------------------------------------------------------------------- /tests/test_misc.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import uuid 3 | 4 | import pytest 5 | from pydantic import BaseModel 6 | 7 | from ninja import NinjaAPI 8 | from ninja.constants import NOT_SET 9 | from ninja.signature.details import is_pydantic_model 10 | from ninja.signature.utils import NinjaUUIDConverter 11 | from ninja.testing import TestClient 12 | 13 | 14 | def test_is_pydantic_model(): 15 | class Model(BaseModel): 16 | x: int 17 | 18 | assert is_pydantic_model(Model) 19 | assert is_pydantic_model("instance") is False 20 | 21 | 22 | def test_client(): 23 | "covering everything in testclient (including invalid paths)" 24 | api = NinjaAPI() 25 | client = TestClient(api) 26 | with pytest.raises(Exception): # noqa: B017 27 | client.get("/404") 28 | 29 | 30 | def test_kwargs(): 31 | api = NinjaAPI() 32 | 33 | @api.get("/") 34 | def operation(request, a: str, *args, **kwargs): 35 | pass 36 | 37 | schema = api.get_openapi_schema() 38 | params = schema["paths"]["/api/"]["get"]["parameters"] 39 | print(params) 40 | assert params == [ # Only `a` should be here, not kwargs 41 | { 42 | "in": "query", 43 | "name": "a", 44 | "schema": {"title": "A", "type": "string"}, 45 | "required": True, 46 | } 47 | ] 48 | 49 | 50 | def test_uuid_converter(): 51 | conv = NinjaUUIDConverter() 52 | assert isinstance(conv.to_url(uuid.uuid4()), str) 53 | 54 | 55 | def test_copy_not_set(): 56 | assert id(NOT_SET) == id(copy.copy(NOT_SET)) 57 | assert id(NOT_SET) == id(copy.deepcopy(NOT_SET)) 58 | -------------------------------------------------------------------------------- /tests/test_openapi_docs.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.test import override_settings 3 | 4 | from ninja import NinjaAPI, Redoc, Swagger 5 | from ninja.testing import TestClient 6 | 7 | NO_NINJA_INSTALLED_APPS = [i for i in settings.INSTALLED_APPS if i != "ninja"] 8 | 9 | 10 | def test_swagger(): 11 | "Default engine is swagger" 12 | api = NinjaAPI() 13 | 14 | assert isinstance(api.docs, Swagger) 15 | 16 | client = TestClient(api) 17 | 18 | response = client.get("/docs") 19 | assert response.status_code == 200 20 | assert b"swagger-ui-init.js" in response.content 21 | 22 | # Testing without ninja in INSTALLED_APPS 23 | @override_settings(INSTALLED_APPS=NO_NINJA_INSTALLED_APPS) 24 | def call_docs(): 25 | response = client.get("/docs") 26 | assert response.status_code == 200 27 | assert b"https://cdn.jsdelivr.net/npm/swagger-ui-dist" in response.content 28 | 29 | call_docs() 30 | 31 | 32 | def test_swagger_settings(): 33 | api = NinjaAPI(docs=Swagger(settings={"persistAuthorization": True})) 34 | client = TestClient(api) 35 | response = client.get("/docs") 36 | assert response.status_code == 200 37 | assert b'"persistAuthorization": true' in response.content 38 | 39 | 40 | def test_redoc(): 41 | api = NinjaAPI(docs=Redoc()) 42 | client = TestClient(api) 43 | 44 | response = client.get("/docs") 45 | assert response.status_code == 200 46 | assert b"redoc.standalone.js" in response.content 47 | 48 | # Testing without ninja in INSTALLED_APPS 49 | @override_settings(INSTALLED_APPS=NO_NINJA_INSTALLED_APPS) 50 | def call_docs(): 51 | response = client.get("/docs") 52 | assert response.status_code == 200 53 | assert ( 54 | b"https://cdn.jsdelivr.net/npm/redoc@2.0.0/bundles/redoc.standalone.js" 55 | in response.content 56 | ) 57 | 58 | call_docs() 59 | 60 | 61 | def test_redoc_settings(): 62 | api = NinjaAPI(docs=Redoc(settings={"disableSearch": True})) 63 | client = TestClient(api) 64 | response = client.get("/docs") 65 | assert response.status_code == 200 66 | assert b'"disableSearch": true' in response.content 67 | -------------------------------------------------------------------------------- /tests/test_openapi_extra.py: -------------------------------------------------------------------------------- 1 | from ninja import NinjaAPI 2 | 3 | 4 | def test_openapi_info_defined(): 5 | "Test appending schema.info" 6 | extra_info = { 7 | "termsOfService": "https://example.com/terms/", 8 | "title": "Test API", 9 | } 10 | api = NinjaAPI(openapi_extra={"info": extra_info}, version="1.0.0") 11 | schema = api.get_openapi_schema() 12 | 13 | assert schema["info"]["termsOfService"] == "https://example.com/terms/" 14 | assert schema["info"]["title"] == "Test API" 15 | assert schema["info"]["version"] == "1.0.0" 16 | 17 | 18 | def test_openapi_no_additional_info(): 19 | api = NinjaAPI(title="Test API") 20 | schema = api.get_openapi_schema() 21 | 22 | assert schema["info"]["title"] == "Test API" 23 | assert "termsOfService" not in schema["info"] 24 | 25 | 26 | def test_openapi_extra(): 27 | "Test adding extra attribute to the schema" 28 | api = NinjaAPI( 29 | openapi_extra={ 30 | "externalDocs": { 31 | "description": "Find more info here", 32 | "url": "https://example.com", 33 | } 34 | }, 35 | version="1.0.0", 36 | ) 37 | schema = api.get_openapi_schema() 38 | 39 | assert schema == { 40 | "openapi": "3.1.0", 41 | "info": {"title": "NinjaAPI", "version": "1.0.0", "description": ""}, 42 | "paths": {}, 43 | "components": {"schemas": {}}, 44 | "servers": [], 45 | "externalDocs": { 46 | "description": "Find more info here", 47 | "url": "https://example.com", 48 | }, 49 | } 50 | -------------------------------------------------------------------------------- /tests/test_orm_relations.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from ninja import NinjaAPI 4 | from ninja.orm import create_schema 5 | from ninja.testing import TestClient 6 | 7 | 8 | def test_manytomany(): 9 | class SomeRelated(models.Model): 10 | f = models.CharField() 11 | 12 | class Meta: 13 | app_label = "tests" 14 | 15 | class ModelWithM2M(models.Model): 16 | m2m = models.ManyToManyField(SomeRelated, blank=True) 17 | 18 | class Meta: 19 | app_label = "tests" 20 | 21 | WithM2MSchema = create_schema(ModelWithM2M, exclude=["id"]) 22 | 23 | api = NinjaAPI() 24 | 25 | @api.post("/bar") 26 | def post_with_m2m(request, payload: WithM2MSchema): 27 | return payload.dict() 28 | 29 | client = TestClient(api) 30 | 31 | response = client.post("/bar", json={"m2m": [1, 2]}) 32 | assert response.status_code == 200, str(response.json()) 33 | assert response.json() == {"m2m": [1, 2]} 34 | 35 | response = client.post("/bar", json={"m2m": []}) 36 | assert response.status_code == 200, str(response.json()) 37 | assert response.json() == {"m2m": []} 38 | -------------------------------------------------------------------------------- /tests/test_pagination_router.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import pytest 4 | 5 | from ninja import NinjaAPI, Schema 6 | from ninja.pagination import RouterPaginated 7 | from ninja.testing import TestAsyncClient, TestClient 8 | 9 | api = NinjaAPI(default_router=RouterPaginated()) 10 | 11 | 12 | class ItemSchema(Schema): 13 | id: int 14 | 15 | 16 | @api.get("/items", response=List[ItemSchema]) 17 | def items(request): 18 | return [{"id": i} for i in range(1, 51)] 19 | 20 | 21 | @api.get("/items_nolist", response=ItemSchema) 22 | def items_nolist(request): 23 | return {"id": 1} 24 | 25 | 26 | client = TestClient(api) 27 | 28 | 29 | def test_for_list_reponse(): 30 | parameters = api.get_openapi_schema()["paths"]["/api/items"]["get"]["parameters"] 31 | assert parameters == [ 32 | { 33 | "in": "query", 34 | "name": "limit", 35 | "schema": { 36 | "title": "Limit", 37 | "default": 100, 38 | "minimum": 1, 39 | "type": "integer", 40 | }, 41 | "required": False, 42 | }, 43 | { 44 | "in": "query", 45 | "name": "offset", 46 | "schema": { 47 | "title": "Offset", 48 | "default": 0, 49 | "minimum": 0, 50 | "type": "integer", 51 | }, 52 | "required": False, 53 | }, 54 | ] 55 | 56 | response = client.get("/items?offset=5&limit=1").json() 57 | # print(response) 58 | assert response == {"items": [{"id": 6}], "count": 50} 59 | 60 | 61 | def test_for_NON_list_reponse(): 62 | parameters = api.get_openapi_schema()["paths"]["/api/items_nolist"]["get"][ 63 | "parameters" 64 | ] 65 | # print(parameters) 66 | assert parameters == [] 67 | 68 | 69 | @pytest.mark.asyncio 70 | async def test_async_pagination(): 71 | @api.get("/items_async", response=List[ItemSchema]) 72 | async def items_async(request): 73 | return [{"id": i} for i in range(1, 51)] 74 | 75 | client = TestAsyncClient(api) 76 | 77 | response = await client.get("/items_async?offset=5&limit=1") 78 | assert response.json() == {"items": [{"id": 6}], "count": 50} 79 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from django.http import HttpRequest, QueryDict 4 | 5 | from ninja import NinjaAPI 6 | from ninja.parser import Parser 7 | from ninja.testing import TestClient 8 | 9 | 10 | class MyParser(Parser): 11 | "Default json parser" 12 | 13 | def parse_body(self, request: HttpRequest): 14 | "just splitting body to lines" 15 | return request.body.encode().splitlines() 16 | 17 | def parse_querydict( 18 | self, data: QueryDict, list_fields: List[str], request: HttpRequest 19 | ): 20 | "Turning empty Query params to None instead of empty string" 21 | result = super().parse_querydict(data, list_fields, request) 22 | for k, v in list(result.items()): 23 | if v == "": 24 | del result[k] 25 | return result 26 | 27 | 28 | api = NinjaAPI(parser=MyParser()) 29 | 30 | 31 | @api.post("/test") 32 | def operation(request, body: List[str], emptyparam: str = None): 33 | return {"emptyparam": emptyparam, "body": body} 34 | 35 | 36 | def test_parser(): 37 | client = TestClient(api) 38 | response = client.post("/test?emptyparam", body="test\nbar") 39 | assert response.status_code == 200, response.content 40 | assert response.json() == {"emptyparam": None, "body": ["test", "bar"]} 41 | -------------------------------------------------------------------------------- /tests/test_patch_dict.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import pytest 4 | 5 | from ninja import NinjaAPI, Schema 6 | from ninja.patch_dict import PatchDict 7 | from ninja.testing import TestClient 8 | 9 | api = NinjaAPI() 10 | 11 | client = TestClient(api) 12 | 13 | 14 | class SomeSchema(Schema): 15 | name: str 16 | age: int 17 | category: Optional[str] = None 18 | 19 | 20 | @api.patch("/patch") 21 | def patch(request, payload: PatchDict[SomeSchema]): 22 | return {"payload": payload, "type": str(type(payload))} 23 | 24 | 25 | @pytest.mark.parametrize( 26 | "input,output", 27 | [ 28 | ({"name": "foo"}, {"name": "foo"}), 29 | ({"age": "1"}, {"age": 1}), 30 | ({}, {}), 31 | ({"wrong_param": 1}, {}), 32 | ({"age": None}, {"age": None}), 33 | ], 34 | ) 35 | def test_patch_calls(input: dict, output: dict): 36 | response = client.patch("/patch", json=input) 37 | assert response.json() == {"payload": output, "type": ""} 38 | 39 | 40 | def test_schema(): 41 | "Checking that json schema properties are all optional" 42 | schema = api.get_openapi_schema() 43 | assert schema["components"]["schemas"]["SomeSchemaPatch"] == { 44 | "title": "SomeSchemaPatch", 45 | "type": "object", 46 | "properties": { 47 | "name": { 48 | "anyOf": [{"type": "string"}, {"type": "null"}], 49 | "title": "Name", 50 | }, 51 | "age": { 52 | "anyOf": [{"type": "integer"}, {"type": "null"}], 53 | "title": "Age", 54 | }, 55 | "category": { 56 | "anyOf": [{"type": "string"}, {"type": "null"}], 57 | "title": "Category", 58 | }, 59 | }, 60 | } 61 | -------------------------------------------------------------------------------- /tests/test_pydantic_migrate.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from typing import Optional 3 | 4 | import pytest 5 | from django.db import models 6 | from pydantic import BaseModel, ValidationError 7 | 8 | from ninja import ModelSchema, Schema 9 | 10 | 11 | class OptModel(BaseModel): 12 | a: int = None 13 | b: Optional[int] 14 | c: Optional[int] = None 15 | 16 | 17 | class OptSchema(Schema): 18 | a: int = None 19 | b: Optional[int] 20 | c: Optional[int] = None 21 | 22 | 23 | def test_optional_pydantic_model(): 24 | with pytest.raises(ValidationError): 25 | OptModel().dict() 26 | 27 | assert OptModel(b=None).model_dump() == {"a": None, "b": None, "c": None} 28 | 29 | 30 | def test_optional_schema(): 31 | with pytest.raises(ValidationError): 32 | OptSchema().dict() 33 | 34 | assert OptSchema(b=None).dict() == {"a": None, "b": None, "c": None} 35 | 36 | 37 | def test_deprecated_schema(): 38 | with warnings.catch_warnings(record=True) as w: 39 | OptSchema.schema() 40 | assert w[0].message.args == (".schema() is deprecated, use .json_schema() instead",) 41 | 42 | 43 | def test_orm_config(): 44 | class SomeCustomModel(models.Model): 45 | f1 = models.CharField() 46 | f2 = models.CharField(blank=True, null=True) 47 | 48 | class Meta: 49 | app_label = "tests" 50 | 51 | class SomeCustomSchema(ModelSchema): 52 | f3: int 53 | f4: int = 1 54 | _private: str = "" # private should be ignored 55 | 56 | class Meta: 57 | model = SomeCustomModel 58 | fields = ["f1", "f2"] 59 | 60 | assert SomeCustomSchema.json_schema() == { 61 | "title": "SomeCustomSchema", 62 | "type": "object", 63 | "properties": { 64 | "f1": {"title": "F1", "type": "string"}, 65 | "f2": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "F2"}, 66 | "f3": {"title": "F3", "type": "integer"}, 67 | "f4": {"title": "F4", "default": 1, "type": "integer"}, 68 | }, 69 | "required": ["f3", "f1"], 70 | } 71 | -------------------------------------------------------------------------------- /tests/test_query.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from main import router 3 | 4 | from ninja.testing import TestClient 5 | 6 | response_missing = { 7 | "detail": [ 8 | { 9 | "type": "missing", 10 | "loc": ["query", "query"], 11 | "msg": "Field required", 12 | } 13 | ] 14 | } 15 | 16 | response_not_valid_int = { 17 | "detail": [ 18 | { 19 | "type": "int_parsing", 20 | "loc": ["query", "query"], 21 | "msg": "Input should be a valid integer, unable to parse string as an integer", 22 | } 23 | ] 24 | } 25 | 26 | response_not_valid_int_float = { 27 | "detail": [ 28 | { 29 | "type": "int_parsing", 30 | "loc": ["query", "query"], 31 | "msg": "Input should be a valid integer, unable to parse string as an integer", 32 | } 33 | ] 34 | } 35 | 36 | 37 | client = TestClient(router) 38 | 39 | 40 | @pytest.mark.parametrize( 41 | "path,expected_status,expected_response", 42 | [ 43 | ("/query", 422, response_missing), 44 | ("/query?query=baz", 200, "foo bar baz"), 45 | ("/query?not_declared=baz", 422, response_missing), 46 | ("/query/optional", 200, "foo bar"), 47 | ("/query/optional?query=baz", 200, "foo bar baz"), 48 | ("/query/optional?not_declared=baz", 200, "foo bar"), 49 | ("/query/int", 422, response_missing), 50 | ("/query/int?query=42", 200, "foo bar 42"), 51 | ("/query/int?query=42.5", 422, response_not_valid_int_float), 52 | ("/query/int?query=baz", 422, response_not_valid_int), 53 | ("/query/int?not_declared=baz", 422, response_missing), 54 | ("/query/int/optional", 200, "foo bar"), 55 | ("/query/int/optional?query=50", 200, "foo bar 50"), 56 | ("/query/int/optional?query=foo", 422, response_not_valid_int), 57 | ("/query/int/default", 200, "foo bar 10"), 58 | ("/query/int/default?query=50", 200, "foo bar 50"), 59 | ("/query/int/default?query=foo", 422, response_not_valid_int), 60 | ("/query/list?query=a&query=b&query=c", 200, "a,b,c"), 61 | ("/query/list-optional?query=a&query=b&query=c", 200, "a,b,c"), 62 | ("/query/list-optional?query=a", 200, "a"), 63 | ("/query/list-optional", 200, None), 64 | ("/query/param", 200, "foo bar"), 65 | ("/query/param?query=50", 200, "foo bar 50"), 66 | ("/query/param-required", 422, response_missing), 67 | ("/query/param-required?query=50", 200, "foo bar 50"), 68 | ("/query/param-required/int", 422, response_missing), 69 | ("/query/param-required/int?query=50", 200, "foo bar 50"), 70 | ("/query/param-required/int?query=foo", 422, response_not_valid_int), 71 | ("/query/aliased-name?aliased.-_~name=foo", 200, "foo bar foo"), 72 | ], 73 | ) 74 | def test_get_path(path, expected_status, expected_response): 75 | response = client.get(path) 76 | resp = response.json() 77 | print(resp) 78 | assert response.status_code == expected_status 79 | assert resp == expected_response 80 | -------------------------------------------------------------------------------- /tests/test_response_cookies.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | from ninja import NinjaAPI 4 | from ninja.testing import TestClient 5 | 6 | api = NinjaAPI() 7 | 8 | 9 | @api.get("/test-no-cookies") 10 | def op_no_cookies(request): 11 | return {} 12 | 13 | 14 | @api.get("/test-set-cookie") 15 | def op_set_cookie(request): 16 | response = HttpResponse() 17 | response.set_cookie(key="sessionid", value="sessionvalue") 18 | return response 19 | 20 | 21 | client = TestClient(api) 22 | 23 | 24 | def test_cookies(): 25 | assert bool(client.get("/test-no-cookies").cookies) is False 26 | assert "sessionid" in client.get("/test-set-cookie").cookies 27 | -------------------------------------------------------------------------------- /tests/test_response_params.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from ninja import NinjaAPI, Schema 4 | from ninja.testing import TestClient 5 | 6 | api = NinjaAPI() 7 | 8 | 9 | class SomeResponse(Schema): 10 | field1: Optional[int] = 1 11 | field2: Optional[str] = "default value" 12 | field3: Optional[int] = None 13 | 14 | 15 | @api.get("/test-no-params", response=SomeResponse) 16 | def op_no_params(request): 17 | return {} # should set defaults from schema 18 | 19 | 20 | @api.get("/test-unset", response=SomeResponse, exclude_unset=True) 21 | def op_exclude_unset(request): 22 | return {"field3": 10} 23 | 24 | 25 | @api.get("/test-defaults", response=SomeResponse, exclude_defaults=True) 26 | def op_exclude_defaults(request): 27 | # changing only field1 28 | return {"field1": 3, "field2": "default value"} 29 | 30 | 31 | @api.get("/test-none", response=SomeResponse, exclude_none=True) 32 | def op_exclude_none(request): 33 | # setting field1 to None to exclude 34 | return {"field1": None, "field2": "default value"} 35 | 36 | 37 | client = TestClient(api) 38 | 39 | 40 | def test_arguments(): 41 | assert client.get("/test-no-params").json() == { 42 | "field1": 1, 43 | "field2": "default value", 44 | "field3": None, 45 | } 46 | assert client.get("/test-unset").json() == {"field3": 10} 47 | assert client.get("/test-defaults").json() == {"field1": 3} 48 | assert client.get("/test-none").json() == {"field2": "default value"} 49 | -------------------------------------------------------------------------------- /tests/test_reverse.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import reverse 3 | 4 | 5 | @pytest.mark.parametrize( 6 | "view_name, path", 7 | [ 8 | ("foobar", "/api/v3/foobar"), 9 | ("post_foobar", "/api/v3/foobar"), 10 | ("foobar_put", "/api/v3/foobar"), 11 | ], 12 | ) 13 | def test_reverse(view_name, path): 14 | assert reverse(f"api-3.0.0:{view_name}") == path 15 | -------------------------------------------------------------------------------- /tests/test_router_add_router.py: -------------------------------------------------------------------------------- 1 | from ninja import NinjaAPI, Router 2 | from ninja.testing import TestClient 3 | 4 | router = Router() 5 | 6 | 7 | @router.get("/") 8 | def op(request): 9 | return True 10 | 11 | 12 | def test_add_router_with_string_path(): 13 | main_router = Router() 14 | main_router.add_router("sub", "tests.test_router_add_router.router") 15 | 16 | api = NinjaAPI() 17 | api.add_router("main", main_router) 18 | 19 | client = TestClient(api) 20 | 21 | assert client.get("/main/sub/").status_code == 200 22 | -------------------------------------------------------------------------------- /tests/test_router_defaults.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import pytest 4 | from pydantic import Field 5 | 6 | from ninja import NinjaAPI, Router, Schema 7 | from ninja.testing import TestClient 8 | 9 | 10 | class SomeResponse(Schema): 11 | field1: Optional[int] = 1 12 | field2: Optional[str] = "default value" 13 | field3: Optional[int] = Field(None, alias="aliased") 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "oparg,retdict,assertone,asserttwo", 18 | [ 19 | ( 20 | "exclude_defaults", 21 | {"field1": 3}, 22 | {"field1": 3}, 23 | {"field1": 3, "field2": "default value", "field3": None}, 24 | ), 25 | ( 26 | "exclude_unset", 27 | {"field2": "test"}, 28 | {"field2": "test"}, 29 | {"field1": 1, "field2": "test", "field3": None}, 30 | ), 31 | ( 32 | "exclude_none", 33 | {"field1": None, "field2": None, "aliased": 10}, 34 | {"field3": 10}, 35 | {"field1": None, "field2": None, "field3": 10}, 36 | ), 37 | ( 38 | "by_alias", 39 | {"aliased": 10}, 40 | {"field1": 1, "field2": "default value", "aliased": 10}, 41 | {"field1": 1, "field2": "default value", "field3": 10}, 42 | ), 43 | ], 44 | ) 45 | def test_router_defaults(oparg, retdict, assertone, asserttwo): 46 | """Test that the router level settings work and can be overridden at the op level""" 47 | api = NinjaAPI() 48 | router = Router(**{oparg: True}) 49 | api.add_router("/", router) 50 | 51 | func1 = router.get("/test1", response=SomeResponse)(lambda request: retdict) 52 | func2 = router.get("/test2", response=SomeResponse, **{oparg: False})( 53 | lambda request: retdict 54 | ) 55 | 56 | client = TestClient(api) 57 | 58 | assert getattr(func1._ninja_operation, oparg) is True 59 | assert getattr(func2._ninja_operation, oparg) is False 60 | 61 | assert client.get("/test1").json() == assertone 62 | assert client.get("/test2").json() == asserttwo 63 | -------------------------------------------------------------------------------- /tests/test_router_path_params.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ninja import NinjaAPI, Path, Router 4 | from ninja.testing import TestClient 5 | 6 | api = NinjaAPI() 7 | router_with_path_type = Router() 8 | router_without_path_type = Router() 9 | router_with_multiple = Router() 10 | 11 | 12 | @router_with_path_type.get("/metadata") 13 | def get_item_metadata(request, item_id: int = Path(None)) -> int: 14 | return item_id 15 | 16 | 17 | @router_without_path_type.get("/") 18 | def get_item_metadata_2(request, item_id: str = Path(None)) -> str: 19 | return item_id 20 | 21 | 22 | @router_without_path_type.get("/metadata") 23 | def get_item_metadata_3(request, item_id: str = Path(None)) -> str: 24 | return item_id 25 | 26 | 27 | @router_without_path_type.get("/") 28 | def get_item_metadata_4(request, item_id: str = Path(None)) -> str: 29 | return item_id 30 | 31 | 32 | @router_with_multiple.get("/metadata/{kind}") 33 | def get_item_metadata_5( 34 | request, item_id: int = Path(None), name: str = Path(None), kind: str = Path(None) 35 | ) -> str: 36 | return f"{item_id} {name} {kind}" 37 | 38 | 39 | api.add_router("/with_type/{int:item_id}", router_with_path_type) 40 | api.add_router("/without_type/{item_id}", router_without_path_type) 41 | api.add_router("/with_multiple/{int:item_id}/name/{name}", router_with_multiple) 42 | 43 | client = TestClient(api) 44 | 45 | 46 | @pytest.mark.parametrize( 47 | "path,expected_status,expected_response", 48 | [ 49 | ("/with_type/1/metadata", 200, 1), 50 | ("/without_type/1/metadata", 200, "1"), 51 | ("/without_type/abc/metadata", 200, "abc"), 52 | ("/with_multiple/99/name/foo/metadata/timestamp", 200, "99 foo timestamp"), 53 | ], 54 | ) 55 | def test_router_with_path_params(path, expected_status, expected_response): 56 | response = client.get(path) 57 | assert response.status_code == expected_status 58 | assert response.json() == expected_response 59 | 60 | 61 | @pytest.mark.parametrize( 62 | "path,expected_exception,expect_exception_contains", 63 | [ 64 | ("/with_type/abc/metadata", Exception, "Cannot resolve"), 65 | ("/with_type//metadata", Exception, "Cannot resolve"), 66 | ("/with_type/null/metadata", Exception, "Cannot resolve"), 67 | ("/with_type", Exception, "Cannot resolve"), 68 | ("/with_type/", Exception, "Cannot resolve"), 69 | ("/with_type//", Exception, "Cannot resolve"), 70 | ("/with_type/null", Exception, "Cannot resolve"), 71 | ("/with_type/null/", Exception, "Cannot resolve"), 72 | ("/without_type", Exception, "Cannot resolve"), 73 | ("/without_type/", Exception, "Cannot resolve"), 74 | ("/without_type//", Exception, "Cannot resolve"), 75 | ("/with_multiple/abc/name/foo/metadata/timestamp", Exception, "Cannot resolve"), 76 | ("/with_multiple/99", Exception, "Cannot resolve"), 77 | ], 78 | ) 79 | def test_router_with_path_params_nomatch( 80 | path, expected_exception, expect_exception_contains 81 | ): 82 | with pytest.raises(expected_exception, match=expect_exception_contains): 83 | client.get(path) 84 | -------------------------------------------------------------------------------- /tests/test_schema_context.py: -------------------------------------------------------------------------------- 1 | from ninja import NinjaAPI, Schema 2 | from ninja.testing import TestClient 3 | 4 | 5 | class ResolveWithKWargs(Schema): 6 | value: int 7 | 8 | @staticmethod 9 | def resolve_value(obj, **kwargs): 10 | context = kwargs["context"] 11 | return obj["value"] + context["extra"] 12 | 13 | 14 | class ResolveWithContext(Schema): 15 | value: int 16 | 17 | @staticmethod 18 | def resolve_value(obj, context): 19 | return obj["value"] + context["extra"] 20 | 21 | 22 | class DataWithRequestContext(Schema): 23 | value: dict = None 24 | other: dict = None 25 | 26 | @staticmethod 27 | def resolve_value(obj, context): 28 | result = {k: str(v) for k, v in context.items()} 29 | assert "request" in result, "request not in context" 30 | result["request"] = "" # making it static for easier testing 31 | return result 32 | 33 | 34 | api = NinjaAPI() 35 | 36 | 37 | @api.post("/resolve_ctx", response=DataWithRequestContext) 38 | def resolve_ctx(request, data: DataWithRequestContext): 39 | return {"other": data.dict()} 40 | 41 | 42 | client = TestClient(api) 43 | 44 | 45 | def test_schema_with_context(): 46 | obj = ResolveWithKWargs.model_validate({"value": 10}, context={"extra": 10}) 47 | assert obj.value == 20 48 | 49 | obj = ResolveWithContext.model_validate({"value": 2}, context={"extra": 2}) 50 | assert obj.value == 4 51 | 52 | obj = ResolveWithContext.from_orm({"value": 2}, context={"extra": 2}) 53 | assert obj.value == 4 54 | 55 | 56 | def test_request_context(): 57 | resp = client.post("/resolve_ctx", json={}) 58 | assert resp.status_code == 200, resp.content 59 | assert resp.json() == { 60 | "other": {"value": {"request": ""}, "other": None}, 61 | "value": {"request": "", "response_status": "200"}, 62 | } 63 | -------------------------------------------------------------------------------- /tests/test_serialization_context.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | from pydantic import model_serializer 5 | 6 | from ninja import Router, Schema 7 | from ninja.schema import pydantic_version 8 | from ninja.testing import TestClient 9 | 10 | 11 | def api_endpoint_test(request): 12 | return { 13 | "test1": "foo", 14 | "test2": "bar", 15 | } 16 | 17 | 18 | @pytest.mark.skipif( 19 | pydantic_version < [2, 7], 20 | reason="Serialization context was introduced in Pydantic 2.7", 21 | ) 22 | def test_request_is_passed_in_context_when_supported(): 23 | class SchemaWithCustomSerializer(Schema): 24 | test1: str 25 | test2: str 26 | 27 | @model_serializer(mode="wrap") 28 | def ser_model(self, handler, info): 29 | assert "request" in info.context 30 | assert info.context["request"].path == "/test" # check it is HttRequest 31 | assert "response_status" in info.context 32 | 33 | return handler(self) 34 | 35 | router = Router() 36 | router.add_api_operation( 37 | "/test", ["GET"], api_endpoint_test, response=SchemaWithCustomSerializer 38 | ) 39 | 40 | TestClient(router).get("/test") 41 | 42 | 43 | @pytest.mark.parametrize( 44 | ["pydantic_version"], 45 | [ 46 | [[2, 0]], 47 | [[2, 4]], 48 | [[2, 6]], 49 | ], 50 | ) 51 | def test_no_serialisation_context_used_when_no_supported(pydantic_version): 52 | class SchemaWithCustomSerializer(Schema): 53 | test1: str 54 | test2: str 55 | 56 | @model_serializer(mode="wrap") 57 | def ser_model(self, handler, info): 58 | if hasattr(info, "context"): 59 | # an actually newer Pydantic, but pydantic_version is still mocked, so no context is expected 60 | assert info.context is None 61 | 62 | return handler(self) 63 | 64 | with mock.patch("ninja.operation.pydantic_version", pydantic_version): 65 | router = Router() 66 | router.add_api_operation( 67 | "/test", ["GET"], api_endpoint_test, response=SchemaWithCustomSerializer 68 | ) 69 | 70 | resp_json = TestClient(router).get("/test").json() 71 | 72 | assert resp_json == { 73 | "test1": "foo", 74 | "test2": "bar", 75 | } 76 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | from ninja import NinjaAPI 2 | 3 | 4 | class TestServer: 5 | def test_server_basic(self): 6 | server = {"url": "http://example.com"} 7 | api = NinjaAPI(servers=[server]) 8 | schema = api.get_openapi_schema() 9 | 10 | schema_server = schema["servers"] 11 | assert schema_server == [server] 12 | 13 | def test_server_with_description(self): 14 | server = { 15 | "url": "http://example.com", 16 | "description": "this is the example server", 17 | } 18 | api = NinjaAPI(servers=[server]) 19 | schema = api.get_openapi_schema() 20 | 21 | schema_server = schema["servers"] 22 | assert schema_server == [server] 23 | 24 | def test_multiple_servers_with_description(self): 25 | server_1 = { 26 | "url": "http://example1.com", 27 | "description": "this is the example server 1", 28 | } 29 | server_2 = { 30 | "url": "http://example2.com", 31 | "description": "this is the example server 2", 32 | } 33 | servers = [server_1, server_2] 34 | api = NinjaAPI(servers=servers) 35 | schema = api.get_openapi_schema() 36 | 37 | schema_server = schema["servers"] 38 | assert schema_server == servers 39 | -------------------------------------------------------------------------------- /tests/test_signature_details.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from sys import version_info 3 | 4 | import pytest 5 | 6 | from ninja.signature.details import is_collection_type 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ("annotation", "expected"), 11 | [ 12 | pytest.param(typing.List, True, id="true_for_typing_List"), 13 | pytest.param(list, True, id="true_for_native_list"), 14 | pytest.param(typing.Set, True, id="true_for_typing_Set"), 15 | pytest.param(set, True, id="true_for_native_set"), 16 | pytest.param(typing.Tuple, True, id="true_for_typing_Tuple"), 17 | pytest.param(tuple, True, id="true_for_native_tuple"), 18 | pytest.param( 19 | typing.Optional[typing.List[str]], True, id="true_for_optional_list" 20 | ), 21 | pytest.param( 22 | type("Custom", (), {}), 23 | False, 24 | id="false_for_custom_type_without_typing_origin", 25 | ), 26 | pytest.param( 27 | object(), False, id="false_for_custom_instance_without_typing_origin" 28 | ), 29 | pytest.param( 30 | typing.NewType("SomethingNew", str), 31 | False, 32 | id="false_for_instance_without_typing_origin", 33 | ), 34 | # Can't mark with `pytest.mark.skipif` since we'd attempt to instantiate the 35 | # parameterized value/type(e.g. `list[int]`). Which only works with Python >= 3.9) 36 | *( 37 | ( 38 | pytest.param(list[int], True, id="true_for_parameterized_native_list"), 39 | pytest.param(set[int], True, id="true_for_parameterized_native_set"), 40 | pytest.param( 41 | tuple[int], True, id="true_for_parameterized_native_tuple" 42 | ), 43 | ) 44 | # TODO: Remove conditional once support for <=3.8 is dropped 45 | if version_info >= (3, 9) 46 | else () 47 | ), 48 | ], 49 | ) 50 | def test_is_collection_type_returns(annotation: typing.Any, expected: bool): 51 | assert is_collection_type(annotation) is expected 52 | -------------------------------------------------------------------------------- /tests/test_union.py: -------------------------------------------------------------------------------- 1 | # This is no longer the case in pydantic 2 2 | # https://github.com/pydantic/pydantic/issues/5991 3 | # from datetime import date 4 | # from typing import Union 5 | 6 | # from ninja import Router 7 | # from ninja.testing import TestClient 8 | 9 | # router = Router() 10 | 11 | 12 | # @router.get("/test") 13 | # def view(request, value: Union[date, str]): 14 | # return [value, type(value).__name__] 15 | 16 | 17 | # client = TestClient(router) 18 | 19 | 20 | # def test_union(): 21 | # assert client.get("/test?value=today").json() == ["today", "str"] 22 | # assert client.get("/test?value=2020-01-15").json() == ["2020-01-15", "date"] 23 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ninja import NinjaAPI, Query 4 | from ninja.utils import contribute_operation_args, replace_path_param_notation 5 | 6 | 7 | @pytest.mark.parametrize( 8 | "input,expected_output", 9 | [ 10 | ("abc/{def}", "abc/"), 11 | ("abc/", "abc/"), 12 | ("abc", "abc"), 13 | ("", ""), 14 | ("{abc}", ""), 15 | ("{abc}/{def}", "/"), 16 | ], 17 | ) 18 | def test_replace_path_param_notation(input, expected_output): 19 | assert replace_path_param_notation(input) == expected_output 20 | 21 | 22 | def test_contribute_operation_args(): 23 | def some_func(): 24 | pass 25 | 26 | contribute_operation_args(some_func, "arg1", str, Query(...)) 27 | contribute_operation_args(some_func, "arg2", int, Query(...)) 28 | 29 | api = NinjaAPI() 30 | 31 | api.get("/test")(some_func) 32 | 33 | schema = api.get_openapi_schema() 34 | assert schema["paths"]["/api/test"]["get"]["parameters"] == [ 35 | { 36 | "in": "query", 37 | "name": "arg1", 38 | "schema": {"title": "Arg1", "type": "string"}, 39 | "required": True, 40 | }, 41 | { 42 | "in": "query", 43 | "name": "arg2", 44 | "schema": {"title": "Arg2", "type": "integer"}, 45 | "required": True, 46 | }, 47 | ] 48 | -------------------------------------------------------------------------------- /tests/test_with_django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdevita/django-shinobi/9bf6f43d7e60cea6fcd9aab7b7a37213e12599df/tests/test_with_django/__init__.py -------------------------------------------------------------------------------- /tests/test_with_django/schema_fixtures/test-multi-body-file.json: -------------------------------------------------------------------------------- 1 | { 2 | "post": { 3 | "operationId": "multi_param_api_test_multi_body_file", 4 | "summary": "Test Multi Body File", 5 | "parameters": [], 6 | "responses": { 7 | "200": { 8 | "description": "OK", 9 | "content": { 10 | "application/json": { 11 | "schema": { 12 | "$ref": "#/components/schemas/ResponseData" 13 | } 14 | } 15 | } 16 | } 17 | }, 18 | "requestBody": { 19 | "content": { 20 | "multipart/form-data": { 21 | "schema": { 22 | "title": "MultiPartBodyParams", 23 | "type": "object", 24 | "properties": { 25 | "file": { 26 | "title": "File", 27 | "type": "string", 28 | "format": "binary" 29 | }, 30 | "i": { 31 | "title": "I", 32 | "type": "integer" 33 | }, 34 | "s": { 35 | "title": "S", 36 | "default": "a-str", 37 | "type": "string" 38 | }, 39 | "data": { 40 | "title": "Data4 Title", 41 | "description": "Data4 Desc", 42 | "$ref": "#/components/schemas/TestData4" 43 | }, 44 | "nested-data": { 45 | "$ref": "#/components/schemas/TestData" 46 | } 47 | }, 48 | "required": [ 49 | "file", 50 | "i", 51 | "data", 52 | "nested-data" 53 | ] 54 | } 55 | } 56 | }, 57 | "required": true 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /tests/test_with_django/schema_fixtures/test-multi-body-form-file.json: -------------------------------------------------------------------------------- 1 | { 2 | "post": { 3 | "operationId": "multi_param_api_test_multi_body_form_file", 4 | "summary": "Test Multi Body Form File", 5 | "parameters": [], 6 | "responses": { 7 | "200": { 8 | "description": "OK", 9 | "content": { 10 | "application/json": { 11 | "schema": { 12 | "$ref": "#/components/schemas/ResponseData" 13 | } 14 | } 15 | } 16 | } 17 | }, 18 | "requestBody": { 19 | "content": { 20 | "multipart/form-data": { 21 | "schema": { 22 | "title": "MultiPartBodyParams", 23 | "type": "object", 24 | "properties": { 25 | "file": { 26 | "title": "File", 27 | "type": "string", 28 | "format": "binary" 29 | }, 30 | "s": { 31 | "title": "S", 32 | "default": "a-str", 33 | "type": "string" 34 | }, 35 | "foo": { 36 | "title": "Foo", 37 | "type": "integer" 38 | }, 39 | "bar": { 40 | "title": "Bar", 41 | "default": "11bar", 42 | "type": "string" 43 | }, 44 | "foo2": { 45 | "title": "Foo2 Title", 46 | "description": "Foo2 Desc", 47 | "default": 22, 48 | "type": "integer" 49 | }, 50 | "bar2": { 51 | "title": "Bar2", 52 | "type": "string" 53 | }, 54 | "foo3": { 55 | "title": "Foo3 Title", 56 | "description": "Foo3 Desc", 57 | "type": "integer" 58 | }, 59 | "bar3": { 60 | "title": "Bar3", 61 | "default": "33bar", 62 | "type": "string" 63 | }, 64 | "i": { 65 | "title": "I", 66 | "type": "integer" 67 | }, 68 | "data": { 69 | "title": "Data4 Title", 70 | "description": "Data4 Desc", 71 | "$ref": "#/components/schemas/TestData4" 72 | } 73 | }, 74 | "required": [ 75 | "file", 76 | "foo", 77 | "bar2", 78 | "foo3", 79 | "i", 80 | "data" 81 | ] 82 | } 83 | } 84 | }, 85 | "required": true 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /tests/test_with_django/schema_fixtures/test-multi-body-form.json: -------------------------------------------------------------------------------- 1 | { 2 | "post": { 3 | "operationId": "multi_param_api_test_multi_body_form", 4 | "summary": "Test Multi Body Form", 5 | "parameters": [], 6 | "responses": { 7 | "200": { 8 | "description": "OK", 9 | "content": { 10 | "application/json": { 11 | "schema": { 12 | "$ref": "#/components/schemas/ResponseData" 13 | } 14 | } 15 | } 16 | } 17 | }, 18 | "requestBody": { 19 | "content": { 20 | "multipart/form-data": { 21 | "schema": { 22 | "title": "MultiPartBodyParams", 23 | "type": "object", 24 | "properties": { 25 | "s": { 26 | "title": "S", 27 | "default": "a-str", 28 | "type": "string" 29 | }, 30 | "foo": { 31 | "title": "Foo", 32 | "type": "integer" 33 | }, 34 | "bar": { 35 | "title": "Bar", 36 | "default": "11bar", 37 | "type": "string" 38 | }, 39 | "foo2": { 40 | "title": "Foo2 Title", 41 | "description": "Foo2 Desc", 42 | "default": 22, 43 | "type": "integer" 44 | }, 45 | "bar2": { 46 | "title": "Bar2", 47 | "type": "string" 48 | }, 49 | "foo3": { 50 | "title": "Foo3 Title", 51 | "description": "Foo3 Desc", 52 | "type": "integer" 53 | }, 54 | "bar3": { 55 | "title": "Bar3", 56 | "default": "33bar", 57 | "type": "string" 58 | }, 59 | "i": { 60 | "title": "I", 61 | "type": "integer" 62 | }, 63 | "data": { 64 | "title": "Data4 Title", 65 | "description": "Data4 Desc", 66 | "$ref": "#/components/schemas/TestData4" 67 | } 68 | }, 69 | "required": [ 70 | "foo", 71 | "bar2", 72 | "foo3", 73 | "i", 74 | "data" 75 | ] 76 | } 77 | } 78 | }, 79 | "required": true 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /tests/test_with_django/schema_fixtures/test-multi-body.json: -------------------------------------------------------------------------------- 1 | { 2 | "post": { 3 | "operationId": "multi_param_api_test_multi_body", 4 | "summary": "Test Multi Body", 5 | "parameters": [], 6 | "responses": { 7 | "200": { 8 | "description": "OK", 9 | "content": { 10 | "application/json": { 11 | "schema": { 12 | "$ref": "#/components/schemas/ResponseData" 13 | } 14 | } 15 | } 16 | } 17 | }, 18 | "requestBody": { 19 | "content": { 20 | "application/json": { 21 | "schema": { 22 | "title": "BodyParams", 23 | "type": "object", 24 | "properties": { 25 | "i": { 26 | "title": "I", 27 | "type": "integer" 28 | }, 29 | "s": { 30 | "title": "S", 31 | "default": "a-str", 32 | "type": "string" 33 | }, 34 | "data": { 35 | "title": "Data4 Title", 36 | "description": "Data4 Desc", 37 | "$ref": "#/components/schemas/TestData4" 38 | }, 39 | "nested-data": { 40 | "$ref": "#/components/schemas/TestData" 41 | } 42 | }, 43 | "required": [ 44 | "i", 45 | "data", 46 | "nested-data" 47 | ] 48 | } 49 | } 50 | }, 51 | "required": true 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /tests/test_with_django/schema_fixtures/test-multi-form-body-file.json: -------------------------------------------------------------------------------- 1 | { 2 | "post": { 3 | "operationId": "multi_param_api_test_multi_form_body_file", 4 | "summary": "Test Multi Form Body File", 5 | "parameters": [], 6 | "responses": { 7 | "200": { 8 | "description": "OK", 9 | "content": { 10 | "application/json": { 11 | "schema": { 12 | "$ref": "#/components/schemas/ResponseData" 13 | } 14 | } 15 | } 16 | } 17 | }, 18 | "requestBody": { 19 | "content": { 20 | "multipart/form-data": { 21 | "schema": { 22 | "title": "MultiPartBodyParams", 23 | "type": "object", 24 | "properties": { 25 | "file": { 26 | "title": "File", 27 | "type": "string", 28 | "format": "binary" 29 | }, 30 | "i": { 31 | "title": "I", 32 | "type": "integer" 33 | }, 34 | "foo4": { 35 | "title": "Foo4 Title", 36 | "description": "Foo4 Desc", 37 | "default": 44, 38 | "type": "integer" 39 | }, 40 | "bar4": { 41 | "title": "Bar4", 42 | "default": "44bar", 43 | "type": "string" 44 | }, 45 | "s": { 46 | "title": "S", 47 | "default": "a-str", 48 | "type": "string" 49 | }, 50 | "nested-data": { 51 | "$ref": "#/components/schemas/TestData" 52 | } 53 | }, 54 | "required": [ 55 | "file", 56 | "i", 57 | "nested-data" 58 | ] 59 | } 60 | } 61 | }, 62 | "required": true 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /tests/test_with_django/schema_fixtures/test-multi-form-body.json: -------------------------------------------------------------------------------- 1 | { 2 | "post": { 3 | "operationId": "multi_param_api_test_multi_form_body", 4 | "summary": "Test Multi Form Body", 5 | "parameters": [], 6 | "responses": { 7 | "200": { 8 | "description": "OK", 9 | "content": { 10 | "application/json": { 11 | "schema": { 12 | "$ref": "#/components/schemas/ResponseData" 13 | } 14 | } 15 | } 16 | } 17 | }, 18 | "requestBody": { 19 | "content": { 20 | "multipart/form-data": { 21 | "schema": { 22 | "title": "MultiPartBodyParams", 23 | "type": "object", 24 | "properties": { 25 | "i": { 26 | "title": "I", 27 | "type": "integer" 28 | }, 29 | "foo4": { 30 | "title": "Foo4 Title", 31 | "description": "Foo4 Desc", 32 | "default": 44, 33 | "type": "integer" 34 | }, 35 | "bar4": { 36 | "title": "Bar4", 37 | "default": "44bar", 38 | "type": "string" 39 | }, 40 | "s": { 41 | "title": "S", 42 | "default": "a-str", 43 | "type": "string" 44 | }, 45 | "nested-data": { 46 | "$ref": "#/components/schemas/TestData" 47 | } 48 | }, 49 | "required": [ 50 | "i", 51 | "nested-data" 52 | ] 53 | } 54 | } 55 | }, 56 | "required": true 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /tests/test_with_django/schema_fixtures/test-multi-form-file.json: -------------------------------------------------------------------------------- 1 | { 2 | "post": { 3 | "operationId": "multi_param_api_test_multi_form_file", 4 | "summary": "Test Multi Form File", 5 | "parameters": [], 6 | "responses": { 7 | "200": { 8 | "description": "OK", 9 | "content": { 10 | "application/json": { 11 | "schema": { 12 | "$ref": "#/components/schemas/ResponseData" 13 | } 14 | } 15 | } 16 | } 17 | }, 18 | "requestBody": { 19 | "content": { 20 | "multipart/form-data": { 21 | "schema": { 22 | "title": "MultiPartBodyParams", 23 | "type": "object", 24 | "properties": { 25 | "file": { 26 | "title": "File", 27 | "type": "string", 28 | "format": "binary" 29 | }, 30 | "i": { 31 | "title": "I", 32 | "type": "integer" 33 | }, 34 | "s": { 35 | "title": "S", 36 | "default": "a-str", 37 | "type": "string" 38 | }, 39 | "foo4": { 40 | "title": "Foo4 Title", 41 | "description": "Foo4 Desc", 42 | "default": 44, 43 | "type": "integer" 44 | }, 45 | "bar4": { 46 | "title": "Bar4", 47 | "default": "44bar", 48 | "type": "string" 49 | }, 50 | "foo": { 51 | "title": "Foo", 52 | "type": "integer" 53 | }, 54 | "bar": { 55 | "title": "Bar", 56 | "default": "11bar", 57 | "type": "string" 58 | }, 59 | "foo2": { 60 | "title": "Foo2 Title", 61 | "description": "Foo2 Desc", 62 | "default": 22, 63 | "type": "integer" 64 | }, 65 | "bar2": { 66 | "title": "Bar2", 67 | "type": "string" 68 | }, 69 | "foo3": { 70 | "title": "Foo3 Title", 71 | "description": "Foo3 Desc", 72 | "type": "integer" 73 | }, 74 | "bar3": { 75 | "title": "Bar3", 76 | "default": "33bar", 77 | "type": "string" 78 | } 79 | }, 80 | "required": [ 81 | "file", 82 | "i", 83 | "foo", 84 | "bar2", 85 | "foo3" 86 | ] 87 | } 88 | } 89 | }, 90 | "required": true 91 | } 92 | } 93 | } -------------------------------------------------------------------------------- /tests/test_with_django/schema_fixtures/test-multi-form.json: -------------------------------------------------------------------------------- 1 | { 2 | "post": { 3 | "operationId": "multi_param_api_test_multi_form", 4 | "summary": "Test Multi Form", 5 | "parameters": [], 6 | "responses": { 7 | "200": { 8 | "description": "OK", 9 | "content": { 10 | "application/json": { 11 | "schema": { 12 | "$ref": "#/components/schemas/ResponseData" 13 | } 14 | } 15 | } 16 | } 17 | }, 18 | "requestBody": { 19 | "content": { 20 | "application/x-www-form-urlencoded": { 21 | "schema": { 22 | "title": "FormParams", 23 | "type": "object", 24 | "properties": { 25 | "i": { 26 | "title": "I", 27 | "type": "integer" 28 | }, 29 | "s": { 30 | "title": "S", 31 | "default": "a-str", 32 | "type": "string" 33 | }, 34 | "foo4": { 35 | "title": "Foo4 Title", 36 | "description": "Foo4 Desc", 37 | "default": 44, 38 | "type": "integer" 39 | }, 40 | "bar4": { 41 | "title": "Bar4", 42 | "default": "44bar", 43 | "type": "string" 44 | }, 45 | "foo": { 46 | "title": "Foo", 47 | "type": "integer" 48 | }, 49 | "bar": { 50 | "title": "Bar", 51 | "default": "11bar", 52 | "type": "string" 53 | }, 54 | "foo2": { 55 | "title": "Foo2 Title", 56 | "description": "Foo2 Desc", 57 | "default": 22, 58 | "type": "integer" 59 | }, 60 | "bar2": { 61 | "title": "Bar2", 62 | "type": "string" 63 | }, 64 | "foo3": { 65 | "title": "Foo3 Title", 66 | "description": "Foo3 Desc", 67 | "type": "integer" 68 | }, 69 | "bar3": { 70 | "title": "Bar3", 71 | "default": "33bar", 72 | "type": "string" 73 | } 74 | }, 75 | "required": [ 76 | "i", 77 | "foo", 78 | "bar2", 79 | "foo3" 80 | ] 81 | } 82 | } 83 | }, 84 | "required": true 85 | } 86 | } 87 | } -------------------------------------------------------------------------------- /tests/test_with_django/schema_fixtures/test-multi-path.json: -------------------------------------------------------------------------------- 1 | { 2 | "post": { 3 | "operationId": "multi_param_api_test_multi_path", 4 | "summary": "Test Multi Path", 5 | "parameters": [ 6 | { 7 | "in": "path", 8 | "name": "i", 9 | "schema": { 10 | "title": "I", 11 | "type": "integer" 12 | }, 13 | "required": true 14 | }, 15 | { 16 | "in": "path", 17 | "name": "s", 18 | "schema": { 19 | "title": "S", 20 | "default": "a-str", 21 | "type": "string" 22 | }, 23 | "required": false 24 | }, 25 | { 26 | "in": "path", 27 | "name": "foo4", 28 | "description": "Foo4 Desc", 29 | "schema": { 30 | "title": "Foo4 Title", 31 | "description": "Foo4 Desc", 32 | "default": 44, 33 | "type": "integer" 34 | }, 35 | "required": false 36 | }, 37 | { 38 | "in": "path", 39 | "name": "bar4", 40 | "schema": { 41 | "title": "Bar4", 42 | "default": "44bar", 43 | "type": "string" 44 | }, 45 | "required": false 46 | }, 47 | { 48 | "in": "path", 49 | "name": "foo", 50 | "schema": { 51 | "title": "Foo", 52 | "type": "integer" 53 | }, 54 | "required": true 55 | }, 56 | { 57 | "in": "path", 58 | "name": "bar", 59 | "schema": { 60 | "title": "Bar", 61 | "default": "11bar", 62 | "type": "string" 63 | }, 64 | "required": false 65 | }, 66 | { 67 | "in": "path", 68 | "name": "foo2", 69 | "description": "Foo2 Desc", 70 | "schema": { 71 | "title": "Foo2 Title", 72 | "description": "Foo2 Desc", 73 | "default": 22, 74 | "type": "integer" 75 | }, 76 | "required": false 77 | }, 78 | { 79 | "in": "path", 80 | "name": "bar2", 81 | "schema": { 82 | "title": "Bar2", 83 | "type": "string" 84 | }, 85 | "required": true 86 | }, 87 | { 88 | "in": "path", 89 | "name": "foo3", 90 | "description": "Foo3 Desc", 91 | "schema": { 92 | "title": "Foo3 Title", 93 | "description": "Foo3 Desc", 94 | "type": "integer" 95 | }, 96 | "required": true 97 | }, 98 | { 99 | "in": "path", 100 | "name": "bar3", 101 | "schema": { 102 | "title": "Bar3", 103 | "default": "33bar", 104 | "type": "string" 105 | }, 106 | "required": false 107 | } 108 | ], 109 | "responses": { 110 | "200": { 111 | "description": "OK", 112 | "content": { 113 | "application/json": { 114 | "schema": { 115 | "$ref": "#/components/schemas/ResponseData" 116 | } 117 | } 118 | } 119 | } 120 | } 121 | } 122 | } -------------------------------------------------------------------------------- /tests/test_wraps.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from unittest import mock 3 | 4 | import pytest 5 | 6 | from ninja import Router 7 | from ninja.testing import TestClient 8 | 9 | router = Router() 10 | client = TestClient(router) 11 | 12 | 13 | def a_good_test_wrapper(f): 14 | """Validate that decorators using functools.wraps(), work as expected""" 15 | 16 | @wraps(f) 17 | def wrapper(*args, **kwargs): 18 | return f(*args, **kwargs) 19 | 20 | return wrapper 21 | 22 | 23 | def a_bad_test_wrapper(f): 24 | """Validate that decorators failing to using functools.wraps(), fail""" 25 | 26 | def wrapper(*args, **kwargs): 27 | return f(*args, **kwargs) 28 | 29 | return wrapper 30 | 31 | 32 | @router.get("/text") 33 | @a_good_test_wrapper 34 | def get_text( 35 | request, 36 | ): 37 | return "Hello World" 38 | 39 | 40 | @router.get("/path/{item_id}") 41 | @a_good_test_wrapper 42 | def get_id(request, item_id): 43 | return item_id 44 | 45 | 46 | @router.get("/query") 47 | @a_good_test_wrapper 48 | def get_query_type(request, query: int): 49 | return f"foo bar {query}" 50 | 51 | 52 | @router.get("/path-query/{item_id}") 53 | @a_good_test_wrapper 54 | def get_query_id(request, item_id, query: int): 55 | return f"foo bar {item_id} {query}" 56 | 57 | 58 | @router.get("/text-bad") 59 | @a_bad_test_wrapper 60 | def get_text_bad(request): 61 | return "Hello World" 62 | 63 | 64 | with mock.patch("ninja.signature.details.warnings.warn_explicit"): 65 | 66 | @router.get("/path-bad/{item_id}") 67 | @a_bad_test_wrapper 68 | def get_id_bad(request, item_id): 69 | return item_id 70 | 71 | 72 | @router.get("/query-bad") 73 | @a_bad_test_wrapper 74 | def get_query_type_bad(request, query: int): 75 | return f"foo bar {query}" 76 | 77 | 78 | with mock.patch("ninja.signature.details.warnings.warn_explicit"): 79 | 80 | @router.get("/path-query-bad/{item_id}") 81 | @a_bad_test_wrapper 82 | def get_query_id_bad(request, item_id, query: int): 83 | return f"foo bar {item_id} {query}" 84 | 85 | 86 | @pytest.mark.parametrize( 87 | "path,expected_status,expected_response", 88 | [ 89 | ("/text", 200, "Hello World"), 90 | ("/path/id", 200, "id"), 91 | ("/query?query=1", 200, "foo bar 1"), 92 | ("/path-query/id?query=2", 200, "foo bar id 2"), 93 | ("/text-bad", 200, "Hello World"), # no params so passes 94 | ("/path-bad/id", None, TypeError), 95 | ("/query-bad?query=1", None, TypeError), 96 | ("/path-query-bad/id?query=2", None, TypeError), 97 | ], 98 | ) 99 | def test_get_path(path, expected_status, expected_response): 100 | if isinstance(expected_response, str): 101 | response = client.get(path) 102 | assert response.status_code == expected_status 103 | assert response.json() == expected_response 104 | else: 105 | match = r"Did you fail to use functools.wraps\(\) in a decorator\?" 106 | with pytest.raises(expected_response, match=match): 107 | client.get(path) 108 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | 4 | def pydantic_ref_fix(data: dict): 5 | "In pydantic 1.7 $ref was changed to allOf: [{'$ref': ...}] but in 2.9 it was changed back" 6 | v = tuple(map(int, pydantic.version.version_short().split("."))) 7 | if v < (1, 7) or v >= (2, 9): 8 | return data 9 | 10 | result = data.copy() 11 | if "$ref" in data: 12 | result["allOf"] = [{"$ref": result.pop("$ref")}] 13 | return result 14 | 15 | 16 | def pydantic_arbitrary_dict_fix(data: dict): 17 | """ 18 | In Pydantic 2.11, arbitrary dictionaries now contain "additionalProperties": True in the schema 19 | https://github.com/pydantic/pydantic/pull/11392 20 | 21 | :param data: A pre-Pydantic 2.11 arbitrary dictionary schema 22 | """ 23 | v = tuple(map(int, pydantic.version.version_short().split("."))) 24 | if v < (2, 11): 25 | return data 26 | 27 | data["additionalProperties"] = True 28 | return data 29 | --------------------------------------------------------------------------------