diff --git a/README.md b/README.md
index 312e75bc..acc5f56a 100644
--- a/README.md
+++ b/README.md
@@ -8,106 +8,191 @@
# Django Ninja Extra
-**Django Ninja Extra** package offers a **class-based** approach plus extra functionalities that will speed up your RESTful API development with [**Django Ninja**](https://django-ninja.rest-framework.com)
+## Overview
-**Key features:**
+Django Ninja Extra is a powerful extension for [Django Ninja](https://django-ninja.rest-framework.com) that enhances your Django REST API development experience. It introduces class-based views and advanced features while maintaining the high performance and simplicity of Django Ninja. Whether you're building a small API or a large-scale application, Django Ninja Extra provides the tools you need for clean, maintainable, and efficient API development.
-All **Django-Ninja** features :
-- **Easy**: Designed to be easy to use and intuitive.
-- **FAST execution**: Very high performance thanks to **Pydantic** and **async support**.
-- **Fast to code**: Type hints and automatic docs lets you focus only on business logic.
-- **Standards-based**: Based on the open standards for APIs: **OpenAPI** (previously known as Swagger) and **JSON Schema**.
-- **Django friendly**: (obviously) has good integration with the Django core and ORM.
+## Features
-Plus **Extra**:
-- **Class Based**: Design your APIs in a class based fashion.
-- **Permissions**: Protect endpoint(s) at ease with defined permissions and authorizations at route level or controller level.
-- **Dependency Injection**: Controller classes supports dependency injection with python [**Injector** ](https://injector.readthedocs.io/en/latest/) or [**django_injector**](https://github.com/blubber/django_injector). Giving you the ability to inject API dependable services to APIController class and utilizing them where needed
+### Core Features (Inherited from Django Ninja)
+- ⚡ **High Performance**: Built on Pydantic for lightning-fast validation
+- 🔄 **Async Support**: First-class support for async/await operations
+- 📝 **Type Safety**: Comprehensive type hints for better development experience
+- 🎯 **Django Integration**: Seamless integration with Django's ecosystem
+- 📚 **OpenAPI Support**: Automatic API documentation with Swagger/ReDoc
----
+### Extra Features
+- 🏗️ **Class-Based Controllers**:
+ - Organize related endpoints in controller classes
+ - Inherit common functionality
+ - Share dependencies across endpoints
+
+- 🔒 **Advanced Permission System (Similar to Django Rest Framework)**:
+ - Controller-level permissions
+ - Route-level permission overrides
+ - Custom permission classes
+
+- 💉 **Dependency Injection**:
+ - Built-in support for [Injector](https://injector.readthedocs.io/en/latest/)
+ - Compatible with [django_injector](https://github.com/blubber/django_injector)
+ - Automatic dependency resolution
+
+- 🔧 **Service Layer**:
+ - Injectable services for business logic
+ - Better separation of concerns
+ - Reusable components
+
+## Requirements
-### Requirements
- Python >= 3.6
-- django >= 2.1
-- pydantic >= 1.6
+- Django >= 2.1
+- Pydantic >= 1.6
- Django-Ninja >= 0.16.1
-Full documentation, [visit](https://eadwincode.github.io/django-ninja-extra/).
-
## Installation
-```
+1. Install the package:
+```bash
pip install django-ninja-extra
```
-After installation, add `ninja_extra` to your `INSTALLED_APPS`
-```Python
+2. Add to INSTALLED_APPS:
+```python
INSTALLED_APPS = [
...,
'ninja_extra',
]
```
-## Usage
+## Quick Start Guide
+
+### 1. Basic API Setup
-In your django project next to urls.py create new `api.py` file:
+Create `api.py` in your Django project:
-```Python
+```python
from ninja_extra import NinjaExtraAPI, api_controller, http_get
api = NinjaExtraAPI()
-# function based definition
-@api.get("/add", tags=['Math'])
-def add(request, a: int, b: int):
- return {"result": a + b}
-
-#class based definition
-@api_controller
-class MathAPI:
-
- @http_get('/subtract',)
- def subtract(self, a: int, b: int):
- """Subtracts a from b"""
- return {"result": a - b}
-
- @http_get('/divide',)
- def divide(self, a: int, b: int):
- """Divides a by b"""
- return {"result": a / b}
-
- @http_get('/multiple',)
- def multiple(self, a: int, b: int):
- """Multiples a with b"""
+# Function-based endpoint example
+@api.get("/hello", tags=['Basic'])
+def hello(request, name: str = "World"):
+ return {"message": f"Hello, {name}!"}
+
+# Class-based controller example
+@api_controller('/math', tags=['Math'])
+class MathController:
+ @http_get('/add')
+ def add(self, a: int, b: int):
+ """Add two numbers"""
+ return {"result": a + b}
+
+ @http_get('/multiply')
+ def multiply(self, a: int, b: int):
+ """Multiply two numbers"""
return {"result": a * b}
-
-api.register_controllers(
- MathAPI
-)
+
+# Register your controllers
+api.register_controllers(MathController)
```
-Now go to `urls.py` and add the following:
+### 2. URL Configuration
-```Python
-...
+In `urls.py`:
+```python
from django.urls import path
from .api import api
urlpatterns = [
- path("admin/", admin.site.urls),
- path("api/", api.urls), # <---------- !
+ path("api/", api.urls), # This will mount your API at /api/
]
+```
+
+## Advanced Features
+
+### Authentication and Permissions
+
+```python
+from ninja_extra import api_controller, http_get
+from ninja_extra.permissions import IsAuthenticated, PermissionBase
+
+# Custom permission
+class IsAdmin(PermissionBase):
+ def has_permission(self, context):
+ return context.request.user.is_staff
+@api_controller('/admin', tags=['Admin'], permissions=[IsAuthenticated, IsAdmin])
+class AdminController:
+ @http_get('/stats')
+ def get_stats(self):
+ return {"status": "admin only data"}
+
+ @http_get('/public', permissions=[]) # Override to make public
+ def public_stats(self):
+ return {"status": "public data"}
```
-### Interactive API docs
+### Dependency Injection with Services
+
+```python
+from injector import inject
+from ninja_extra import api_controller, http_get
+
-Now go to http://127.0.0.1:8000/api/docs
+# Service class
+class UserService:
+ def get_user_details(self, user_id: int):
+ return {"user_id": user_id, "status": "active"}
-You will see the automatic interactive API documentation (provided by Swagger UI):
+
+# Controller with dependency injection
+@api_controller('/users', tags=['Users'])
+class UserController:
+ def __init__(self, user_service: UserService):
+ self.user_service = user_service
+
+ @http_get('/{user_id}')
+ def get_user(self, user_id: int):
+ return self.user_service.get_user_details(user_id)
+```
+
+## API Documentation
+
+Access your API's interactive documentation at `/api/docs`:
![Swagger UI](docs/images/ui_swagger_preview_readme.gif)
-## Tutorials
-- [django-ninja - Permissions, Controllers & Throttling with django-ninja-extra!](https://www.youtube.com/watch?v=yQqig-c2dd4) - Learn how to use permissions, controllers and throttling with django-ninja-extra
-- [BookStore API](https://github.com/eadwinCode/bookstoreapi) - A sample project that demonstrates how to use django-ninja-extra with ninja schema and ninja-jwt
+## Learning Resources
+
+### Tutorials
+- 📺 [Video: Permissions & Controllers](https://www.youtube.com/watch?v=yQqig-c2dd4)
+- 💻 [Example: BookStore API](https://github.com/eadwinCode/bookstoreapi)
+- 📚 [Official Documentation](https://eadwincode.github.io/django-ninja-extra/)
+
+### Community and Support
+- 🌟 [GitHub Repository](https://github.com/eadwinCode/django-ninja-extra)
+- 🐛 [Issue Tracker](https://github.com/eadwinCode/django-ninja-extra/issues)
+- 💬 [Discussions](https://github.com/eadwinCode/django-ninja-extra/discussions)
+
+## Contributing
+
+We welcome contributions! Here's how you can help:
+
+1. Fork the repository
+2. Create a feature branch
+3. Write your changes
+4. Submit a pull request
+
+Please ensure your code follows our coding standards and includes appropriate tests.
+
+## License
+
+This project is licensed under the MIT License - see the LICENSE file for details.
+
+## Support the Project
+
+- ⭐ Star the repository
+- 🐛 Report issues
+- 📖 Contribute to documentation
+- 🤝 Submit pull requests
diff --git a/docs/index.md b/docs/index.md
index 21757e72..49f61631 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -4,112 +4,195 @@
[![PyPI version](https://img.shields.io/pypi/pyversions/django-ninja-extra.svg)](https://pypi.python.org/pypi/django-ninja-extra)
[![PyPI version](https://img.shields.io/pypi/djversions/django-ninja-extra.svg)](https://pypi.python.org/pypi/django-ninja-extra)
[![Codecov](https://img.shields.io/codecov/c/gh/eadwinCode/django-ninja-extra)](https://codecov.io/gh/eadwinCode/django-ninja-extra)
-[![Downloads](https://pepy.tech/badge/django-ninja-extra)](https://pepy.tech/project/django-ninja-extra)
+[![Downloads](https://static.pepy.tech/badge/django-ninja-extra)](https://pepy.tech/project/django-ninja-extra)
# Django Ninja Extra
-**Django Ninja Extra** package offers a **class-based** approach plus extra functionalities that will speed up your RESTful API development with [**Django Ninja**](https://django-ninja.rest-framework.com)
+## Overview
-**Key features:**
+Django Ninja Extra is a powerful extension for [Django Ninja](https://django-ninja.rest-framework.com) that enhances your Django REST API development experience. It introduces class-based views and advanced features while maintaining the high performance and simplicity of Django Ninja. Whether you're building a small API or a large-scale application, Django Ninja Extra provides the tools you need for clean, maintainable, and efficient API development.
-All **Django-Ninja** features :
+## Features
-- **Easy**: Designed to be easy to use and intuitive.
-- **FAST execution**: Very high performance thanks to **Pydantic** and **async support**.
-- **Fast to code**: Type hints and automatic docs lets you focus only on business logic.
-- **Standards-based**: Based on the open standards for APIs: **OpenAPI** (previously known as Swagger) and **JSON Schema**.
-- **Django friendly**: (obviously) has good integration with the Django core and ORM.
+### Core Features (Inherited from Django Ninja)
+- ⚡ **High Performance**: Built on Pydantic for lightning-fast validation
+- 🔄 **Async Support**: First-class support for async/await operations
+- 📝 **Type Safety**: Comprehensive type hints for better development experience
+- 🎯 **Django Integration**: Seamless integration with Django's ecosystem
+- 📚 **OpenAPI Support**: Automatic API documentation with Swagger/ReDoc
-Plus **Extra**:
+### Extra Features
+- 🏗️ **Class-Based Controllers**:
+ - Organize related endpoints in controller classes
+ - Inherit common functionality
+ - Share dependencies across endpoints
-- **Class Based**: Design your APIs in a class based fashion.
-- **Permissions**: Protect endpoint(s) at ease with defined permissions and authorizations at route level or controller level.
-- **Dependency Injection**: Controller classes supports dependency injection with python [**Injector** ](https://injector.readthedocs.io/en/latest/) or [**django_injector**](https://github.com/blubber/django_injector). Giving you the ability to inject API dependable services to APIController class and utilizing them where needed
+- 🔒 **Advanced Permission System (Similar to Django Rest Framework)**:
+ - Controller-level permissions
+ - Route-level permission overrides
+ - Custom permission classes
----
+- 💉 **Dependency Injection**:
+ - Built-in support for [Injector](https://injector.readthedocs.io/en/latest/)
+ - Compatible with [django_injector](https://github.com/blubber/django_injector)
+ - Automatic dependency resolution
-### Requirements
+- 🔧 **Service Layer**:
+ - Injectable services for business logic
+ - Better separation of concerns
+ - Reusable components
+
+## Requirements
- Python >= 3.6
-- django >= 2.1
-- pydantic >= 1.6
+- Django >= 2.1
+- Pydantic >= 1.6
- Django-Ninja >= 0.16.1
-
-Full documentation, [visit](https://eadwincode.github.io/django-ninja-extra/).
-
## Installation
-```
+1. Install the package:
+```bash
pip install django-ninja-extra
```
-After installation, add `ninja_extra` to your `INSTALLED_APPS`
-```Python
+2. Add to INSTALLED_APPS:
+```python
INSTALLED_APPS = [
...,
'ninja_extra',
]
```
-## Usage
+## Quick Start Guide
-In your django project next to urls.py create new `api.py` file:
+### 1. Basic API Setup
-```Python
+Create `api.py` in your Django project:
+
+```python
from ninja_extra import NinjaExtraAPI, api_controller, http_get
api = NinjaExtraAPI()
-# function based definition
-@api.get("/add", tags=['Math'])
-def add(request, a: int, b: int):
- return {"result": a + b}
+# Function-based endpoint example
+@api.get("/hello", tags=['Basic'])
+def hello(request, name: str = "World"):
+ return {"message": f"Hello, {name}!"}
-#class based definition
-@api_controller('/', tags=['Math'], permissions=[])
-class MathAPI:
- @http_get('/subtract',)
- def subtract(self, a: int, b: int):
- """Subtracts a from b"""
- return {"result": a - b}
+# Class-based controller example
+@api_controller('/math', tags=['Math'])
+class MathController:
+ @http_get('/add')
+ def add(self, a: int, b: int):
+ """Add two numbers"""
+ return {"result": a + b}
- @http_get('/divide',)
- def divide(self, a: int, b: int):
- """Divides a by b"""
- return {"result": a / b}
-
- @http_get('/multiple',)
- def multiple(self, a: int, b: int):
- """Multiples a with b"""
+ @http_get('/multiply')
+ def multiply(self, a: int, b: int):
+ """Multiply two numbers"""
return {"result": a * b}
-
-api.register_controllers(
- MathAPI
-)
+
+# Register your controllers
+api.register_controllers(MathController)
```
-Now go to `urls.py` and add the following:
+### 2. URL Configuration
-```Python
-...
+In `urls.py`:
+```python
from django.urls import path
from .api import api
urlpatterns = [
- path("admin/", admin.site.urls),
- path("api/", api.urls), # <---------- !
+ path("api/", api.urls), # This will mount your API at /api/
]
```
-### Interactive API docs
+## Advanced Features
+
+### Authentication and Permissions
+
+```python
+from ninja_extra import api_controller, http_get
+from ninja_extra.permissions import IsAuthenticated, PermissionBase
+
+# Custom permission
+class IsAdmin(PermissionBase):
+ def has_permission(self, context):
+ return context.request.user.is_staff
+
+@api_controller('/admin', tags=['Admin'], permissions=[IsAuthenticated, IsAdmin])
+class AdminController:
+ @http_get('/stats')
+ def get_stats(self):
+ return {"status": "admin only data"}
+
+ @http_get('/public', permissions=[]) # Override to make public
+ def public_stats(self):
+ return {"status": "public data"}
+```
+
+### Dependency Injection with Services
+
+```python
+from injector import inject
+from ninja_extra import api_controller, http_get
+
+
+# Service class
+class UserService:
+ def get_user_details(self, user_id: int):
+ return {"user_id": user_id, "status": "active"}
+
+
+# Controller with dependency injection
+@api_controller('/users', tags=['Users'])
+class UserController:
+ def __init__(self, user_service: UserService):
+ self.user_service = user_service
+
+ @http_get('/{user_id}')
+ def get_user(self, user_id: int):
+ return self.user_service.get_user_details(user_id)
+```
+
+## API Documentation
+
+Access your API's interactive documentation at `/api/docs`:
+
+![Swagger UI](docs/images/ui_swagger_preview_readme.gif)
+
+## Learning Resources
+
+### Tutorials
+- 📺 [Video: Permissions & Controllers](https://www.youtube.com/watch?v=yQqig-c2dd4)
+- 💻 [Example: BookStore API](https://github.com/eadwinCode/bookstoreapi)
+- 📚 [Official Documentation](https://eadwincode.github.io/django-ninja-extra/)
+
+### Community and Support
+- 🌟 [GitHub Repository](https://github.com/eadwinCode/django-ninja-extra)
+- 🐛 [Issue Tracker](https://github.com/eadwinCode/django-ninja-extra/issues)
+- 💬 [Discussions](https://github.com/eadwinCode/django-ninja-extra/discussions)
+
+## Contributing
+
+We welcome contributions! Here's how you can help:
+
+1. Fork the repository
+2. Create a feature branch
+3. Write your changes
+4. Submit a pull request
+
+Please ensure your code follows our coding standards and includes appropriate tests.
-Now go to http://127.0.0.1:8000/api/docs
+## License
-You will see the automatic interactive API documentation (provided by Swagger UI):
+This project is licensed under the MIT License - see the LICENSE file for details.
-![Swagger UI](images/ui_swagger_preview_readme.gif)
+## Support the Project
-## Tutorials
-- [django-ninja - Permissions, Controllers & Throttling with django-ninja-extra!](https://www.youtube.com/watch?v=yQqig-c2dd4) - Learn how to use permissions, controllers and throttling with django-ninja-extra
-- [BookStore API](https://github.com/eadwinCode/bookstoreapi) - A sample project that demonstrates how to use django-ninja-extra with ninja schema and ninja-jwt
+- ⭐ Star the repository
+- 🐛 Report issues
+- 📖 Contribute to documentation
+- 🤝 Submit pull requests
diff --git a/tests/main.py b/tests/main.py
new file mode 100644
index 00000000..eff237d0
--- /dev/null
+++ b/tests/main.py
@@ -0,0 +1,252 @@
+from typing import List, Optional
+from uuid import UUID
+
+from django.urls import register_converter
+from ninja import Field, Path, Query, Schema
+
+from ninja_extra import api_controller, route
+
+
+class CustomPathConverter1:
+ regex = "[0-9]+"
+
+ def to_python(self, value) -> "int":
+ """reverse the string and convert to int"""
+ return int(value[::-1])
+
+ def to_url(self, value):
+ return str(value)
+
+
+class CustomPathConverter2:
+ regex = "[0-9]+"
+
+ def to_python(self, value):
+ """reverse the string and convert to float like"""
+ return f"0.{value[::-1]}"
+
+ def to_url(self, value):
+ return str(value)
+
+
+register_converter(CustomPathConverter1, "custom-int")
+register_converter(CustomPathConverter2, "custom-float")
+
+
+@api_controller("/path")
+class PathParamController:
+ @route.get("/param-django-custom-int/{custom-int:item_id}")
+ def get_path_param_django_custom_int(self, request, item_id: int):
+ return item_id
+
+ @route.get("/param-django-custom-float/{custom-float:item_id}")
+ def get_path_param_django_custom_float(self, request, item_id: float):
+ return item_id
+
+ @route.get("/text")
+ def get_text(self, request):
+ return "Hello World"
+
+ @route.get("/{item_id}")
+ def get_id(self, request, item_id):
+ return item_id
+
+ @route.get("/str/{item_id}")
+ def get_str_id(self, request, item_id: str):
+ return item_id
+
+ @route.get("/int/{item_id}")
+ def get_int_id(self, request, item_id: int):
+ return item_id
+
+ @route.get("/float/{item_id}")
+ def get_float_id(self, request, item_id: float):
+ return item_id
+
+ @route.get("/bool/{item_id}")
+ def get_bool_id(self, request, item_id: bool):
+ return item_id
+
+ @route.get("/param/{item_id}")
+ def get_path_param_id(self, request, item_id: str = Path(None)):
+ return item_id
+
+ @route.get("/param-required/{item_id}")
+ def get_path_param_required_id(self, request, item_id: str = Path(...)):
+ return item_id
+
+ @route.get("/param-minlength/{item_id}")
+ def get_path_param_min_length(
+ self, request, item_id: str = Path(..., min_length=3)
+ ):
+ return item_id
+
+ @route.get("/param-maxlength/{item_id}")
+ def get_path_param_max_length(
+ self, request, item_id: str = Path(..., max_length=3)
+ ):
+ return item_id
+
+ @route.get("/param-min_maxlength/{item_id}")
+ def get_path_param_min_max_length(
+ self, request, item_id: str = Path(..., max_length=3, min_length=2)
+ ):
+ return item_id
+
+ @route.get("/param-gt/{item_id}")
+ def get_path_param_gt(self, request, item_id: float = Path(..., gt=3)):
+ return item_id
+
+ @route.get("/param-gt0/{item_id}")
+ def get_path_param_gt0(self, request, item_id: float = Path(..., gt=0)):
+ return item_id
+
+ @route.get("/param-ge/{item_id}")
+ def get_path_param_ge(self, request, item_id: float = Path(..., ge=3)):
+ return item_id
+
+ @route.get("/param-lt/{item_id}")
+ def get_path_param_lt(self, request, item_id: float = Path(..., lt=3)):
+ return item_id
+
+ @route.get("/param-lt0/{item_id}")
+ def get_path_param_lt0(self, request, item_id: float = Path(..., lt=0)):
+ return item_id
+
+ @route.get("/param-le/{item_id}")
+ def get_path_param_le(self, request, item_id: float = Path(..., le=3)):
+ return item_id
+
+ @route.get("/param-lt-gt/{item_id}")
+ def get_path_param_lt_gt(self, request, item_id: float = Path(..., lt=3, gt=1)):
+ return item_id
+
+ @route.get("/param-le-ge/{item_id}")
+ def get_path_param_le_ge(self, request, item_id: float = Path(..., le=3, ge=1)):
+ return item_id
+
+ @route.get("/param-lt-int/{item_id}")
+ def get_path_param_lt_int(self, request, item_id: int = Path(..., lt=3)):
+ return item_id
+
+ @route.get("/param-gt-int/{item_id}")
+ def get_path_param_gt_int(self, request, item_id: int = Path(..., gt=3)):
+ return item_id
+
+ @route.get("/param-le-int/{item_id}")
+ def get_path_param_le_int(self, request, item_id: int = Path(..., le=3)):
+ return item_id
+
+ @route.get("/param-ge-int/{item_id}")
+ def get_path_param_ge_int(self, request, item_id: int = Path(..., ge=3)):
+ return item_id
+
+ @route.get("/param-lt-gt-int/{item_id}")
+ def get_path_param_lt_gt_int(self, request, item_id: int = Path(..., lt=3, gt=1)):
+ return item_id
+
+ @route.get("/param-le-ge-int/{item_id}")
+ def get_path_param_le_ge_int(self, request, item_id: int = Path(..., le=3, ge=1)):
+ return item_id
+
+ @route.get("/param-pattern/{item_id}")
+ def get_path_param_pattern(self, request, item_id: str = Path(..., pattern="^foo")):
+ return item_id
+
+ @route.get("/param-django-str/{str:item_id}")
+ def get_path_param_django_str(self, request, item_id):
+ return item_id
+
+ @route.get("/param-django-int/{int:item_id}")
+ def get_path_param_django_int(self, request, item_id: int):
+ assert isinstance(item_id, int)
+ return item_id
+
+ @route.get("/param-django-int/not-an-int")
+ def get_path_param_django_not_an_int(self, request):
+ """Verify that url resolution for get_path_param_django_int passes non-ints forward"""
+ return "Found not-an-int"
+
+ @route.get("/param-django-int-str/{int:item_id}")
+ def get_path_param_django_int_str(self, request, item_id: str):
+ assert isinstance(item_id, str)
+ return item_id
+
+ @route.get("/param-django-slug/{slug:item_id}")
+ def get_path_param_django_slug(self, request, item_id):
+ return item_id
+
+ @route.get("/param-django-uuid/{uuid:item_id}")
+ def get_path_param_django_uuid(self, request, item_id: UUID):
+ assert isinstance(item_id, UUID)
+ return item_id
+
+ @route.get("/param-django-uuid-str/{uuid:item_id}")
+ def get_path_param_django_uuid_str(self, request, item_id):
+ assert isinstance(item_id, str)
+ return item_id
+
+ @route.get("/param-django-path/{path:item_id}/after")
+ def get_path_param_django_path(self, request, item_id):
+ return item_id
+
+
+class AliasedSchema(Schema):
+ query: str = Field(..., alias="aliased.-_~name")
+
+
+@api_controller("/query")
+class QueryParamController:
+ @route.get("/")
+ def get_query(self, request, query):
+ return f"foo bar {query}"
+
+ @route.get("/optional")
+ def get_query_optional(self, request, query=None):
+ if query is None:
+ return "foo bar"
+ return f"foo bar {query}"
+
+ @route.get("/int")
+ def get_query_type(self, request, query: int):
+ return f"foo bar {query}"
+
+ @route.get("/int/optional")
+ def get_query_type_optional(self, request, query: int = None):
+ if query is None:
+ return "foo bar"
+ return f"foo bar {query}"
+
+ @route.get("/int/default")
+ def get_query_type_optional_10(self, request, query: int = 10):
+ return f"foo bar {query}"
+
+ @route.get("/list")
+ def get_query_list(self, request, query: List[str] = Query(...)):
+ return ",".join(query)
+
+ @route.get("/list-optional")
+ def get_query_optional_list(
+ self, request, query: Optional[List[str]] = Query(None)
+ ):
+ if query:
+ return ",".join(query)
+ return query
+
+ @route.get("/param")
+ def get_query_param(self, request, query=Query(None)):
+ if query is None:
+ return "foo bar"
+ return f"foo bar {query}"
+
+ @route.get("/param-required")
+ def get_query_param_required(self, request, query=Query(...)):
+ return f"foo bar {query}"
+
+ @route.get("/param-required/int")
+ def get_query_param_required_type(self, request, query: int = Query(...)):
+ return f"foo bar {query}"
+
+ @route.get("/aliased-name")
+ def get_query_aliased_name(self, request, query: AliasedSchema = Query(...)):
+ return f"foo bar {query.query}"
diff --git a/tests/test_operation.py b/tests/test_operation.py
index 8d6511db..78943329 100644
--- a/tests/test_operation.py
+++ b/tests/test_operation.py
@@ -2,8 +2,9 @@
import django
import pytest
+from ninja import Body, Schema
-from ninja_extra import api_controller, http_delete, http_get, route, status
+from ninja_extra import api_controller, http_delete, http_get, http_post, route, status
from ninja_extra.controllers import AsyncRouteFunction, RouteFunction
from ninja_extra.helper import get_route_function
from ninja_extra.operation import AsyncOperation, Operation
@@ -118,8 +119,17 @@ async def test_async_route_operation_execution_should_log_execution(self):
def test_controller_operation_order():
+ class InputSchema(Schema):
+ name: str
+ age: int
+
@api_controller("/my/api/users", tags=["User"])
class UserAPIController:
+ @http_post("/me")
+ def set_user(self, request, data: Body[InputSchema]):
+ assert self.context.kwargs["data"] == data
+ return data
+
@http_get("/me")
def get_current_user(self, request):
return {"debug": "ok", "message": "Current user"}
@@ -133,6 +143,10 @@ def delete_user_from_clinic(self, request, user_id: uuid.UUID):
return {"debug": "ok", "message": "User deleted"}
client = TestClient(UserAPIController)
+
+ response = client.post("/me", json={"name": "Ellar", "age": 2})
+ assert response.json() == {"name": "Ellar", "age": 2}
+
response = client.get("/me")
assert response.json() == {"debug": "ok", "message": "Current user"}
diff --git a/tests/test_path.py b/tests/test_path.py
new file mode 100644
index 00000000..7eb31888
--- /dev/null
+++ b/tests/test_path.py
@@ -0,0 +1,330 @@
+import pytest
+from main import PathParamController
+
+from ninja_extra.testing import TestClient
+
+client = TestClient(PathParamController)
+
+
+def test_text_get():
+ response = client.get("/text")
+ assert response.status_code == 200, response.text
+ assert response.json() == "Hello World"
+
+
+response_not_valid_bool = {
+ "detail": [
+ {
+ "type": "bool_parsing",
+ "loc": ["path", "item_id"],
+ "msg": "Input should be a valid boolean, unable to interpret input",
+ }
+ ]
+}
+
+response_not_valid_int = {
+ "detail": [
+ {
+ "type": "int_parsing",
+ "loc": ["path", "item_id"],
+ "msg": "Input should be a valid integer, unable to parse string as an integer",
+ }
+ ]
+}
+
+response_not_valid_int_float = {
+ "detail": [
+ {
+ "type": "int_parsing",
+ "loc": ["path", "item_id"],
+ "msg": "Input should be a valid integer, unable to parse string as an integer",
+ }
+ ]
+}
+
+response_not_valid_float = {
+ "detail": [
+ {
+ "type": "float_parsing",
+ "loc": ["path", "item_id"],
+ "msg": "Input should be a valid number, unable to parse string as a number",
+ }
+ ]
+}
+
+response_at_least_3 = {
+ "detail": [
+ {
+ "type": "string_too_short",
+ "loc": ["path", "item_id"],
+ "msg": "String should have at least 3 characters",
+ "ctx": {"min_length": 3},
+ }
+ ]
+}
+
+
+response_at_least_2 = {
+ "detail": [
+ {
+ "type": "string_too_short",
+ "loc": ["path", "item_id"],
+ "msg": "String should have at least 2 characters",
+ "ctx": {"min_length": 2},
+ }
+ ]
+}
+
+
+response_maximum_3 = {
+ "detail": [
+ {
+ "type": "string_too_long",
+ "loc": ["path", "item_id"],
+ "msg": "String should have at most 3 characters",
+ "ctx": {"max_length": 3},
+ }
+ ]
+}
+
+
+response_greater_than_3 = {
+ "detail": [
+ {
+ "type": "greater_than",
+ "loc": ["path", "item_id"],
+ "msg": "Input should be greater than 3",
+ "ctx": {"gt": 3.0},
+ }
+ ]
+}
+
+
+response_greater_than_0 = {
+ "detail": [
+ {
+ "type": "greater_than",
+ "loc": ["path", "item_id"],
+ "msg": "Input should be greater than 0",
+ "ctx": {"gt": 0.0},
+ }
+ ]
+}
+
+
+response_greater_than_1 = {
+ "detail": [
+ {
+ "type": "greater_than",
+ "loc": ["path", "item_id"],
+ "msg": "Input should be greater than 1",
+ "ctx": {"gt": 1},
+ }
+ ]
+}
+
+
+response_greater_than_equal_3 = {
+ "detail": [
+ {
+ "type": "greater_than_equal",
+ "loc": ["path", "item_id"],
+ "msg": "Input should be greater than or equal to 3",
+ "ctx": {"ge": 3.0},
+ }
+ ]
+}
+
+
+response_less_than_3 = {
+ "detail": [
+ {
+ "type": "less_than",
+ "loc": ["path", "item_id"],
+ "msg": "Input should be less than 3",
+ "ctx": {"lt": 3.0},
+ }
+ ]
+}
+
+
+response_less_than_0 = {
+ "detail": [
+ {
+ "type": "less_than",
+ "loc": ["path", "item_id"],
+ "msg": "Input should be less than 0",
+ "ctx": {"lt": 0.0},
+ }
+ ]
+}
+
+response_less_than_equal_3 = {
+ "detail": [
+ {
+ "type": "less_than_equal",
+ "loc": ["path", "item_id"],
+ "msg": "Input should be less than or equal to 3",
+ "ctx": {"le": 3.0},
+ }
+ ]
+}
+
+
+response_not_valid_pattern = {
+ "detail": [
+ {
+ "ctx": {
+ "pattern": "^foo",
+ },
+ "loc": ["path", "item_id"],
+ "msg": "String should match pattern '^foo'",
+ "type": "string_pattern_mismatch",
+ }
+ ]
+}
+
+
+@pytest.mark.parametrize(
+ "path,expected_status,expected_response",
+ [
+ ("/foobar", 200, "foobar"),
+ ("/str/foobar", 200, "foobar"),
+ ("/str/42", 200, "42"),
+ ("/str/True", 200, "True"),
+ ("/int/foobar", 422, response_not_valid_int),
+ ("/int/True", 422, response_not_valid_int),
+ ("/int/42", 200, 42),
+ ("/int/42.5", 422, response_not_valid_int_float),
+ ("/float/foobar", 422, response_not_valid_float),
+ ("/float/True", 422, response_not_valid_float),
+ ("/float/42", 200, 42),
+ ("/float/42.5", 200, 42.5),
+ ("/bool/foobar", 422, response_not_valid_bool),
+ ("/bool/True", 200, True),
+ ("/bool/42", 422, response_not_valid_bool),
+ ("/bool/42.5", 422, response_not_valid_bool),
+ ("/bool/1", 200, True),
+ ("/bool/0", 200, False),
+ ("/bool/true", 200, True),
+ ("/bool/False", 200, False),
+ ("/bool/false", 200, False),
+ ("/param/foo", 200, "foo"),
+ ("/param-required/foo", 200, "foo"),
+ ("/param-minlength/foo", 200, "foo"),
+ ("/param-minlength/fo", 422, response_at_least_3),
+ ("/param-maxlength/foo", 200, "foo"),
+ ("/param-maxlength/foobar", 422, response_maximum_3),
+ ("/param-min_maxlength/foo", 200, "foo"),
+ ("/param-min_maxlength/foobar", 422, response_maximum_3),
+ ("/param-min_maxlength/f", 422, response_at_least_2),
+ ("/param-gt/42", 200, 42),
+ ("/param-gt/2", 422, response_greater_than_3),
+ ("/param-gt0/0.05", 200, 0.05),
+ ("/param-gt0/0", 422, response_greater_than_0),
+ ("/param-ge/42", 200, 42),
+ ("/param-ge/3", 200, 3),
+ ("/param-ge/2", 422, response_greater_than_equal_3),
+ ("/param-lt/42", 422, response_less_than_3),
+ ("/param-lt/2", 200, 2),
+ ("/param-lt0/-1", 200, -1),
+ ("/param-lt0/0", 422, response_less_than_0),
+ ("/param-le/42", 422, response_less_than_equal_3),
+ ("/param-le/3", 200, 3),
+ ("/param-le/2", 200, 2),
+ ("/param-lt-gt/2", 200, 2),
+ ("/param-lt-gt/4", 422, response_less_than_3),
+ ("/param-lt-gt/0", 422, response_greater_than_1),
+ ("/param-le-ge/2", 200, 2),
+ ("/param-le-ge/1", 200, 1),
+ ("/param-le-ge/3", 200, 3),
+ ("/param-le-ge/4", 422, response_less_than_equal_3),
+ ("/param-lt-int/2", 200, 2),
+ ("/param-lt-int/42", 422, response_less_than_3),
+ ("/param-lt-int/2.7", 422, response_not_valid_int_float),
+ ("/param-gt-int/42", 200, 42),
+ ("/param-gt-int/2", 422, response_greater_than_3),
+ ("/param-gt-int/2.7", 422, response_not_valid_int_float),
+ ("/param-le-int/42", 422, response_less_than_equal_3),
+ ("/param-le-int/3", 200, 3),
+ ("/param-le-int/2", 200, 2),
+ ("/param-le-int/2.7", 422, response_not_valid_int_float),
+ ("/param-ge-int/42", 200, 42),
+ ("/param-ge-int/3", 200, 3),
+ ("/param-ge-int/2", 422, response_greater_than_equal_3),
+ ("/param-ge-int/2.7", 422, response_not_valid_int_float),
+ ("/param-lt-gt-int/2", 200, 2),
+ ("/param-lt-gt-int/4", 422, response_less_than_3),
+ ("/param-lt-gt-int/0", 422, response_greater_than_1),
+ ("/param-lt-gt-int/2.7", 422, response_not_valid_int_float),
+ ("/param-le-ge-int/2", 200, 2),
+ ("/param-le-ge-int/1", 200, 1),
+ ("/param-le-ge-int/3", 200, 3),
+ ("/param-le-ge-int/4", 422, response_less_than_equal_3),
+ ("/param-le-ge-int/2.7", 422, response_not_valid_int_float),
+ ("/param-pattern/foo", 200, "foo"),
+ ("/param-pattern/fo", 422, response_not_valid_pattern),
+ ],
+)
+def test_get_path(path, expected_status, expected_response):
+ response = client.get(path)
+ print(path, response.json())
+ assert response.status_code == expected_status
+ assert response.json() == expected_response
+
+
+@pytest.mark.parametrize(
+ "path,expected_status,expected_response",
+ [
+ ("/param-django-str/42", 200, "42"),
+ ("/param-django-str/-1", 200, "-1"),
+ ("/param-django-str/foobar", 200, "foobar"),
+ ("/param-django-int/0", 200, 0),
+ ("/param-django-int/42", 200, 42),
+ ("/param-django-int/42.5", "Cannot resolve", Exception),
+ ("/param-django-int/-1", "Cannot resolve", Exception),
+ ("/param-django-int/True", "Cannot resolve", Exception),
+ ("/param-django-int/foobar", "Cannot resolve", Exception),
+ ("/param-django-int/not-an-int", 200, "Found not-an-int"),
+ # ("/path/param-django-int-str/42", 200, "42"), # https://github.com/pydantic/pydantic/issues/5993
+ ("/param-django-int-str/42.5", "Cannot resolve", Exception),
+ (
+ "/param-django-slug/django-ninja-is-the-best",
+ 200,
+ "django-ninja-is-the-best",
+ ),
+ ("/param-django-slug/42.5", "Cannot resolve", Exception),
+ (
+ "/param-django-uuid/31ea378c-c052-4b4c-bf0b-679ce5cfcc2a",
+ 200,
+ "31ea378c-c052-4b4c-bf0b-679ce5cfcc2a",
+ ),
+ (
+ "/param-django-uuid/31ea378c-c052-4b4c-bf0b-679ce5cfcc2",
+ "Cannot resolve",
+ Exception,
+ ),
+ (
+ "/param-django-uuid-str/31ea378c-c052-4b4c-bf0b-679ce5cfcc2a",
+ 200,
+ "31ea378c-c052-4b4c-bf0b-679ce5cfcc2a",
+ ),
+ ("/param-django-path/some/path/things/after", 200, "some/path/things"),
+ ("/param-django-path/less/path/after", 200, "less/path"),
+ ("/param-django-path/plugh/after", 200, "plugh"),
+ ("/param-django-path//after", "Cannot resolve", Exception),
+ ("/param-django-custom-int/42", 200, 24),
+ ("/param-django-custom-int/x42", "Cannot resolve", Exception),
+ ("/param-django-custom-float/42", 200, 0.24),
+ ("/param-django-custom-float/x42", "Cannot resolve", Exception),
+ ],
+)
+def test_get_path_django(path, expected_status, expected_response):
+ if expected_response is Exception:
+ with pytest.raises(Exception, match=expected_status):
+ client.get(path)
+ else:
+ response = client.get(path)
+ print(response.json())
+ assert response.status_code == expected_status
+ assert response.json() == expected_response
diff --git a/tests/test_query.py b/tests/test_query.py
new file mode 100644
index 00000000..78a094e7
--- /dev/null
+++ b/tests/test_query.py
@@ -0,0 +1,79 @@
+import pytest
+from main import QueryParamController
+
+from ninja_extra.testing import TestClient
+
+response_missing = {
+ "detail": [
+ {
+ "type": "missing",
+ "loc": ["query", "query"],
+ "msg": "Field required",
+ }
+ ]
+}
+
+response_not_valid_int = {
+ "detail": [
+ {
+ "type": "int_parsing",
+ "loc": ["query", "query"],
+ "msg": "Input should be a valid integer, unable to parse string as an integer",
+ }
+ ]
+}
+
+response_not_valid_int_float = {
+ "detail": [
+ {
+ "type": "int_parsing",
+ "loc": ["query", "query"],
+ "msg": "Input should be a valid integer, unable to parse string as an integer",
+ }
+ ]
+}
+
+
+client = TestClient(QueryParamController)
+
+
+@pytest.mark.parametrize(
+ "path,expected_status,expected_response",
+ [
+ ("/", 422, response_missing),
+ ("/?query=baz", 200, "foo bar baz"),
+ ("/?not_declared=baz", 422, response_missing),
+ ("/optional", 200, "foo bar"),
+ ("/optional?query=baz", 200, "foo bar baz"),
+ ("/optional?not_declared=baz", 200, "foo bar"),
+ ("/int", 422, response_missing),
+ ("/int?query=42", 200, "foo bar 42"),
+ ("/int?query=42.5", 422, response_not_valid_int_float),
+ ("/int?query=baz", 422, response_not_valid_int),
+ ("/int?not_declared=baz", 422, response_missing),
+ ("/int/optional", 200, "foo bar"),
+ ("/int/optional?query=50", 200, "foo bar 50"),
+ ("/int/optional?query=foo", 422, response_not_valid_int),
+ ("/int/default", 200, "foo bar 10"),
+ ("/int/default?query=50", 200, "foo bar 50"),
+ ("/int/default?query=foo", 422, response_not_valid_int),
+ ("/list?query=a&query=b&query=c", 200, "a,b,c"),
+ ("/list-optional?query=a&query=b&query=c", 200, "a,b,c"),
+ ("/list-optional?query=a", 200, "a"),
+ ("/list-optional", 200, None),
+ ("/param", 200, "foo bar"),
+ ("/param?query=50", 200, "foo bar 50"),
+ ("/param-required", 422, response_missing),
+ ("/param-required?query=50", 200, "foo bar 50"),
+ ("/param-required/int", 422, response_missing),
+ ("/param-required/int?query=50", 200, "foo bar 50"),
+ ("/param-required/int?query=foo", 422, response_not_valid_int),
+ ("/aliased-name?aliased.-_~name=foo", 200, "foo bar foo"),
+ ],
+)
+def test_get_path(path, expected_status, expected_response):
+ response = client.get(path)
+ resp = response.json()
+ print(resp)
+ assert response.status_code == expected_status
+ assert resp == expected_response