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