django-api-versioning 0.1.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
File without changes
@@ -0,0 +1,88 @@
1
+ from functools import wraps
2
+ from typing import Callable, Optional, List
3
+ from .settings import api_settings as settings
4
+ from .registry import registry
5
+ from .exceptions import InvalidVersionError, VersionRangeError, VersionTypeError
6
+
7
+
8
+ def endpoint(
9
+ postfix: str,
10
+ version: Optional[int] = None,
11
+ backward: bool = True,
12
+ app_name: Optional[str] = None,
13
+ view_name: Optional[str] = None,
14
+ ) -> Callable:
15
+ """
16
+ Decorator to register API views with versioning support.
17
+
18
+ - Uses `API_MIN_VERSION` and `API_MAX_VERSION` from Django settings.
19
+ - Supports backward compatibility by registering multiple versions if needed.
20
+ - Ensures that no version lower than `API_MIN_VERSION` is registered.
21
+
22
+ Args:
23
+ postfix (str): The endpoint suffix (e.g., "users" → "api/v1/users").
24
+ version (Optional[int]): The version of the API. Defaults to None (unversioned).
25
+ backward (bool): If True, registers routes for all versions from `API_MIN_VERSION` up to the current version, which is less than or equal to `API_MAX_VERSION`. Defaults to True.
26
+ app_name (Optional[str]): The app name to be prefixed to the route.
27
+ view_name (Optional[str]): The custom view name for Django.
28
+
29
+ Returns:
30
+ Callable: The decorated view function.
31
+
32
+ Raises:
33
+ VersionTypeError: If the provided `version` is not an integer.
34
+ VersionRangeError: If `API_MIN_VERSION` or `API_MAX_VERSION` are not properly set.
35
+ """
36
+
37
+ def decorator(func: Callable) -> Callable:
38
+ @wraps(func)
39
+ def view(*args, **kwargs):
40
+ return func(*args, **kwargs)
41
+
42
+ # Read API versioning settings
43
+ min_version: int = getattr(settings, "API_MIN_VERSION", 1)
44
+ max_version: int = getattr(settings, "API_MAX_VERSION", 1)
45
+
46
+ if not isinstance(min_version, int) or not isinstance(max_version, int):
47
+ raise VersionRangeError("API_MIN_VERSION and API_MAX_VERSION must be integers.")
48
+
49
+ if min_version > max_version:
50
+ raise VersionRangeError("API_MIN_VERSION cannot be greater than API_MAX_VERSION.")
51
+
52
+ if version is not None and not isinstance(version, int):
53
+ raise VersionTypeError("Version must be an integer or None.")
54
+
55
+ if version is not None and version > max_version:
56
+ raise InvalidVersionError(f"Version {version} is above the maximum allowed version {max_version}.")
57
+
58
+ app_name_part: str = f"{app_name}/" if app_name else ""
59
+
60
+ def _register_route(ver: Optional[int]) -> None:
61
+ """Helper function to register a route in the registry."""
62
+ if ver is None:
63
+ base_path = "" # No version prefix
64
+ else:
65
+ base_path = settings.API_BASE_PATH.format(version=ver)
66
+ route = f"{base_path}{app_name_part}{postfix}"
67
+ registry.register(route, view, view_name)
68
+
69
+ def _get_valid_versions() -> List[Optional[int]]:
70
+ """Returns a list of valid versions to register."""
71
+ if version is None:
72
+ # If no version is given, register only the unversioned route
73
+ return [None] # Just register the unversioned route
74
+ if version < min_version:
75
+ return [] # Ignore versions below min_version
76
+ if backward:
77
+ return list(range(min_version, version + 1)) # Register all versions up to the given version
78
+ return [version] # Only register the specified version when backward is False
79
+
80
+ # Register valid versions
81
+ valid_versions = _get_valid_versions()
82
+
83
+ for ver in valid_versions:
84
+ _register_route(ver)
85
+
86
+ return view
87
+
88
+ return decorator
@@ -0,0 +1,18 @@
1
+ class VersioningError(Exception):
2
+ """Raised when there is a general error in API versioning."""
3
+ pass
4
+
5
+
6
+ class InvalidVersionError(VersioningError):
7
+ """Raised when an invalid API version is used."""
8
+ pass
9
+
10
+
11
+ class VersionRangeError(VersioningError):
12
+ """Raised when the version range is not valid (e.g., min_version > max_version)."""
13
+ pass
14
+
15
+
16
+ class VersionTypeError(VersioningError):
17
+ """Raised when the version type is invalid (e.g., not an integer)."""
18
+ pass
@@ -0,0 +1,33 @@
1
+ from typing import Callable, List, Optional
2
+ from django.urls import path
3
+ from django.http import HttpRequest, HttpResponse
4
+
5
+
6
+ class UrlRegistry:
7
+ """Registry for managing API versioning URLs."""
8
+
9
+ def __init__(self):
10
+ self.urlpatterns: List[path] = []
11
+
12
+ def register(self, route: str, view: Callable[[HttpRequest], HttpResponse], name: Optional[str] = None) -> None:
13
+ """Register a new API endpoint."""
14
+ if self._is_duplicate(route):
15
+ self._remove_duplicate(route)
16
+
17
+ if not callable(view) and hasattr(view, "as_view"):
18
+ view = view.as_view()
19
+
20
+ path_dict = {"name": name} if name else {}
21
+ self.urlpatterns.append(path(route, view, **path_dict))
22
+
23
+ def _is_duplicate(self, full_path):
24
+ """Check if the route is already registered."""
25
+ return any(str(pattern.pattern) == full_path for pattern in self.urlpatterns)
26
+
27
+ def _remove_duplicate(self, full_path):
28
+ """Remove any existing route with the same path."""
29
+ self.urlpatterns = [p for p in self.urlpatterns if str(p.pattern) != full_path]
30
+
31
+
32
+ registry = UrlRegistry()
33
+
@@ -0,0 +1,77 @@
1
+ import logging
2
+ from django.conf import settings
3
+ from django.core.exceptions import ImproperlyConfigured
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+ from .exceptions import VersionTypeError, VersionRangeError, VersioningError
7
+
8
+ # Initialize logger
9
+ logger = logging.getLogger(__name__)
10
+
11
+ @dataclass
12
+ class APISettings:
13
+
14
+ API_BASE_PATH: str = "api/v{version}/"
15
+ API_MAX_VERSION: int = 1
16
+ API_MIN_VERSION: int = 1
17
+ ROOT_URLCONF: str = 'django_api_versioning.urls'
18
+
19
+ @staticmethod
20
+ def get_setting(name: str, default: Optional[any] = None) -> Optional[any]:
21
+ """
22
+ Reads the setting from Django settings and provides a default if not found.
23
+ """
24
+ return getattr(settings, name, default)
25
+
26
+ def __post_init__(self):
27
+ # Ensure API_BASE_PATH contains "{version}"
28
+ if "{version}" not in self.API_BASE_PATH:
29
+ raise VersioningError("API_BASE_PATH must contain '{version}' like 'api/v{version}/' to support API versioning.")
30
+
31
+ # Ensure that API_BASE_PATH ends with a "/"
32
+ if not self.API_BASE_PATH.endswith("/"):
33
+ logger.warning(
34
+ "API_BASE_PATH should end with a '/'. Adding '/' automatically."
35
+ )
36
+ self.API_BASE_PATH += "/"
37
+
38
+ # Validate version settings
39
+ self.validate_version_settings()
40
+
41
+ if not self.ROOT_URLCONF:
42
+ raise ImproperlyConfigured("ROOT_URLCONF is required in settings.")
43
+
44
+ def validate_version_settings(self) -> None:
45
+ """
46
+ Validates that the API_MIN_VERSION and API_MAX_VERSION are integers
47
+ and that API_MIN_VERSION is not greater than API_MAX_VERSION.
48
+ """
49
+ if not isinstance(self.API_MIN_VERSION, int):
50
+ raise VersionTypeError(f"API_MIN_VERSION must be an integer, got {type(self.API_MIN_VERSION).__name__}.")
51
+
52
+ if not isinstance(self.API_MAX_VERSION, int):
53
+ raise VersionTypeError(f"API_MAX_VERSION must be an integer, got {type(self.API_MAX_VERSION).__name__}.")
54
+
55
+ if self.API_MIN_VERSION > self.API_MAX_VERSION:
56
+ raise VersionRangeError("API_MIN_VERSION cannot be greater than API_MAX_VERSION.")
57
+
58
+
59
+ # Adding default settings if not defined in Django settings
60
+ default_settings = {
61
+ 'API_BASE_PATH': "api/v{version}/",
62
+ 'API_MAX_VERSION': 1,
63
+ 'API_MIN_VERSION': 1,
64
+ 'ROOT_URLCONF': 'django_api_versioning.urls',
65
+ }
66
+
67
+ # Override default settings with actual Django settings if they exist
68
+ for setting, default_value in default_settings.items():
69
+ setattr(settings, setting, getattr(settings, setting, default_value))
70
+
71
+ # Initialize APISettings from Django settings
72
+ api_settings = APISettings(
73
+ API_BASE_PATH=APISettings.get_setting("API_BASE_PATH", "api/v{version}/"),
74
+ API_MAX_VERSION=APISettings.get_setting("API_MAX_VERSION", 1),
75
+ API_MIN_VERSION=APISettings.get_setting("API_MIN_VERSION", 1),
76
+ ROOT_URLCONF=APISettings.get_setting("ROOT_URLCONF", 'django_api_versioning.urls')
77
+ )
@@ -0,0 +1,5 @@
1
+ from typing import List
2
+ from django.urls import URLPattern
3
+ from .registry import registry
4
+
5
+ urlpatterns: List[URLPattern] = registry.urlpatterns
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) [year] [Full name]
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,261 @@
1
+ Metadata-Version: 2.1
2
+ Name: django-api-versioning
3
+ Version: 0.1.0
4
+ Summary: A powerful and flexible library for managing API versioning in Django projects.
5
+ Home-page: https://github.com/mojtaba-arvin/django-api-versioning
6
+ Author: Mojtaba Arvin
7
+ Author-email: ArvinDevDay@gmail.com
8
+ License: UNKNOWN
9
+ Platform: UNKNOWN
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Django :: 3.2
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: Django>=3.2
22
+ Provides-Extra: dev
23
+ Requires-Dist: black>=22.0.0; extra == "dev"
24
+ Requires-Dist: flake8>=4.0.0; extra == "dev"
25
+ Requires-Dist: isort>=5.0.0; extra == "dev"
26
+ Requires-Dist: mypy>=0.900; extra == "dev"
27
+ Requires-Dist: pytest>=6.0; extra == "dev"
28
+ Requires-Dist: pytest-django>=4.0; extra == "dev"
29
+ Requires-Dist: pytest-cov>=3.0.0; extra == "dev"
30
+ Requires-Dist: pre-commit>=2.0.0; extra == "dev"
31
+ Requires-Dist: twine>=4.0.0; extra == "dev"
32
+ Requires-Dist: build>=0.8.0; extra == "dev"
33
+
34
+
35
+ # Django API Versioning
36
+
37
+ [![PyPI version](https://badge.fury.io/py/django-api-versioning.svg)](https://badge.fury.io/py/django-api-versioning)
38
+ [![Build Status](https://github.com/mojtaba-arvin/django-api-versioning/actions/workflows/tests.yml/badge.svg)](https://github.com/mojtaba-arvin/django-api-versioning/actions)
39
+ [![codecov](https://codecov.io/gh/mojtaba-arvin/django-api-versioning/branch/main/graph/badge.svg)](https://codecov.io/gh/mojtaba-arvin/django-api-versioning)
40
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
41
+
42
+ **Django API Versioning** is a powerful and flexible library for managing API versioning in Django projects. It allows you to easily define and manage different versions of your API endpoints using decorators, ensuring backward compatibility and clean code organization.
43
+
44
+ ## Features
45
+
46
+ - **Easy Versioning**: Define API versions using simple decorators.
47
+ - **Backward Compatibility**: Automatically register routes for all versions up to the specified version.
48
+ - **Automatic Registration:** Views are **automatically** registered for each version specified, so there is no need to manually register each version in your `urls.py`.
49
+ - **Customizable Settings**: Configure API base path, minimum and maximum versions, and more.
50
+ - **Type Checking**: Full support for type hints and static type checking with `mypy`.
51
+ - **Testing Ready**: Includes comprehensive test suite and pre-commit hooks for code quality.
52
+
53
+ ## Installation
54
+
55
+ You can install Django API Versioning via pip:
56
+
57
+ ```bash
58
+ pip install django-api-versioning
59
+ ```
60
+
61
+ ## Quick Start
62
+
63
+ 1. ### Add to Django Settings:
64
+
65
+ ```python
66
+ INSTALLED_APPS = [
67
+ ...
68
+ 'django_api_versioning',
69
+ ...
70
+ ]
71
+ ```
72
+
73
+ 2. ### Define API Settings:
74
+
75
+ ```python
76
+
77
+ API_BASE_PATH = "api/v{version}/"
78
+ API_MIN_VERSION = 1
79
+ API_MAX_VERSION = 3
80
+ ```
81
+
82
+ 3. ### Register API urls:
83
+
84
+ if you don't use any `ROOT_URLCONF` in settings you can use this:
85
+
86
+ ```python
87
+ ROOT_URLCONF = 'django_api_versioning.urls'
88
+ ```
89
+
90
+ or you have already have a `ROOT_URLCONF` in settings, you only need to import them into your root `urls.py`:
91
+
92
+ ```python
93
+ from django.urls import path, include
94
+ from django_api_versioning.urls import urlpatterns as api_urlpatterns
95
+
96
+ urlpatterns = [
97
+ # other paths here
98
+
99
+ # use empty `route` param and use `API_BASE_PATH` in settings as prefix
100
+ path('', include(api_urlpatterns)),
101
+ ]
102
+
103
+ ```
104
+
105
+ 3. ### Use the Decorator:
106
+
107
+ The `endpoint` decorator can be used in both function-based views (FBVs) and class-based views (CBVs). It's also fully compatible with `Django Rest Framework (DRF)`. The decorator allows you to define versioning for your API views and supports backward compatibility by default and you don't need to pass `backward=True` flag to the `endpoint` decorator.
108
+
109
+
110
+ #### Example for Function-Based Views (FBVs):
111
+
112
+ ```python
113
+ from django_api_versioning.decorators import endpoint
114
+ from django.http import HttpResponse
115
+
116
+ @endpoint("users", version=2, app_name='account_app', view_name="users_list_api")
117
+ def users_view(request):
118
+ return HttpResponse("API Version 2 Users")
119
+ ```
120
+
121
+ In this example, the `users_view` function is decorated with the endpoint decorator. This specifies that the view is accessible under version `2` of the API and **supports backward compatibility**. The `backward=True` flag as default ensures that users can also access the previous version (version `1`) at `/api/v1/account_app/users`.
122
+
123
+ #### Example for Class-Based Views (CBVs):
124
+ For class-based views, you can apply the decorator to methods such as `get`, `post`, or any other HTTP method you need to handle. Here’s an example:
125
+
126
+ ```python
127
+
128
+ from django_api_versioning.decorators import endpoint
129
+ from django.http import JsonResponse
130
+ from django.views import View
131
+
132
+ @endpoint("users", version=2, app_name='account_app', view_name="users_list_api")
133
+ class UsersView(View):
134
+
135
+ def get(self, request):
136
+ return JsonResponse({"message": "API Version 2 Users"})
137
+
138
+ ```
139
+
140
+ #### Integration with Django Rest Framework (DRF):
141
+
142
+ If you have already installed [Django Rest Framework](https://www.django-rest-framework.org/#installation), the `endpoint` decorator can be easily applied to APIView or viewsets. Here’s an example with a DRF APIView:
143
+
144
+
145
+ ```python
146
+ from rest_framework.views import APIView
147
+ from rest_framework.response import Response
148
+ from django_api_versioning.decorators import endpoint
149
+
150
+ @endpoint("users", version=2, app_name='account_app', view_name="users_list_api")
151
+ class UsersAPIView(APIView):
152
+
153
+ def get(self, request):
154
+ return Response({"message": "API Version 2 Users"})
155
+ ```
156
+
157
+ #### URL Generation Based on Versioning:
158
+ Once the decorator is applied, the URLs for your API will be generated based on the version specified in the decorator. For example, if the `API_MIN_VERSION` in your settings.py is set to `1` and the version in the decorator is set to `2`, the following URLs will be available:
159
+
160
+ * `/api/v1/account_app/users`
161
+ * `/api/v2/account_app/users`
162
+
163
+ The `API_MIN_VERSION` setting ensures that users can access the API using different versions, providing backward compatibility. You can adjust which versions are considered valid by modifying the `API_MIN_VERSION` and `version` numbers in the decorators.
164
+
165
+ #### Additional Configuration Options:
166
+
167
+ **Without `app_name`:** If you don't pass `app_name` in the decorator, like this:
168
+ ```python
169
+ @endpoint("users", version=2, view_name="users_list_api")
170
+ ```
171
+
172
+ The generated URLs will be:
173
+
174
+ * `/api/v1/users`
175
+ * `/api/v2/users`
176
+
177
+
178
+ **Without `version`:** If you don't pass `version` in the decorator, like this:
179
+
180
+ ```python
181
+ @endpoint("users", view_name="users_list_api")
182
+ ```
183
+
184
+ API versioning will be disabled (`API_BASE_PATH` as prefix will be removed) for that view. The only URL generated will be:
185
+
186
+ * `/users`
187
+
188
+ **Setting `backward=False`:** By default, the `backward` parameter is set to `True`, which ensures backward compatibility. If you explicitly set `backward=False`, like this:
189
+
190
+ ```python
191
+ @endpoint("users", version=2, backward=False, view_name="users_list_api")
192
+ ```
193
+
194
+ The generated URL will be only version 2:
195
+
196
+ * `api/v2/users`
197
+
198
+ 4. Run the Server:
199
+
200
+ ```bash
201
+ python manage.py runserver
202
+ ```
203
+
204
+ ## Notes
205
+ ### 1. `API_BASE_PATH` in settings Must Include ‍‍`{version}`:
206
+ The `API_BASE_PATH` should always include `{version}` to ensure proper API versioning. This is important for correctly mapping API routes to different versions.
207
+
208
+ ### 2. Using `app_name` in the `endpoint` decorator:
209
+ It's recommended to fill in the `app_name` in the `endpoint` decorator to make the API URLs **more unique and organized**. This ensures that the routes are scoped under the correct app, avoiding potential conflicts and making them easier to manage.
210
+
211
+ ### 3. Views with Version Less Than `API_MIN_VERSION` Are Automatically Ignored:
212
+ Any view whose `version` is less than the `API_MIN_VERSION` will be automatically ignored. This means clients will no longer have access to these older versions, **without the need to manually edit or remove code**. This is handled automatically by the package.
213
+
214
+ ### 4. URLs for Versions Between `API_MIN_VERSION` <= `version` <= `API_MAX_VERSION`:
215
+ Endpoints that have versions within the range defined by `API_MIN_VERSION` <= `version` <= `API_MAX_VERSION` will always have a corresponding URL generated. This ensures that only valid versions will be accessible, providing flexibility in version management.
216
+
217
+ ### `endpoint` Decorator Function Definition
218
+
219
+ The `endpoint` decorator is designed to register API views with versioning support in a Django application. It provides flexibility in managing versioned endpoints and ensures backward compatibility with previous versions of the API.
220
+
221
+ ```python
222
+ def endpoint(
223
+ postfix: str,
224
+ version: Optional[int] = None,
225
+ backward: bool = True,
226
+ app_name: Optional[str] = None,
227
+ view_name: Optional[str] = None,
228
+ ) -> Callable:
229
+ """
230
+ Decorator to register API views with versioning support.
231
+
232
+ - Uses `API_MIN_VERSION` and `API_MAX_VERSION` from Django settings.
233
+ - Supports backward compatibility by registering multiple versions if needed.
234
+ - Ensures that no version lower than `API_MIN_VERSION` is registered.
235
+
236
+ Args:
237
+ postfix (str): The endpoint suffix (e.g., "users" → "api/v1/users").
238
+ version (Optional[int]): The version of the API. Defaults to None (unversioned).
239
+ backward (bool): If True, registers routes for all versions from `API_MIN_VERSION` up to the current version, which is less than or equal to `API_MAX_VERSION`. Defaults to True.
240
+ app_name (Optional[str]): The app name to be prefixed to the route.
241
+ view_name (Optional[str]): The custom view name for Django.
242
+
243
+ Returns:
244
+ Callable: The decorated view function.
245
+
246
+ Raises:
247
+ VersionTypeError: If the provided `version` is not an integer.
248
+ VersionRangeError: If `API_MIN_VERSION` or `API_MAX_VERSION` are not properly set.
249
+ """
250
+ ```
251
+
252
+
253
+ ## Contributing
254
+
255
+ Feel free to open an issue or submit a pull request with any improvements or bug fixes. We appreciate contributions to enhance this package!
256
+
257
+ ## License
258
+
259
+ This package is open-source and available under the MIT license.
260
+
261
+
@@ -0,0 +1,17 @@
1
+ django_api_versioning/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ django_api_versioning/decorators.py,sha256=BC_7ZQwCDq3BjPdcNw-Efdh09kHv3uDjZEW1nGgcNog,3732
3
+ django_api_versioning/exceptions.py,sha256=MgCpaNBsD8laQoVIVK823_t1liQ82K_uqguuA60PxXQ,485
4
+ django_api_versioning/registry.py,sha256=FCRTHGyl995U1kLlxtIBp3lb62v-6AbSK9k3orzZxWA,1140
5
+ django_api_versioning/settings.py,sha256=rasiYFuKM0EUYqzrpIphHGPEYb209uz56pEKR59R8w4,3006
6
+ django_api_versioning/urls.py,sha256=B8UBYSXdyq_xTpWeN5zzH2SQzKiqZQMx3WFBrsu93X0,144
7
+ tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ tests/test_decorators.py,sha256=G2G3PR_QAPNyJuaw7H9BMARSOK8C6GJlHRAL3KmTuCY,3587
9
+ tests/test_exceptions.py,sha256=fsyfA7ouIMqz6Emexqo_oB7oVEZlhqptNmwH8KX37s8,863
10
+ tests/test_registry.py,sha256=KQT6yWkfKZmcLShFkidHwfJfPi8yHN5i9xuzC3vqDso,2634
11
+ tests/test_settings.py,sha256=SjieOTnwHdU0E6A2-qLE3iXG5osY3qacbAzJJ7tqD-0,5213
12
+ tests/test_urls.py,sha256=DA8DbIEAYX812Re2PlTkL1OpkwCO2IZ1vW_QMh2d9nI,1207
13
+ django_api_versioning-0.1.0.dist-info/LICENSE,sha256=iDPJdze6sBlBBSoB-BIyT2iHfHDGUAaZG3nTFd6m2FQ,1070
14
+ django_api_versioning-0.1.0.dist-info/METADATA,sha256=B5McJrgZzySQghhKtqCvLMfBABI_TjgIFX8zHAnidS4,10436
15
+ django_api_versioning-0.1.0.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
16
+ django_api_versioning-0.1.0.dist-info/top_level.txt,sha256=F4n1zaE6P--9OytuMrvCD50vn7NvIVWkIl6ie9fsFck,28
17
+ django_api_versioning-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.45.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ django_api_versioning
2
+ tests
tests/__init__.py ADDED
File without changes
@@ -0,0 +1,88 @@
1
+ import pytest
2
+ from unittest.mock import patch
3
+ from django_api_versioning.settings import api_settings as settings
4
+ from django_api_versioning.registry import registry
5
+ from django_api_versioning.decorators import endpoint
6
+ from django_api_versioning.exceptions import InvalidVersionError, VersionTypeError, VersionRangeError
7
+
8
+ @pytest.fixture(autouse=True)
9
+ def clear_registered_routes():
10
+ """Clear the registry before each test to ensure isolation."""
11
+ registry.urlpatterns.clear() # Clear the previous routes before the test
12
+ yield # Run the test
13
+ registry.urlpatterns.clear() # Clean up after the test to ensure isolation
14
+
15
+ @pytest.fixture
16
+ def mock_settings():
17
+ """Fixture to mock settings for API versioning."""
18
+ with patch.object(settings, 'API_MIN_VERSION', 1), patch.object(settings, 'API_MAX_VERSION', 3):
19
+ yield settings
20
+
21
+ def test_version_below_minimum(mock_settings):
22
+ @endpoint("users", version=0)
23
+ def test_view():
24
+ pass
25
+
26
+ registered_routes = [str(p.pattern) for p in registry.urlpatterns]
27
+ assert "api/v0/users" not in registered_routes, f"Unexpected registered routes: {registered_routes}"
28
+
29
+ def test_version_above_maximum(mock_settings):
30
+ with pytest.raises(InvalidVersionError):
31
+ @endpoint("users", version=4)
32
+ def test_view():
33
+ pass
34
+
35
+ def test_invalid_version_type(mock_settings):
36
+ with pytest.raises(VersionTypeError):
37
+ @endpoint("users", version="invalid")
38
+ def test_view():
39
+ pass
40
+
41
+ def test_register_unversioned_route(mock_settings):
42
+ @endpoint("users")
43
+ def test_view():
44
+ pass
45
+
46
+ registered_routes = [str(p.pattern) for p in registry.urlpatterns]
47
+ # Assert that no versioned route (e.g., api/v1/users) is registered
48
+ for version in range(1, 4):
49
+ assert f"api/v{version}/users" not in registered_routes, f"Unexpected versioned route: api/v{version}/users"
50
+ # Assert that the unversioned route is registered
51
+ assert "users" in registered_routes, f"Missing unversioned route: {registered_routes}"
52
+
53
+ def test_backward_compatibility_enabled(mock_settings):
54
+ @endpoint("users", version=3)
55
+ def test_view():
56
+ pass
57
+
58
+ registered_routes = [str(p.pattern) for p in registry.urlpatterns]
59
+ # Assert that versions 1, 2, and 3 are registered for backward compatibility
60
+ for version in range(1, 4):
61
+ assert f"api/v{version}/users" in registered_routes, f"Missing route for v{version}: {registered_routes}"
62
+
63
+ def test_backward_compatibility_disabled(mock_settings):
64
+ @endpoint("users", version=2, backward=False)
65
+ def test_view():
66
+ pass
67
+
68
+ registered_routes = [str(p.pattern) for p in registry.urlpatterns]
69
+ # Assert that only version 2 is registered, and versions 1 and 3 are not
70
+ assert "api/v2/users" in registered_routes
71
+ assert "api/v1/users" not in registered_routes
72
+ assert "api/v3/users" not in registered_routes
73
+
74
+ def test_invalid_version_range(mock_settings):
75
+ # Set an invalid version range (min > max)
76
+ with patch.object(settings, 'API_MIN_VERSION', 4), patch.object(settings, 'API_MAX_VERSION', 3):
77
+ with pytest.raises(VersionRangeError):
78
+ @endpoint("users", version=2)
79
+ def test_view():
80
+ pass
81
+
82
+ def test_missing_api_version_settings():
83
+ # Remove API version settings to test for missing settings scenario
84
+ with patch.dict(settings.__dict__, {'API_MIN_VERSION': None, 'API_MAX_VERSION': None}):
85
+ with pytest.raises(VersionRangeError):
86
+ @endpoint("users", version=2)
87
+ def test_view():
88
+ pass
@@ -0,0 +1,19 @@
1
+ import pytest
2
+ from django_api_versioning.exceptions import VersioningError, InvalidVersionError, VersionRangeError, VersionTypeError
3
+
4
+ def test_versioning_error():
5
+ with pytest.raises(VersioningError, match="General versioning error"):
6
+ raise VersioningError("General versioning error")
7
+
8
+
9
+ def test_invalid_version_error():
10
+ with pytest.raises(InvalidVersionError, match="Invalid version specified"):
11
+ raise InvalidVersionError("Invalid version specified")
12
+
13
+ def test_version_range_error():
14
+ with pytest.raises(VersionRangeError, match="Invalid version range: min_version > max_version"):
15
+ raise VersionRangeError("Invalid version range: min_version > max_version")
16
+
17
+ def test_version_type_error():
18
+ with pytest.raises(VersionTypeError, match="Version must be an integer"):
19
+ raise VersionTypeError("Version must be an integer")
tests/test_registry.py ADDED
@@ -0,0 +1,83 @@
1
+ import pytest
2
+ from django_api_versioning.registry import registry
3
+
4
+
5
+ @pytest.fixture
6
+ def clear_registry():
7
+ """Clears the registry before each test to ensure isolation."""
8
+ registry.urlpatterns.clear()
9
+ yield
10
+ registry.urlpatterns.clear()
11
+
12
+
13
+ def test_register_new_route(clear_registry):
14
+ """Test that a new route is correctly registered."""
15
+ def sample_view():
16
+ pass
17
+
18
+ route = "api/v1/users"
19
+ registry.register(route, sample_view)
20
+
21
+ assert len(registry.urlpatterns) == 1
22
+ assert str(registry.urlpatterns[0].pattern) == route
23
+
24
+
25
+ def test_register_duplicate_route(clear_registry):
26
+ """Test that a duplicate route is detected and removed."""
27
+ def sample_view():
28
+ pass
29
+
30
+ route = "api/v1/users"
31
+ registry.register(route, sample_view)
32
+ registry.register(route, sample_view) # Registering the same route again
33
+
34
+ assert len(registry.urlpatterns) == 1 # Only one instance of the route should be registered
35
+ assert str(registry.urlpatterns[0].pattern) == route
36
+
37
+
38
+ def test_register_route_with_name(clear_registry):
39
+ """Test that a route is registered with a custom name."""
40
+ def sample_view():
41
+ pass
42
+
43
+ route = "api/v1/users"
44
+ route_name = "user-list"
45
+ registry.register(route, sample_view, name=route_name)
46
+
47
+ # Check if the route has the correct name
48
+ assert len(registry.urlpatterns) == 1
49
+ assert registry.urlpatterns[0].name == route_name
50
+ assert str(registry.urlpatterns[0].pattern) == route
51
+
52
+
53
+ def test_register_route_with_view_conversion(clear_registry):
54
+ """Test that a view is correctly converted to an 'as_view' callable if needed."""
55
+ class SampleView:
56
+ def as_view(self):
57
+ return lambda: None # Simulate view conversion
58
+
59
+ route = "api/v1/items"
60
+ view = SampleView()
61
+
62
+ registry.register(route, view)
63
+
64
+ # Check if the route is registered with the converted view
65
+ assert len(registry.urlpatterns) == 1
66
+ assert callable(registry.urlpatterns[0].callback) # Should be callable as a view
67
+ assert str(registry.urlpatterns[0].pattern) == route
68
+
69
+
70
+ def test_duplicate_route_removal(clear_registry):
71
+ """Test that duplicate routes are correctly removed."""
72
+ def sample_view():
73
+ pass
74
+
75
+ route1 = "api/v1/posts"
76
+ route2 = "api/v1/posts" # Duplicate route
77
+
78
+ registry.register(route1, sample_view)
79
+ registry.register(route2, sample_view) # Registering the same route again
80
+
81
+ # Check that only one route exists after attempting to register the duplicate
82
+ assert len(registry.urlpatterns) == 1
83
+ assert str(registry.urlpatterns[0].pattern) == route1
tests/test_settings.py ADDED
@@ -0,0 +1,147 @@
1
+ import pytest
2
+ from typing import Iterator
3
+ from django.conf import settings
4
+ from django.core.exceptions import ImproperlyConfigured
5
+ from django.test import override_settings
6
+ from django_api_versioning.settings import APISettings
7
+ from django_api_versioning.exceptions import VersioningError, VersionTypeError
8
+
9
+ @pytest.fixture(autouse=True)
10
+ def reset_settings() -> Iterator[None]:
11
+ """
12
+ Automatically resets Django settings to their default values after each test.
13
+ This prevents state leakage between tests.
14
+ """
15
+ settings.API_BASE_PATH = "api/v{version}/"
16
+ settings.API_MAX_VERSION = 1
17
+ settings.API_MIN_VERSION = 1
18
+ settings.ROOT_URLCONF = "django_api_versioning.urls"
19
+ yield # Run the test
20
+ # Reset settings after test execution
21
+ settings.API_BASE_PATH = "api/v{version}/"
22
+ settings.API_MAX_VERSION = 1
23
+ settings.API_MIN_VERSION = 1
24
+ settings.ROOT_URLCONF = "django_api_versioning.urls"
25
+
26
+ @override_settings(
27
+ API_BASE_PATH="api/v{version}/",
28
+ API_MAX_VERSION=1,
29
+ API_MIN_VERSION=1,
30
+ ROOT_URLCONF="django_api_versioning.urls",
31
+ )
32
+ def test_default_settings() -> None:
33
+ """
34
+ Verifies that default settings are correctly applied when using APISettings.
35
+ """
36
+ api_settings = APISettings(
37
+ API_BASE_PATH=settings.API_BASE_PATH,
38
+ API_MAX_VERSION=settings.API_MAX_VERSION,
39
+ API_MIN_VERSION=settings.API_MIN_VERSION,
40
+ ROOT_URLCONF=settings.ROOT_URLCONF,
41
+ )
42
+
43
+ assert api_settings.API_BASE_PATH == "api/v{version}/"
44
+ assert api_settings.API_MAX_VERSION == 1
45
+ assert api_settings.API_MIN_VERSION == 1
46
+ assert api_settings.ROOT_URLCONF == "django_api_versioning.urls"
47
+
48
+ @override_settings(
49
+ API_BASE_PATH="api/v{version}/custom/",
50
+ API_MAX_VERSION=2,
51
+ API_MIN_VERSION=1,
52
+ ROOT_URLCONF="my_custom_urls",
53
+ )
54
+ def test_custom_settings() -> None:
55
+ """
56
+ Ensures that custom settings override the default values.
57
+ """
58
+ api_settings = APISettings(
59
+ API_BASE_PATH=settings.API_BASE_PATH,
60
+ API_MAX_VERSION=settings.API_MAX_VERSION,
61
+ API_MIN_VERSION=settings.API_MIN_VERSION,
62
+ ROOT_URLCONF=settings.ROOT_URLCONF,
63
+ )
64
+
65
+ assert api_settings.API_BASE_PATH == "api/v{version}/custom/"
66
+ assert api_settings.API_MAX_VERSION == 2
67
+ assert api_settings.API_MIN_VERSION == 1
68
+ assert api_settings.ROOT_URLCONF == "my_custom_urls"
69
+
70
+ @override_settings(API_BASE_PATH="api/v1/")
71
+ def test_invalid_api_base_path() -> None:
72
+ """
73
+ Ensures that API_BASE_PATH without '{version}' raises a ValueError.
74
+ """
75
+ with pytest.raises(VersioningError, match="API_BASE_PATH must contain '{version}'"):
76
+ APISettings(
77
+ API_BASE_PATH=settings.API_BASE_PATH,
78
+ API_MAX_VERSION=1,
79
+ API_MIN_VERSION=1,
80
+ ROOT_URLCONF="django_api_versioning.urls",
81
+ )
82
+
83
+ @override_settings(API_MIN_VERSION=2, API_MAX_VERSION=1)
84
+ def test_invalid_version_range() -> None:
85
+ """
86
+ Ensures that API_MIN_VERSION > API_MAX_VERSION raises a ValueError.
87
+ """
88
+ with pytest.raises(VersioningError, match="API_MIN_VERSION cannot be greater than API_MAX_VERSION"):
89
+ APISettings(
90
+ API_BASE_PATH="api/v{version}/",
91
+ API_MAX_VERSION=settings.API_MAX_VERSION,
92
+ API_MIN_VERSION=settings.API_MIN_VERSION,
93
+ ROOT_URLCONF="django_api_versioning.urls",
94
+ )
95
+
96
+ @override_settings(API_MIN_VERSION="one")
97
+ def test_api_min_version_is_not_integer() -> None:
98
+ """
99
+ Ensures that API_MIN_VERSION is validated as an integer.
100
+ """
101
+ with pytest.raises(VersionTypeError, match="API_MIN_VERSION must be an integer"):
102
+ APISettings(
103
+ API_BASE_PATH="api/v{version}/",
104
+ API_MAX_VERSION=1,
105
+ API_MIN_VERSION=settings.API_MIN_VERSION,
106
+ ROOT_URLCONF="django_api_versioning.urls",
107
+ )
108
+
109
+ @override_settings(API_MAX_VERSION="two")
110
+ def test_api_max_version_is_not_integer() -> None:
111
+ """
112
+ Ensures that API_MAX_VERSION is validated as an integer.
113
+ """
114
+ with pytest.raises(VersionTypeError, match="API_MAX_VERSION must be an integer"):
115
+ APISettings(
116
+ API_BASE_PATH="api/v{version}/",
117
+ API_MAX_VERSION=settings.API_MAX_VERSION,
118
+ API_MIN_VERSION=1,
119
+ ROOT_URLCONF="django_api_versioning.urls",
120
+ )
121
+
122
+ @override_settings(API_BASE_PATH="api/v{version}")
123
+ def test_validate_version_path_format() -> None:
124
+ """
125
+ Ensures that API_BASE_PATH automatically ends with '/' if not already present.
126
+ """
127
+ api_settings = APISettings(
128
+ API_BASE_PATH=settings.API_BASE_PATH,
129
+ API_MAX_VERSION=1,
130
+ API_MIN_VERSION=1,
131
+ ROOT_URLCONF="django_api_versioning.urls",
132
+ )
133
+
134
+ assert api_settings.API_BASE_PATH == "api/v{version}/"
135
+
136
+ @override_settings(ROOT_URLCONF=None)
137
+ def test_missing_root_urlconf() -> None:
138
+ """
139
+ Ensures that missing ROOT_URLCONF raises an ImproperlyConfigured exception.
140
+ """
141
+ with pytest.raises(ImproperlyConfigured, match="ROOT_URLCONF is required"):
142
+ APISettings(
143
+ API_BASE_PATH="api/v{version}/",
144
+ API_MAX_VERSION=1,
145
+ API_MIN_VERSION=1,
146
+ ROOT_URLCONF=settings.ROOT_URLCONF, # None
147
+ )
tests/test_urls.py ADDED
@@ -0,0 +1,33 @@
1
+ import pytest
2
+ from django.urls import URLPattern, resolve
3
+
4
+
5
+ @pytest.fixture(scope="module", autouse=True)
6
+ def clear_urlpatterns():
7
+ """Ensure urlpatterns is cleared before each test."""
8
+ from django_api_versioning.urls import urlpatterns
9
+ urlpatterns.clear() # Clear any existing patterns
10
+ yield
11
+
12
+ @pytest.fixture(scope="module")
13
+ def register_routes():
14
+ from django_api_versioning.decorators import endpoint
15
+
16
+ # Registering the accounts route with the endpoint decorator
17
+ @endpoint("accounts" , view_name="accounts_view")
18
+ def test_accounts():
19
+ pass
20
+
21
+ yield
22
+
23
+ def test_urlpatterns_type():
24
+ """Test to check that urlpatterns is a list of URLPattern."""
25
+ from django_api_versioning.urls import urlpatterns
26
+
27
+ assert isinstance(urlpatterns, list), "urlpatterns should be a list."
28
+ assert all(isinstance(pattern, URLPattern) for pattern in urlpatterns), "All elements in urlpatterns should be of type URLPattern."
29
+
30
+ def test_registry_urlpatterns(register_routes):
31
+ """Test to check that the 'accounts' route resolves correctly."""
32
+ match = resolve('/accounts')
33
+ assert match.view_name == 'accounts_view', f"Expected 'accounts_view', but got {match.view_name}"