diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9d866e3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/python-CI.yml b/.github/workflows/python-CI.yml index 335def9..d1b7198 100644 --- a/.github/workflows/python-CI.yml +++ b/.github/workflows/python-CI.yml @@ -1,104 +1,99 @@ -name: Full CI +name: CI for Tests and Package Publishing on: push: - branches: [main, dev] + branches: + - '**' + pull_request: + branches: + - main release: - types: [published] + types: + - published workflow_dispatch: - inputs: - job: - description: 'Choose which job to run' - required: true - default: 'deploy' - type: choice - options: - - publish-module - - test-Django - - coverage - - all permissions: contents: read jobs: - test-Django: - if: github.event.inputs.job == 'test-Django' || github.event.inputs.job == 'all' || github.event_name == 'release' || github.event_name == 'push' + test: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install Dependencies - run: | - cd ./test/app/ - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install ../../ - - name: Run Tests - run: | - cd ./test/app/ - python manage.py test - - coverage: - if: github.event.inputs.job == 'coverage' || github.event.inputs.job == 'all' || github.event_name == 'release' || github.event_name == 'push' - runs-on: ubuntu-latest - needs: test-Django - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - name: Install Dependencies - run: | - cd ./test/app/ - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install coverage - pip install ../../ - - name: Run Coverage - run: | - cd ./test/app/ && coverage run manage.py test && coverage html && coverage xml - - name: Upload Coverage to GitHub - uses: actions/upload-artifact@v4 - with: - name: coverage-report - path: ./test/app/htmlcov - - name: Report Coverage - uses: codecov/codecov-action@v4.0.1 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: ./test/app/htmlcov/coverage.xml - - publish-module: - if: github.event_name == 'release' || github.event.inputs.job == 'publish-module' || github.event.inputs.job == 'all' + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.x' + + - name: Install Poetry + run: | + curl -sSL https://install.python-poetry.org | python3 - + export PATH="$HOME/.local/bin:$PATH" + + - name: Install dependencies + run: | + poetry install + + - name: Run pre-commit hooks + run: | + poetry run pre-commit run --all-files + + - name: Run tests with tox + run: | + poetry run tox + + - name: Stop on failure + if: failure() + run: exit 1 + + - name: Upload to Codecov + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + flags: unittests + fail_ci_if_error: true + verbose: true + + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload HTML coverage report + if: always() + uses: actions/upload-artifact@v3 + with: + name: coverage-report + path: htmlcov/ + + publish: runs-on: ubuntu-latest - needs: [test-Django, coverage] + needs: test + + if: github.event_name == 'release' # Exécuter uniquement lors d'une release + steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Check current folder - run: | - ls - - name: Build package - run: | - python -m build - - name: Publish build - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.x' + + - name: Install Poetry + run: | + curl -sSL https://install.python-poetry.org | python3 - + export PATH="$HOME/.local/bin:$PATH" + + - name: Install dependencies + run: poetry install --no-dev + + - name: Build package + run: poetry build + + - name: Publish package + run: poetry publish --username __token__ --password ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore index f9268e3..cecb533 100644 --- a/.gitignore +++ b/.gitignore @@ -162,4 +162,6 @@ cython_debug/ # MacOS .DS_Store .AppleDouble -.LSOverride \ No newline at end of file +.LSOverride + +junit.xml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0c21804 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,81 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-toml + - id: check-yaml + files: \.yaml$ + - id: trailing-whitespace + exclude: (migrations/|tests/).* + - id: end-of-file-fixer + exclude: (migrations/|tests/).* + - id: check-added-large-files + exclude: (migrations/|tests/).* + - id: check-case-conflict + exclude: (migrations/|tests/).* + - id: check-merge-conflict + exclude: (migrations/|tests/).* + - id: check-docstring-first + exclude: (migrations/|tests/).* + + - repo: https://github.com/tox-dev/pyproject-fmt + rev: 2.2.1 + hooks: + - id: pyproject-fmt + + - repo: https://github.com/tox-dev/tox-ini-fmt + rev: 1.3.1 + hooks: + - id: tox-ini-fmt + + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.2 + hooks: + - id: pyupgrade + + - repo: https://github.com/pre-commit/mirrors-isort + rev: v5.10.1 + hooks: + - id: isort + exclude: (migrations/|tests/).* + + - repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black + args: [ "--config=pyproject.toml" ] + exclude: (migrations/).* + + - repo: https://github.com/PyCQA/bandit + rev: 1.7.4 + hooks: + - id: bandit + args: [ "-c", "pyproject.toml", "-r", "." ] + additional_dependencies: [ "bandit[toml]" ] + exclude: (migrations/|tests/).* + + - repo: local + hooks: + - id: pytest + name: Pytest + entry: poetry run pytest -v + language: system + types: [ python ] + stages: [ commit ] + pass_filenames: false + always_run: true + + - id: pylint + name: pylint + entry: poetry run pylint + language: system + types: [ python ] + require_serial: true + args: + - "-rn" + - "-sn" + - "--rcfile=pyproject.toml" + - "--load-plugins=pylint_pytest" + + files: ^hybridrouter/ + exclude: (migrations/|tests/).* diff --git a/.vscode/settings.json b/.vscode/settings.json index 7d70419..34b81ce 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,14 +1,17 @@ { - "python.testing.unittestArgs": [ + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + }, + "isort.args":["--profile", "black"], + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false, + "python.testing.pytestArgs": [ "-v", "-s", - "./test/app/", - "-m", - "unittest", - "discover", - "-p", - "test*.py" - ], - "python.testing.pytestEnabled": false, - "python.testing.unittestEnabled": true, -} \ No newline at end of file + "./hybridrouter/tests/" + ] +} diff --git a/README.md b/README.md index ed696fc..9c0c2ed 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,20 @@ -[![codecov](https://codecov.io/github/enzofrnt/djangorestframework-hybridrouter/branch/main/graph/badge.svg?token=4DAZ8HFPOZ)](https://codecov.io/github/enzofrnt/djangorestframework-hybridrouter) - # djangorestframework-hybridrouter -A router for ViewSets and Views! And with a better browsable API! + +A router for ViewSets and APIViews, with a better browsable API and support for nested routers! ***Inspired by [this topic](https://stackoverflow.com/questions/18817988/using-django-rest-frameworks-browsable-api-with-apiviews/78459183#78459183).*** ## Overview -The `HybridRouter` class is an extension of Django REST framework's `DefaultRouter` that allows you to register both ViewSets and APIViews. This provides more flexibility in managing your URL routes and offers a better browsable API experience. +The `HybridRouter` class is an extension of Django REST Framework's `DefaultRouter` that allows you to register both `ViewSet`s and `APIView`s. It provides more flexibility in managing your URL routes, offers a better browsable API experience, and supports nested routers. ## Features -- Register both ViewSets and APIViews. +- Register both `ViewSet`s and `APIView`s using a unified interface. - Simplified URL patterns for better readability. -- Enhanced browsable API with custom intermediary API views for grouped endpoints ***Checkout the experimental features section for more details***. +- Automatic creation of intermediate API views for grouped endpoints (configurable). +- Support for nested routers. +- Automatic conflict resolution for basenames. ## Installation @@ -23,8 +24,15 @@ pip install djangorestframework-hybridrouter ## Usage -Here’s an example of how to use the HybridRouter: +Here’s an example of how to use the `HybridRouter`: + ```python +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.viewsets import ViewSet +from django.urls import path, include +from hybrid_router import HybridRouter + class ServerConfigViewSet(ViewSet): def list(self, request): return Response({'a': 'b'}) @@ -41,68 +49,120 @@ class ServerModsView(APIView): def get(self, request): return Response({'mods': 'server'}) -router = HybridRouter(enable_intermediate_apiviews=True) -router.register_view(r'^server-config/$', ServerConfigView, name='server-config') -router.register_view(r'^mods/client/$', ClientModsView, name='mods-client') -router.register_view(r'^mods/server/$', ServerModsView, name='mods-server') -router.register_viewset(r'coucou', ServerConfigViewSet, basename='coucou') -router.register_view(r'^coucou/client/$', ClientModsView, name='coucou-client') -router.register_view(r'^coucou/server/$', ServerModsView, name='coucou-server') +router = HybridRouter() +router.include_intermediate_views = True # Enable intermediate API views + +# Register APIViews +router.register('server-config', ServerConfigView, basename='server-config') +router.register('mods/client', ClientModsView, basename='mods-client') +router.register('mods/server', ServerModsView, basename='mods-server') + +# Register a ViewSet +router.register('coucou', ServerConfigViewSet, basename='coucou') + +# Register more APIViews under 'coucou' prefix +router.register('coucou/client', ClientModsView, basename='coucou-client') +router.register('coucou/server', ServerModsView, basename='coucou-server') urlpatterns = [ path('', include(router.urls)), ] ``` +This configuration will generate URLs for both APIViews and ViewSets, and include intermediate API views for grouped endpoints. + ## Documentation -HybridRouter +**HybridRouter** + +- `register(prefix, view, basename=None)` + + Registers an `APIView` or `ViewSet` with the specified prefix. -- `register_view(url, view, name)` + - `prefix`: URL prefix for the view or viewset. + - `view`: The `APIView `or `ViewSet` class. + - `basename`: The base name for the view or viewset (optional). If not provided, it will be automatically generated. +- `register_nested_router(prefix, router)` - Registers an APIView with the specified URL pattern. + Registers a nested router under a specific prefix. - • url: URL pattern for the view. - • view: The APIView class. - • name: The name of the view. + - `prefix`: URL prefix under which the nested router will be registered. + - `router`: The DRF router instance to be nested. -- `register_viewset(prefix, viewset, basename=None)` +**Attributes** - Registers a ViewSet with the specified prefix. +- `include_intermediate_views` (default True) - • prefix: URL prefix for the viewset. - • viewset: The ViewSet class. - • basename: The base name for the viewset (optional). + Controls whether intermediate API views are automatically created for grouped endpoints. When set to True, the router will generate intermediate views that provide a browsable API listing of all endpoints under a common prefix. -- `register(prefix, view, name)` +**Notes** - Registers an APIView or ViewSet with the specified prefix. +- Automatic Basename Conflict Resolution + + The `HybridRouter` automatically handles conflict resolution for `basenames`. If you register multiple `views` or `viewsets` with the same `basename`, it will assign unique `basenames` and log a warning. + +- Trailing Slash + + The `HybridRouter` uses a configurable trailing_slash attribute, defaulting to "/?" to match DRF’s `SimpleRouter` behavior. - • prefix: URL prefix for the view. - • view: The APIView or ViewSet class. - • name: The name of the view. ## Advanced Features -Custom Intermediary API Views +### Custom Intermediary API Views -The HybridRouter automatically creates custom intermediary API views for grouped endpoints. This is useful for organizing your API and providing a cleaner browsable interface. +The `HybridRouter` can automatically create custom intermediary API views for grouped endpoints. This feature improves the organization of your API and provides a cleaner browsable interface. -## Experimental Features +**Example:** + +```python +router = HybridRouter() +router.include_intermediate_views = True # Enable intermediate API views + +router.register('server-config', ServerConfigView, basename='server-config') +router.register('server-config/map', ServerConfigView, basename='server-config') +router.register('server-config/health', ServerConfigView, basename='server-config') +router.register('mods/client', ClientModsView, basename='mods-client') +router.register('mods/server', ServerModsView, basename='mods-server') +``` + +With `include_intermediate_views` set to True, the router will create intermediate views at the `mods/` prefix, providing a browsable API that lists both `client` and `server` endpoints under `mods/`. Also note that here for `server-config/` endpoints an intermediate view will not be created because it's already registered. + +### Nested Routers + +You can register nested routers under a specific prefix using `register_nested_router`. This allows you to include routers from other apps or create a complex URL structure. + +**Example:** -In these improved Django REST framework's router, I introduced a new feature that automatically creates intermediary API views for grouped endpoints. This feature is still in development and may not work as expected. Please report any issues or suggestions. +```python +from rest_framework.routers import DefaultRouter + +nested_router = DefaultRouter() +nested_router.register('items', ItemViewSet, basename='item') + +router = HybridRouter() +router.register_nested_router('nested/', nested_router) + +urlpatterns = [ + path('', include(router.urls)), +] +``` + + +In this example, all routes from nested_router will be available under the `nested/` prefix. + +## Experimental Features -here is a quick example of how to use this feature: +The automatic creation of intermediary API views is a feature that improves the browsable API experience. This feature is still in development and may not work as expected in all cases. Please report any issues or suggestions. ```python router = HybridRouter(enable_intermediate_apiviews=False) -router.register_view(r'^server-config', ServerConfigView, name='server-config') -router.register_view(r'^mods/client', ClientModsView, name='mods-client') -router.register_view(r'^mods/server', ServerModsView, name='mods-server') -router.register_view(r'^coucou/client', ClientModsView, name='coucou-client') -router.register_view(r'^coucou/server', ServerModsView, name='coucou-server') -router.register_viewset(r'coucou', ServerConfigViewSet, basename='coucou') +router.register('server-config', ServerConfigView, name='server-config') +router.register('mods/client', ClientModsView, name='mods-client') +router.register('mods/server', ServerModsView, name='mods-server') +router.register('coucou/client', ClientModsView, name='coucou-client') +router.register('coucou/server', ServerModsView, name='coucou-server') +router.register('coucou', ServerConfigViewSet, basename='coucou') ``` With this configuration of the router with `enable_intermediate_apiviews`set to `False`, the intermediary API views will not be created. So the browsable API will look like on a `DefaultRouter` : @@ -118,8 +178,32 @@ router = HybridRouter(enable_intermediate_apiviews=True) ![image](./docs/imgs/After_1.png) ![image](./docs/imgs/After_2.png) -This improves the readability and the logic of the browsable API and provides a better user experience. +This improves the readability and the logic of the browsable API and provides a better user experience. And as you can see that will not interfere with other already existing views. **Here, the `ServerConfigViewSet` is still accessible through the `coucou` endpoint and as not been overridden by an intermediary API view.** -***Note: Spectacular is supported and the intermediary API views will be generated with the `@extend_schema(exclude=True)` decorator, to not be included in the OpenAPI schema.*** \ No newline at end of file +## Testing + +The package includes comprehensive tests to ensure reliability. Here are some highlights: + +- Registering Views and ViewSets +Tests registering both APIViews and ViewSets, ensuring that URLs are correctly generated and accessible. + +- Intermediate Views +Tests the creation of intermediate views when include_intermediate_views is enabled or disabled. + +- Nested Routers +Tests registering nested routers and ensuring that their routes are correctly included under the specified prefix. + +- Basename Conflict Resolution +Tests automatic conflict resolution when multiple views or viewsets are registered with the same basename. + +## Notes + +- Compatibility + + The HybridRouter is designed to work seamlessly with Django REST Framework and is compatible with existing DRF features like schema generation. + +- Spectacular Support + + Will be added in the future. diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 062980a..0000000 --- a/codecov.yml +++ /dev/null @@ -1,14 +0,0 @@ -comment: #this is a top-level key - layout: " diff, flags, files" - behavior: default - require_changes: false # if true: only post the comment if coverage changes - require_base: false # [true :: must have a base report to post] - require_head: true # [true :: must have a head report to post] - hide_project_coverage: false # [true :: only show coverage on the git diff aka patch coverage]] - -coverage: - status: - project: - default: - target: 90% - threshold: 10% \ No newline at end of file diff --git a/hybridrouter/__init__.py b/hybridrouter/__init__.py index 948d379..e69de29 100644 --- a/hybridrouter/__init__.py +++ b/hybridrouter/__init__.py @@ -1,2 +0,0 @@ -from .hybridrouter import HybridRouter -from .hybridrouter import DRF_SPECTACULAR \ No newline at end of file diff --git a/hybridrouter/apps.py b/hybridrouter/apps.py new file mode 100644 index 0000000..e154d80 --- /dev/null +++ b/hybridrouter/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class HybridRouterConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "hybridrouter" diff --git a/hybridrouter/hybridrouter.py b/hybridrouter/hybridrouter.py old mode 100644 new mode 100755 index 3bf136d..d473da5 --- a/hybridrouter/hybridrouter.py +++ b/hybridrouter/hybridrouter.py @@ -1,147 +1,281 @@ -from django.urls import re_path +from collections import OrderedDict +from typing import Optional, Type, Union, overload + +from django.urls import include, path, re_path +from django.urls.exceptions import NoReverseMatch +from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.routers import DefaultRouter from rest_framework.views import APIView -from rest_framework.viewsets import ViewSet -from rest_framework.response import Response -from urllib.parse import urlsplit, urlunsplit - -try: - from drf_spectacular.utils import extend_schema - DRF_SPECTACULAR = True -except ImportError: - DRF_SPECTACULAR = False - -def conditionally_extend_schema(exclude=True): - def decorator(view_func): - if DRF_SPECTACULAR: - return extend_schema(exclude=exclude)(view_func) - return decorator - -class HybridRouter(DefaultRouter): - def __init__(self, enable_intermediate_apiviews=False , *args, **kwargs): - super().__init__(*args, **kwargs) - self._api_view_urls = {} - self.enable_intermediate_apiviews = enable_intermediate_apiviews - - def register_view(self, url, view, name): - path = re_path(url + '/$', view.as_view(), name=name) - self._api_view_urls[name] = path - - def register_viewset(self, prefix, viewset, basename=None): - super().register(prefix, viewset, basename) - - def register(self, prefix, view_class, basename=None): - if issubclass(view_class, ViewSet): - self.register_viewset(prefix, view_class, basename) - elif issubclass(view_class, APIView): - self.register_view(prefix, view_class, basename) +from rest_framework.viewsets import ViewSetMixin + +from .utils import logger + + +class TreeNode: + def __init__(self, name=None): + self.name = name + self.children = {} + self.view = None # Can be a view or a ViewSet + self.basename = None + self.is_viewset = False + self.is_nested_router = False + self.router = None # For manually nested routers + + +class HybridRouter(DefaultRouter): + include_intermediate_views = True # Controls intermediate views + trailing_slash = "/?" # Define trailing slash as in DRF's SimpleRouter + + def __init__(self): + super().__init__() + self.root_node = TreeNode() + self.used_url_names = set() # Set of used URL names + self.basename_registry = {} # Registry for basenames + + def _add_route(self, path_parts, view, basename=None): + # Determine if it's a ViewSet or a regular view + is_viewset = False + if isinstance(view, type): + is_viewset = issubclass(view, ViewSetMixin) else: - raise ValueError("The class must be a subclass of APIView or ViewSet") + is_viewset = isinstance(view, ViewSetMixin) - @property - def api_view_urls(self): - return self._api_view_urls.copy() + node = self.root_node + for part in path_parts: + if part not in node.children: + node.children[part] = TreeNode(name=part) + node = node.children[part] + + node.view = view + node.basename = basename + node.is_viewset = is_viewset + + @overload + def register( + self, prefix: str, viewset: Type[APIView], basename: Optional[str] = None + ) -> None: + ... + + @overload + def register( + self, prefix: str, viewset: Type[ViewSetMixin], basename: Optional[str] = None + ) -> None: + ... + + def register( + self, + prefix: str, + viewset: Union[Type[APIView], Type[ViewSetMixin]], + basename: Optional[str] = None, + ) -> None: + """ + Registers an APIView or ViewSet with the specified prefix. + + Args: + prefix (str): URL prefix for the view or viewset. + viewset (Type[APIView] or Type[ViewSetMixin]): The APIView or ViewSet class. + basename (str, optional): The base name for the view or viewset. Defaults to None. + """ + if basename is None: + basename = self.get_default_basename(viewset) + path_parts = prefix.strip("/").split("/") + + # Register the information for conflict resolution + if basename not in self.basename_registry: + self.basename_registry[basename] = [] + self.basename_registry[basename].append( + { + "prefix": prefix, + "view": viewset, + "basename": basename, + "path_parts": path_parts, + } + ) + + def register_nested_router(self, prefix, router): + """ + Registers a nested router under a certain prefix. + """ + node = self.root_node + path_parts = prefix.strip("/").split("/") + for part in path_parts: + if part not in node.children: + node.children[part] = TreeNode(name=part) + node = node.children[part] + node.is_nested_router = True + node.router = router + + def _resolve_basename_conflicts(self): + """ + Resolve basename conflicts by assigning unique basenames + and displaying a single warning message per conflicting basename. + """ + for basename, registrations in self.basename_registry.items(): + if len(registrations) > 1: + # Conflict detected + prefixes = [reg["prefix"] for reg in registrations] + logger.warning( + "The basename '%s' is used for multiple registrations: %s. Generating unique basenames.", + basename, + ", ".join(prefixes), + ) + # Assign new unique basenames + for idx, reg in enumerate(registrations, start=1): + unique_basename = f"{basename}_{idx}" + reg["basename"] = unique_basename + # Else, the basename is unique, no need to change it def get_urls(self): - urls = super().get_urls() - urls.extend(self._api_view_urls.values()) - - if self.enable_intermediate_apiviews: - pattern_mapping = {} - simplified_urls = [] - - for url in urls: - # Extract the path pattern as a string - path = url.pattern.regex.pattern if hasattr(url.pattern, 'regex') else url.pattern._route - - # Simplify the path by removing regex-specific elements - simple_path = path.replace('^', '').replace('$', '').replace('.(?P[a-z0-9]+)/?', '').replace('(?P.[a-z0-9]+/?)', '') - if "\\" not in simple_path: - simplified_urls.append(simple_path) - pattern_mapping[simple_path] = url.name - - # Remove duplicates while preserving order - simplified_urls = list(dict.fromkeys(simplified_urls)) - - groupes = {} - for path in simplified_urls: - base_path = path.split('/')[0] - groupes.setdefault(base_path, []).append(pattern_mapping[path]) - - for base_path, endpoints in groupes.items(): - if len(endpoints) > 1: - self.create_custom_intermediary_api_view(urls, base_path, endpoints) + # Before building the URLs, resolve basename conflicts + self._resolve_basename_conflicts() + # Build the tree by calling _add_route for each registration + for registrations in self.basename_registry.values(): + for reg in registrations: + self._add_route( + reg["path_parts"], reg["view"], basename=reg["basename"] + ) + # Now, build the URLs + urls = [] + self._build_urls(self.root_node, "", urls) + return urls + + def _build_urls(self, node, prefix, urls): + # If there's a view at this node, add it + if node.view: + if node.is_viewset: + # Generate URL patterns directly for the ViewSet + viewset_urls = self._get_viewset_urls(node.view, prefix, node.basename) + urls.extend(viewset_urls) + else: + # Add the basic view with a unique name + name = f"{node.basename}" + urls.append(path(f"{prefix}", node.view.as_view(), name=name)) + # If this node is a nested router, include it + elif node.is_nested_router: + urls.append( + path( + f"{prefix}", + include(node.router.urls), + ) + ) + # Process child nodes + if node.children: + # Include intermediate views if enabled and there's no view at this node + if ( + self.include_intermediate_views + and not node.view + and not node.is_nested_router + ): + if prefix: + api_root_view = self._get_api_root_view(node, prefix) + if api_root_view: + urls.append(path(f"{prefix}", api_root_view)) + for child in node.children.values(): + child_prefix = f"{prefix}{child.name}/" + self._build_urls(child, child_prefix, urls) + + def _get_viewset_urls(self, viewset, prefix, basename): + """ + Génère les URL patterns pour un ViewSet sans utiliser de sous-routeur. + """ + routes = self.get_routes(viewset) + urls = [] + lookup = self.get_lookup_regex(viewset) + + for route in routes: + mapping = self.get_method_map(viewset, route.mapping) + if not mapping: + continue + + # Construire le pattern URL + regex = route.url.format( + prefix=prefix.rstrip("/"), + lookup=lookup, + trailing_slash=self.trailing_slash, + ) + + # Générer la vue + view = viewset.as_view(mapping, **route.initkwargs) + + # Générer le nom de l'URL + name = route.name.format(basename=basename) if route.name else None + + # Ajouter le pattern URL + urls.append(re_path(regex, view, name=name)) return urls - def create_custom_intermediary_api_view(self, urls, base_path, endpoints): - @conditionally_extend_schema() - class CustomIntermediaryAPIView(APIView): - _ignore_model_permissions = True + def get_method_map(self, viewset, method_map): + """ + Given a viewset and a mapping {http_method: action}, + return a new mapping dict mapping the HTTP methods + to the corresponding viewset methods if they exist. + """ + bound_methods = {} + for http_method, action in method_map.items(): + if hasattr(viewset, action): + bound_methods[http_method] = action + return bound_methods - def get(self, request, format=None): - ret = {endpoint: reverse(endpoint, request=request, format=format) for endpoint in self.endpoints} - return Response(ret) + def get_lookup_regex(self, viewset, lookup_prefix=""): + """ + Return the regex pattern for the lookup field. + """ + lookup_field = getattr(viewset, "lookup_field", "pk") + lookup_url_kwarg = getattr(viewset, "lookup_url_kwarg", None) or lookup_field + lookup_value = getattr(viewset, "lookup_value_regex", "[^/.]+") + return f"(?P<{lookup_prefix}{lookup_url_kwarg}>{lookup_value})" + + def _get_api_root_view(self, node, prefix): + api_root_dict = OrderedDict() + has_children = False + + for child_name, child_node in node.children.items(): + has_children = True + if child_node.is_viewset or child_node.view: + url_name = f"{child_node.basename}-list" + elif child_node.is_nested_router: + url_name = f"{prefix}{child_name}-api-root" + else: + url_name = f"{prefix}{child_name}-api-root" + api_root_dict[child_name] = url_name - CustomIntermediaryAPIView.endpoints = endpoints - CustomIntermediaryAPIView.__name__ = f"API{base_path.capitalize()}" - urls.append(re_path(rf'^{base_path}/$', CustomIntermediaryAPIView.as_view(), name=base_path)) + if not has_children: + return None - def get_api_root_view(self, **kwargs): - - api_root_dict = {prefix: self.routes[0].name.format(basename=basename) for prefix, viewset, basename in self.registry} - api_view_urls = self.api_view_urls - - @conditionally_extend_schema() class APIRoot(APIView): _ignore_model_permissions = True - enable_intermediate_apiviews = self.enable_intermediate_apiviews - - def simplify_url(self, url): - parsed_url = urlsplit(url) - path_segments = parsed_url.path.split('/') - new_path = f"/{path_segments[1]}/" if len(path_segments) > 1 else '/' - return urlunsplit((parsed_url.scheme, parsed_url.netloc, new_path, '', '')) - - def get(self, request, format=None): - ret = {} - if self.enable_intermediate_apiviews: - ret_simplify = {} - - for key, url_name in api_root_dict.items(): - full_url = reverse(url_name, request=request, format=format) - ret[key] = full_url - simplify_url = self.simplify_url(full_url) - if simplify_url != full_url and simplify_url not in ret.values(): - ret_simplify[key] = simplify_url - for api_view_key in api_view_urls: - full_url = reverse(api_view_urls[api_view_key].name, request=request, format=format) - ret[api_view_key] = full_url - simplify_url = self.simplify_url(full_url) - if simplify_url != full_url and simplify_url not in ret.values(): - ret_simplify[api_view_key] = simplify_url - - result = {} - compteur = {} - - for url in ret_simplify.values(): - compteur[url] = compteur.get(url, 0) + 1 - - for name, url in ret_simplify.items(): - if compteur[url] > 1: - result.setdefault(url, []).append(name) - - for url, names in result.items(): - for name in names: - ret.pop(name) - new_name = url.strip('/').split('/')[-1] - ret[new_name] = url - - else: - for key, url_name in api_root_dict.items(): - ret[key] = reverse(url_name, request=request, format=format) - for api_view_key in api_view_urls: - ret[api_view_key] = reverse(api_view_urls[api_view_key].name, request=request, format=format) + schema = None # Exclude from schema if necessary + + def get(self, request, *args, **kwargs): + ret = OrderedDict() + namespace = request.resolver_match.namespace + for key, url_name in api_root_dict.items(): + if namespace: + url_name_full = f"{namespace}:{url_name}" + else: + url_name_full = url_name + try: + ret[key] = reverse(url_name_full, request=request) + except NoReverseMatch: + ret[key] = request.build_absolute_uri(f"{key}/") return Response(ret) - return APIRoot.as_view() \ No newline at end of file + + return APIRoot.as_view() + + def get_api_root_view(self, api_urls=None): + """ + Override the main API Root view to respect the `include_root_view` + logic of DefaultRouter. + """ + if not self.include_root_view: + return None + return self._get_api_root_view(self.root_node, "") + + @property + def urls(self): + urls = self.get_urls() + if self.include_root_view: + urls.append(path("", self.get_api_root_view(), name=self.root_view_name)) + return urls diff --git a/hybridrouter/tests/__init__.py b/hybridrouter/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hybridrouter/tests/conftest.py b/hybridrouter/tests/conftest.py new file mode 100644 index 0000000..403cb4c --- /dev/null +++ b/hybridrouter/tests/conftest.py @@ -0,0 +1,111 @@ +import django +import pytest +from django.core.exceptions import ImproperlyConfigured +from django.urls import get_resolver + +from .utils import list_urls + +test_url_resolver = None + + +def recevoir_test_url_resolver(url_resolver): + global test_url_resolver + test_url_resolver = url_resolver + + +def pytest_exception_interact(node, call, report): + global test_url_resolver + + print("test_url_resolver lors de l'exception:", test_url_resolver) + + if report.failed: + print("test_url_resolver lors de l'exception:", test_url_resolver) + + if test_url_resolver: + all_urls = test_url_resolver + else: + try: + all_urls = get_resolver().url_patterns + # Votre code ici + except ImproperlyConfigured: + return + except ModuleNotFoundError as e: + print(f"Erreur lors de l'accès au résolveur : {e}") + return + + urls_list = list_urls(all_urls, prefix="http://localhost/") + urls_text = "\n".join(urls_list) + + if hasattr(report, "longrepr"): + report.longrepr = f"{report.longrepr}\n\nAvailable URLs:\n{urls_text}" + + +def pytest_configure(): + from django.conf import settings + + default_settings = dict( + DEBUG_PROPAGATE_EXCEPTIONS=True, + DATABASES={ + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "db.sqlite3", + "TEST": { + "NAME": "db_test.sqlite3", + }, + }, + }, + SITE_ID=1, + SECRET_KEY="not very secret in tests", + USE_I18N=True, + STATIC_URL="/static/", + ROOT_URLCONF="tests.urls", + TEMPLATES=[ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "OPTIONS": { + "debug": True, + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, + ], + MIDDLEWARE=( + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + ), + INSTALLED_APPS=[ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "hybridrouter.tests", + ], + REST_FRAMEWORK={ + "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), + "DEFAULT_PARSER_CLASSES": ("rest_framework.parsers.JSONParser",), + }, + WSGI_APPLICATION="tests.wsgi.application", + ASGI_APPLICATION="tests.asgi.application", + PASSWORD_HASHERS=("django.contrib.auth.hashers.MD5PasswordHasher",), + DEFAULT_AUTO_FIELD="django.db.models.AutoField", + ) + + settings.configure(**default_settings) + django.setup() + + +@pytest.fixture +def hybrid_router(): + from hybridrouter.hybridrouter import HybridRouter + + return HybridRouter() diff --git a/hybridrouter/tests/models.py b/hybridrouter/tests/models.py new file mode 100644 index 0000000..748a3f6 --- /dev/null +++ b/hybridrouter/tests/models.py @@ -0,0 +1,9 @@ +from django.db import models + + +class Item(models.Model): + name = models.CharField(max_length=100) + description = models.TextField(blank=True, null=True) + + def __str__(self): + return self.name diff --git a/hybridrouter/tests/serializers.py b/hybridrouter/tests/serializers.py new file mode 100644 index 0000000..95863c8 --- /dev/null +++ b/hybridrouter/tests/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from .models import Item + + +class ItemSerializer(serializers.ModelSerializer): + class Meta: + model = Item + fields = "__all__" diff --git a/hybridrouter/tests/test_hybrid_router.py b/hybridrouter/tests/test_hybrid_router.py new file mode 100644 index 0000000..25b4e83 --- /dev/null +++ b/hybridrouter/tests/test_hybrid_router.py @@ -0,0 +1,574 @@ +import types + +import pytest +from django.test import override_settings +from django.urls import include, path, reverse +from django.urls.resolvers import get_resolver +from rest_framework import status +from rest_framework.routers import DefaultRouter +from rest_framework.test import APIClient + +from .conftest import recevoir_test_url_resolver +from .models import Item +from .views import ItemView +from .viewsets import ItemViewSet, SlugItemViewSet + + +def create_urlconf(router): + module = types.ModuleType("temporary_urlconf") + module.urlpatterns = [ + path("", include(router.urls)), + ] + return module + + +@override_settings() +def test_register_views_and_viewsets(hybrid_router, db): + # Enregistrer des vues simples + hybrid_router.register("items-view", ItemView, basename="item-view") + + # Enregistrer des ViewSets + hybrid_router.register("items-set", ItemViewSet, basename="item-set") + + # Créer un module temporaire pour les URLs + urlconf = create_urlconf(hybrid_router) + + # Définir ROOT_URLCONF sur le module temporaire + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + # Vérifier que les URL sont correctement générées + view_url = reverse("item-view") + list_url = reverse("item-set-list") + detail_url = reverse("item-set-detail", kwargs={"pk": 1}) + + assert view_url == "/items-view/" + assert list_url == "/items-set/" + assert detail_url == "/items-set/1/" + + # Vérifier que les vues fonctionnent correctement + client = APIClient() + response = client.get(view_url) + assert response.status_code == status.HTTP_200_OK + + Item.objects.create(id=1, name="Test Item", description="Item for testing.") + + response = client.get(list_url) + assert response.status_code == status.HTTP_200_OK + + response = client.get(detail_url) + assert response.status_code == status.HTTP_200_OK + + +@override_settings() +def test_register_only_views(hybrid_router, db): + # Enregistrer uniquement des vues simples + hybrid_router.register("simple-view", ItemView, basename="simple-view") + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + # Vérifier que l'URL est correctement générée + view_url = reverse("simple-view") + assert view_url == "/simple-view/" + + # Vérifier que la vue fonctionne correctement + client = APIClient() + response = client.get(view_url) + assert response.status_code == status.HTTP_200_OK + + +@override_settings() +def test_register_only_viewsets(hybrid_router, db): + # Enregistrer uniquement des ViewSets + hybrid_router.register("items", ItemViewSet, basename="item") + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + # Vérifier que les URLs du ViewSet fonctionnent + list_url = reverse("item-list") + detail_url = reverse("item-detail", kwargs={"pk": 1}) + + assert list_url == "/items/" + assert detail_url == "/items/1/" + + # Créer un item pour le test + Item.objects.create(id=1, name="Test Item", description="Item for testing.") + + client = APIClient() + response = client.get(list_url) + assert response.status_code == status.HTTP_200_OK + + response = client.get(detail_url) + assert response.status_code == status.HTTP_200_OK + + +@override_settings() +def test_url_patterns(hybrid_router, db): + hybrid_router.register("items", ItemViewSet, basename="item") + + # Créer un module temporaire pour les URLs + urlconf = create_urlconf(hybrid_router) + + # Définir ROOT_URLCONF sur le module temporaire + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + # Vérifier que les patterns d'URL sont correctement générés + list_url = reverse("item-list") + detail_url = reverse("item-detail", kwargs={"pk": 1}) + + assert list_url == "/items/" + assert detail_url == "/items/1/" + + # Créer un item pour le test + Item.objects.create(id=1, name="Test Item", description="Item for testing.") + + # Vérifier que les URL fonctionnent avec le client de test + client = APIClient() + response = client.get(list_url) + assert response.status_code == status.HTTP_200_OK + + response = client.get(detail_url) + assert response.status_code == status.HTTP_200_OK + + +@override_settings() +def test_custom_lookup_field(hybrid_router, db): + hybrid_router.register("slug-items", SlugItemViewSet, basename="slug-item") + + # Créer un module temporaire pour les URLs + urlconf = create_urlconf(hybrid_router) + + # Définir ROOT_URLCONF sur le module temporaire + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + # Créer un item avec un nom unique + Item.objects.create(name="unique-name", description="Item with unique name.") + + # Vérifier que l'URL utilise le champ de recherche personnalisé + detail_url = reverse("slug-item-detail", kwargs={"name": "unique-name"}) + assert detail_url == "/slug-items/unique-name/" + + client = APIClient() + response = client.get(detail_url) + assert response.status_code == status.HTTP_200_OK + + +def test_api_root_view(hybrid_router, db): + # include_root_view est True par défaut + hybrid_router.register("items", ItemViewSet, basename="item") + hybrid_router.register("users", ItemViewSet, basename="user") + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + response = APIClient().get("/") + assert response.status_code == status.HTTP_200_OK + expected_data = { + "items": "http://testserver/items/", + "users": "http://testserver/users/", + } + assert response.json() == expected_data + + +def test_no_api_root_view(hybrid_router, db): + hybrid_router.include_root_view = False + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + response = APIClient().get("/") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_intermediary_view(hybrid_router, db): + hybrid_router.include_intermediate_views = True # Par défaut True + + hybrid_router.register("items/1/", ItemView, basename="item_1") + hybrid_router.register("items/2/", ItemView, basename="item_2") + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + client = APIClient() + + response = client.get("/items/1/") + assert response.status_code == status.HTTP_200_OK + + response = client.get("/items/2/") + assert response.status_code == status.HTTP_200_OK + + # La vue intermédiaire est-elle disponible ? + response = client.get("/items/") + assert response.status_code == status.HTTP_200_OK + assert response.data == { + "1": "http://testserver/items/1/", + "2": "http://testserver/items/2/", + } + + +def test_no_intermediary_view(hybrid_router, db): + hybrid_router.include_intermediate_views = False + + hybrid_router.register("items/1/", ItemView, basename="item_1") + hybrid_router.register("items/2/", ItemView, basename="item_2") + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + client = APIClient() + + response = client.get("/items/1/") + assert response.status_code == status.HTTP_200_OK + + response = client.get("/items/2/") + assert response.status_code == status.HTTP_200_OK + + response = client.get("/items/") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_intermediary_view_multiple_levels(hybrid_router, db): + hybrid_router.include_intermediate_views = True + + hybrid_router.register("level1/level2/level3/", ItemView, basename="item-deep") + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + client = APIClient() + + response = client.get("/level1/") + assert response.status_code == status.HTTP_200_OK + + response = client.get("/level1/level2/") + assert response.status_code == status.HTTP_200_OK + + response = client.get("/level1/level2/level3/") + assert response.status_code == status.HTTP_200_OK + + +def test_no_intermediary_view_multiple_levels(hybrid_router, db): + hybrid_router.include_intermediate_views = False + + hybrid_router.register("level1/level2/level3/", ItemView, basename="item-deep") + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + client = APIClient() + + response = client.get("/level1/") + assert response.status_code == status.HTTP_404_NOT_FOUND + + response = client.get("/level1/level2/") + assert response.status_code == status.HTTP_404_NOT_FOUND + + response = client.get("/level1/level2/level3/") + assert response.status_code == status.HTTP_200_OK + + +def test_intermediary_view_with_only_viewsets(hybrid_router, db): + hybrid_router.include_intermediate_views = True + + hybrid_router.register("items", ItemViewSet, basename="item") + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + client = APIClient() + + response = client.get("/items/") + assert response.status_code == status.HTTP_200_OK + + # Créer un item pour le test + Item.objects.create(id=1, name="Test Item", description="Item for testing.") + + detail_url = reverse("item-detail", kwargs={"pk": 1}) + response = client.get(detail_url) + assert response.status_code == status.HTTP_200_OK + + +def test_register_nested_router(hybrid_router, db): + nested_router = DefaultRouter() + nested_router.register("items", ItemViewSet, basename="item") + + hybrid_router.register_nested_router("nested/", nested_router) + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + client = APIClient() + + response = client.get("/nested/items/") + assert response.status_code == status.HTTP_200_OK + + +def test_register_router_directly(hybrid_router, db): + nested_router = DefaultRouter() + nested_router.register("items", ItemViewSet, basename="item") + + # Enregistrer le routeur imbriqué directement + hybrid_router.register_nested_router("nested/", nested_router) + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + client = APIClient() + + response = client.get("/nested/items/") + assert response.status_code == status.HTTP_200_OK + + +def test_nested_router_url_patterns(hybrid_router, db): + nested_router = DefaultRouter() + nested_router.register("items", ItemViewSet, basename="item") + + hybrid_router.register_nested_router("api/v1/", nested_router) + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + list_url = reverse("item-list") + assert list_url == "/api/v1/items/" + + response = APIClient().get("/api/v1/items/") + assert response.status_code == status.HTTP_200_OK + + +def test_no_basename_provided(hybrid_router): + # Enregistrer un ViewSet sans fournir de basename + hybrid_router.register("items", ItemViewSet) + # Le basename par défaut devrait être 'item' + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + # Essayer de renverser les noms d'URL en utilisant le basename par défaut + list_url = reverse("item-list") + assert list_url == "/items/" + + +def test_no_basename_provided_multiple(hybrid_router): + # Enregistrer plusieurs ViewSets sans basename + hybrid_router.register("items1", ItemViewSet) + hybrid_router.register("items2", ItemViewSet) + hybrid_router.register("items3", ItemViewSet) + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + list_url1 = reverse("item_1-list") + list_url2 = reverse("item_2-list") + list_url3 = reverse("item_3-list") + + assert list_url1 == "/items1/" + assert list_url2 == "/items2/" + assert list_url3 == "/items3/" + + +def test_no_basename_conflict(hybrid_router): + # Enregistrer deux ViewSets de la même classe sans basename + hybrid_router.register("items1", ItemViewSet) + hybrid_router.register("items2", ItemViewSet) + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + # Les basenames par défaut seront les mêmes, il devrait y avoir un conflit résolu + list_url1 = reverse("item_1-list") + list_url2 = reverse("item_2-list") + assert list_url1 == "/items1/" + assert list_url2 == "/items2/" + + +def test_same_basename_warning(hybrid_router, caplog): + hybrid_router.register("items1", ItemViewSet, basename="item") + hybrid_router.register("items2", ItemViewSet, basename="item") + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + # Vérifier qu'un avertissement a été enregistré + warnings = [ + record for record in caplog.records if record.levelname == "WARNING" + ] + assert len(warnings) >= 1 + assert ( + "The basename 'item' is used for multiple registrations" + in warnings[0].message + ) + + # Vérifier que des basenames uniques ont été assignés + list_url1 = reverse("item_1-list") + list_url2 = reverse("item_2-list") + assert list_url1 == "/items1/" + assert list_url2 == "/items2/" + + +def test_same_basename_error(hybrid_router, caplog): + # Enregistrer deux ViewSets avec le même basename explicitement + hybrid_router.register("items1", ItemViewSet, basename="item") + hybrid_router.register("items2", ItemViewSet, basename="item") + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + # Vérifier qu'un avertissement a été enregistré + warnings_list = [ + record for record in caplog.records if record.levelname == "WARNING" + ] + assert len(warnings_list) >= 1 + assert ( + "The basename 'item' is used for multiple registrations" + in warnings_list[0].message + ) + + # Vérifier que des basenames uniques ont été assignés + list_url1 = reverse("item_1-list") + list_url2 = reverse("item_2-list") + assert list_url1 == "/items1/" + assert list_url2 == "/items2/" + + +def test_register_with_trailing_slash_in_prefix(hybrid_router, db): + hybrid_router.register("items/", ItemViewSet, basename="item") + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + list_url = reverse("item-list") + assert list_url == "/items/" + + # Essayer d'accéder à l'URL + response = APIClient().get("/items/") + assert response.status_code == status.HTTP_200_OK + + +def test_custom_trailing_slash(hybrid_router, db): + # Définir trailing_slash sur une chaîne vide + hybrid_router.trailing_slash = "" + + hybrid_router.register("items", ItemViewSet, basename="item") + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + list_url = reverse("item-list") + assert list_url == "/items" + + # Essayer d'accéder à l'URL + response = APIClient().get("/items") + assert response.status_code == status.HTTP_200_OK + + +def test_intermediate_view_with_nested_router(hybrid_router, db): + hybrid_router.include_intermediate_views = True + + nested_router = DefaultRouter() + nested_router.register("subitems", ItemViewSet, basename="subitem") + + hybrid_router.register_nested_router("items/", nested_router) + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + # La vue intermédiaire 'items/' devrait être disponible + response = APIClient().get("/items/") + assert response.status_code == status.HTTP_200_OK + + # Les URLs du routeur imbriqué devraient être accessibles + response = APIClient().get("/items/subitems/") + assert response.status_code == status.HTTP_200_OK + + +def test_no_intermediate_view_with_nested_router(hybrid_router, db): + hybrid_router.include_intermediate_views = False + + nested_router = DefaultRouter() + nested_router.include_root_view = ( + False # Important pour éviter la vue intermédiaire + ) + nested_router.register("subitems", ItemViewSet, basename="subitem") + + hybrid_router.register_nested_router("items/", nested_router) + + urlconf = create_urlconf(hybrid_router) + + with override_settings(ROOT_URLCONF=urlconf): + resolver = get_resolver(urlconf) + recevoir_test_url_resolver(resolver.url_patterns) + + # La vue intermédiaire 'items/' ne devrait pas être disponible + response = APIClient().get("/items/") + assert response.status_code == status.HTTP_404_NOT_FOUND + + # Les URLs du routeur imbriqué devraient être accessibles + response = APIClient().get("/items/subitems/") + assert response.status_code == status.HTTP_200_OK diff --git a/hybridrouter/tests/urls.py b/hybridrouter/tests/urls.py new file mode 100644 index 0000000..637600f --- /dev/null +++ b/hybridrouter/tests/urls.py @@ -0,0 +1 @@ +urlpatterns = [] diff --git a/hybridrouter/tests/utils.py b/hybridrouter/tests/utils.py new file mode 100644 index 0000000..ee2c90b --- /dev/null +++ b/hybridrouter/tests/utils.py @@ -0,0 +1,12 @@ +import sys + + +def list_urls(urlpatterns, prefix=""): + sys.stdout.write("\n") + for pattern in urlpatterns: + if hasattr(pattern, "url_patterns"): # Si c'est un include + list_urls(pattern.url_patterns, prefix + str(pattern.pattern)) + else: + url = prefix + str(pattern.pattern) + name = pattern.name if pattern.name else "None" + sys.stdout.write(f"{url} -> {name}\n") diff --git a/hybridrouter/tests/views.py b/hybridrouter/tests/views.py new file mode 100644 index 0000000..f7a4e4e --- /dev/null +++ b/hybridrouter/tests/views.py @@ -0,0 +1,11 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from .models import Item +from .serializers import ItemSerializer + + +class ItemView(APIView): + def get(self, request): + items = Item.objects.all() + serializer = ItemSerializer(items, many=True) + return Response(serializer.data) diff --git a/hybridrouter/tests/viewsets.py b/hybridrouter/tests/viewsets.py new file mode 100644 index 0000000..8d75a2c --- /dev/null +++ b/hybridrouter/tests/viewsets.py @@ -0,0 +1,14 @@ +from rest_framework.viewsets import ModelViewSet +from .models import Item +from .serializers import ItemSerializer + + +class ItemViewSet(ModelViewSet): + queryset = Item.objects.all() + serializer_class = ItemSerializer + + +class SlugItemViewSet(ModelViewSet): + queryset = Item.objects.all() + serializer_class = ItemSerializer + lookup_field = "name" diff --git a/hybridrouter/utils.py b/hybridrouter/utils.py new file mode 100644 index 0000000..a3c3527 --- /dev/null +++ b/hybridrouter/utils.py @@ -0,0 +1,45 @@ +import logging + + +class ColorFormatter(logging.Formatter): + COLOR_MAP = { + "ERROR": "\033[31m", # Rouge + "WARNING": "\033[33m", # Jaune/Orange + "INFO": "\033[32m", # Vert + } + RESET = "\033[0m" + + def __init__(self, fmt=None, datefmt=None): + super().__init__(fmt, datefmt) + + def format(self, record): + # Formater la date selon le format spécifié + record.asctime = self.formatTime(record, self.datefmt) + date_str = f"[{record.asctime}] " + + # Construire le reste du message + color = self.COLOR_MAP.get(record.levelname, self.RESET) + message = f"{record.levelname}: {record.getMessage()}" + colored_message = f"{color}{message}{self.RESET}" + + # Combiner la date non colorée avec le message coloré + return f"{date_str}{colored_message}" + + +# Configurer le logger 'hybridrouter' +logger = logging.getLogger("hybridrouter") +logger.setLevel(logging.DEBUG) # Définir le niveau de log souhaité + +# Définir le format avec la date +log_format = "[%(asctime)s] %(levelname)s: %(message)s" +date_format = "%d/%b/%Y %H:%M:%S" + +# Initialiser le ColorFormatter avec le format et le format de date +color_formatter = ColorFormatter(fmt=log_format, datefmt=date_format) + +# Créer un handler pour la sortie console et appliquer le formatter +handler = logging.StreamHandler() +handler.setFormatter(color_formatter) + +# Ajouter le handler au logger +logger.addHandler(handler) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..93d807c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,175 @@ +[build-system] +build-backend = "poetry.core.masonry.api" +requires = [ "poetry-core" ] + +[tool.poetry] +name = "djangorestframework-hybridrouter" +version = "1.0.0" +description = "Django app that provides a hybrid router for Django Rest Framework" +authors = [ "enzo_frnt" ] +license = "MIT" +readme = "README.md" +keywords = [ "django", "rest", "framework", "router", "hybrid", "views and viewsets" ] + +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Django :: 3.2", + "Framework :: Django :: 4.0", + "Framework :: Django :: 4.1", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", + "Framework :: Django :: 5.1", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +packages = [ + { include = "hybridrouter" }, +] + +[tool.poetry.urls] +"Documentation" = "https://github.com/enzofrnt/djangorestframework-hybridrouter/blob/main/README.md" +"Source Code" = "https://github.com/enzofrnt/djangorestframework-hybridrouter" +"Issues" = "https://github.com/enzofrnt/djangorestframework-hybridrouter/issues" + +[tool.poetry.dependencies] +python = ">=3.8,<4" +django = ">=3.2,<5.2" +djangorestframework = ">=3.12.4,<3.16" + +[tool.poetry.group.dev.dependencies] +pytest = "8.2.0" +pytest-django = "^4.8.0" +pytest-cov = "^5.0.0" +pylint = "^3.2.6" +pylint-pytest = "^1.1.8" +mypy = "^1.11.1" +isort = "^5.13.2" +black = "^24.4.2" +pre-commit = "^3.5.0" +bandit = { extras = [ "toml" ], version = "^1.7.9" } +tox = "^4.16.0" +django-stubs = "^5.0.4" +codecov = "^2.1.13" +python-semantic-release = "^9.8.8" + +[tool.black] +line-length = 88 +exclude = ''' +/( + \.git + | \.venv + | \.tox + | build + | dist + | migrations + | venv + | env + | __pycache__ + | node_modules + | env + | kernel + | \.mypy_cache + | \.pytest_cache + | .*\.egg-info +)/ +''' + +[tool.isort] +profile = "black" +line_length = 88 +skip = [ + "venv", + ".venv", + ".tox", + "build", + "dist", + ".git", + "__pycache__", + "*.egg-info", + ".mypy_cache", + ".pytest_cache", + "migrations", + "node_modules", + "env", + "kernel", +] + +[tool.pylint] +disable = [ + "C0103", # Invalid constant name + "C0114", # Missing module docstring + "C0115", # Missing class docstring + "C0116", # Missing function or method docstring + "E1101", # Instance of 'Foo' has no 'bar' member + "W0212", # Access to a protected member + "C0301", # Line too long + "C0411", # Wrong import order + "W0611", # Unused imports + "W0613", # Unused arguments + "W0622", # Redefining built-in names + "R0903", # Too few public methods + "R0801", # Duplicate code + "W0621", + "C0415", + "R1719", # The if expression can be replaced with 'bool(test)' + "R1705", # Unnecessary "elif" after "return" + "R0401", +] +max-line-length = 88 +ignore = [ + "tests", + "migrations/*", + "venv/*", + "build/*", + "dist/*", + ".git/*", + ".tox/*", + "__pycache__/*", + "*.egg-info/*", + ".mypy_cache/*", + ".pytest_cache/*", +] +load-plugins = [ + "pylint_pytest", +] + +suggestion-mode = true +const-rgx = "([A-Z_][A-Z0-9_]*)|(__.*__)" +attr-rgx = "[a-z_][a-z0-9_]{2,30}$" +variable-rgx = "[a-z_][a-z0-9_]{2,30}$" +argument-rgx = "[a-z_][a-z0-9_]{2,30}$" +method-rgx = "[a-z_][a-z0-9_]{2,30}$" +function-rgx = "[a-z_][a-z0-9_]{2,30}$" +class-rgx = "[A-Z_][a-zA-Z0-9]+$" +module-rgx = "(([a-z_][a-z0-9_]*)|(__.*__))$" + +[tool.bandit] +targets = [ "./hybridrouter" ] +exclude_dirs = [ + "tests", + "migrations", +] +severity = "medium" +confidence = "medium" +max_lines = 500 +progress = true +reports = true +output_format = "screen" +output_file = "bandit_report.txt" +include = [ "B101", "B102" ] +exclude_tests = [ "B301", "B302" ] + +[tool.bandit.plugins] +B104 = { check_typed_list = true } diff --git a/setup.py b/setup.py deleted file mode 100644 index 2800b23..0000000 --- a/setup.py +++ /dev/null @@ -1,28 +0,0 @@ -""" -This model is used to create the package -""" - -from setuptools import setup - - -with open("README.md", "r", encoding="utf-8") as fh: - long_description = fh.read() - -setup( - name='djangorestframework-hybridrouter', - version='0.1.5', - packages=[ - 'hybridrouter', - ], - install_requires=[ - 'Django', - 'djangorestframework', - ], - description='A package to regsiter viewsets and views in the same router', - long_description=long_description, - long_description_content_type="text/markdown", - author='Made by enzo_frnt from a ', - url='https://github.com/enzofrnt/djangorestframework-hybridrouter', - keywords=["Django", "Django REST Framework", "viewsets", "views", "router", "view", "viewset"], - test_suite='test', -) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..ba5de23 --- /dev/null +++ b/tox.ini @@ -0,0 +1,45 @@ +[tox] +requires = + tox>=4.2 +env_list = + py312-django4.2-drf3.14 + py312-django5.0-drf3.14 + py312-django5.1-drf3.15 + py311-django4.1-drf3.14 + py311-django4.2-drf3.14 + py311-django5.0-drf3.14 + py39-django3.2-drf3.12 + py39-django4.0-drf3.14 + py39-django4.1-drf3.14 + +[testenv] +package = editable +deps = + codecov + django3.2: Django>=3.2,<4.0 + django4.0: Django>=4.0,<4.1 + django4.1: Django>=4.1,<4.2 + django4.2: Django>=4.2,<4.3 + django5.0: Django>=5.0,<5.1 + django5.1: Django>=5.1,<5.2 + drf3.12: djangorestframework>=3.12.4,<3.13 + drf3.14: djangorestframework>=3.14,<3.15 + drf3.15: djangorestframework>=3.15,<3.16 + pytest + pytest-cov + pytest-django +commands = + pytest --cov=hybridrouter --cov-report=html --cov-report=xml --junitxml=junit.xml -o junit_family=legacy + codecov -f coverage.xml + +[testenv:py39] +base_python = python3.9 + +[testenv:py310] +base_python = python3.10 + +[testenv:py311] +base_python = python3.11 + +[testenv:py312] +base_python = python3.12