├── .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 
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 |
--------------------------------------------------------------------------------