Repository: nautobot/nautobot

Issue:
Default Nautobot API Version
### As ...

Austin - Network Automation Engineer

### I want ...

Use the latest features that are offered by the API

### So that ...

* I may get maximum benefit from the latest features.  
* Know when there are small breaking changes vs only when there are major breaking changes

### I know this is done when...

Without setting a header, I get the latest API functionality given I am using the latest version of code.

### Optional - Feature groups this request pertains to.

- [ ] Automation
- [ ] Circuits
- [ ] DCIM
- [ ] IPAM
- [ ] Misc (including Data Sources)
- [ ] Organization
- [ ] Plugins (and other Extensibility)
- [ ] Security (Secrets, etc)
- [ ] Image Management
- [ ] UI/UX
- [ ] Documentation
- [ ] Other (not directly a platform feature)

### Database Changes

N/A

### External Dependencies

_No response_


### TODO

- Verify DRF's default behavior when `DEFAULT_VERSION` is `None`, expecting it chooses the latest
- Remove `DEFAULT_VERSION` from `settings.py`
- Make it easier via config and documentation to override "default to latest" behavior


Discussion:
Allowing a "magic" setting of `latest` as well as a specific version to be provided here: https://github.com/nautobot/nautobot/blob/develop/nautobot/core/settings.py#L195 should suffice. We should by default still fall back to the oldest minor version if not provided.

Resolving patch:
diff --git a/nautobot/core/api/versioning.py b/nautobot/core/api/versioning.py
--- a/nautobot/core/api/versioning.py
+++ b/nautobot/core/api/versioning.py
@@ -1,8 +1,9 @@
-from django.conf import settings
+from django.utils.functional import classproperty
 from django.utils.translation import gettext_lazy as _
 from rest_framework import exceptions
 from rest_framework.compat import unicode_http_header
 from rest_framework.utils.mediatypes import _MediaType
+from rest_framework.settings import api_settings
 from rest_framework.versioning import AcceptHeaderVersioning
 
 
@@ -14,16 +15,31 @@ class APIVersionMismatch(exceptions.APIException):
 class NautobotAPIVersioning(AcceptHeaderVersioning):
     """Support both accept-header versioning and query-parameter versioning as options."""
 
-    invalid_version_in_header = _('Invalid version in "Accept" header. Supported versions are %(versions)s') % {
-        "versions": ", ".join(settings.REST_FRAMEWORK["ALLOWED_VERSIONS"])
-    }
-    invalid_version_in_query = _("Invalid version in query parameter. Supported versions are %(versions)s") % {
-        "versions": ", ".join(settings.REST_FRAMEWORK["ALLOWED_VERSIONS"])
-    }
-
     header_version_param = "version"  # staying consistent with pre-1.3 versions of Nautobot
     query_version_param = "api_version"  # to avoid ambiguity with filtersets potentially including a "version" param
 
+    @classproperty  # https://github.com/PyCQA/pylint-django/issues/240
+    def allowed_versions(cls):  # pylint: disable=no-self-argument
+        """
+        List of version strings that are accepted by this class, based on api_settings.ALLOWED_VERSIONS.
+
+        The base `AcceptHeaderVersioning.allowed_versions` is a class property that is set at import time,
+        which means that things like `@override_settings` in tests happen "too late" to change it.
+        By re-implementing it as a property, we bypass that problem.
+        """
+        return api_settings.ALLOWED_VERSIONS
+
+    @classproperty  # https://github.com/PyCQA/pylint-django/issues/240
+    def default_version(cls):  # pylint: disable=no-self-argument
+        """
+        Default version number to use if unspecified by the requester, based on api_settings.DEFAULT_VERSION.
+
+        The base `AcceptHeaderVersioning.default_version` is a class property that is set at import time,
+        which means that things like `@override_settings` in tests happen "too late" to change it.
+        By re-implementing it as a property, we bypass that problem.
+        """
+        return api_settings.DEFAULT_VERSION
+
     def determine_version(self, request, *args, **kwargs):
         """Use either Accept header or query parameter for versioning."""
 
@@ -40,10 +56,16 @@ def determine_version(self, request, *args, **kwargs):
             raise APIVersionMismatch()
         if header_version is not None and not self.is_allowed_version(header_version):
             # Behave like AcceptHeaderVersioning
-            raise exceptions.NotAcceptable(self.invalid_version_in_header)
+            raise exceptions.NotAcceptable(
+                f'Invalid version "{header_version}" in "Accept" header. Supported versions are: '
+                + ", ".join(self.allowed_versions)
+            )
         if query_version is not None and not self.is_allowed_version(query_version):
             # Behave like QueryParameterVersioning
-            raise exceptions.NotFound(self.invalid_version_in_query)
+            raise exceptions.NotFound(
+                f'Invalid version "{query_version}" in query parameter. Supported versions are: '
+                + ", ".join(self.allowed_versions)
+            )
         version = header_version or query_version or self.default_version
         # self.default_version is always allowed, no need to re-check is_allowed_version here
         return version
diff --git a/nautobot/core/settings.py b/nautobot/core/settings.py
--- a/nautobot/core/settings.py
+++ b/nautobot/core/settings.py
@@ -13,7 +13,7 @@
 # Environment setup
 #
 
-# This is used for display in the UI.
+# This is used for display in the UI. There are also VERSION_MAJOR and VERSION_MINOR derived from this later.
 VERSION = __version__
 
 # Hostname of the system. This is displayed in the web UI footers along with the
@@ -169,11 +169,11 @@
 #
 
 REST_FRAMEWORK_VERSION = VERSION.rsplit(".", 1)[0]  # Use major.minor as API version
-current_major, current_minor = REST_FRAMEWORK_VERSION.split(".")
+VERSION_MAJOR, VERSION_MINOR = [int(v) for v in REST_FRAMEWORK_VERSION.split(".")]
 # We support all major.minor API versions from 2.0 to the present latest version.
 # Similar logic exists in tasks.py, please keep them in sync!
-assert current_major == "2", f"REST_FRAMEWORK_ALLOWED_VERSIONS needs to be updated to handle version {current_major}"
-REST_FRAMEWORK_ALLOWED_VERSIONS = [f"{current_major}.{minor}" for minor in range(0, int(current_minor) + 1)]
+assert VERSION_MAJOR == 2, f"REST_FRAMEWORK_ALLOWED_VERSIONS needs to be updated to handle version {VERSION_MAJOR}"
+REST_FRAMEWORK_ALLOWED_VERSIONS = [f"{VERSION_MAJOR}.{minor}" for minor in range(0, VERSION_MINOR + 1)]
 
 REST_FRAMEWORK = {
     "ALLOWED_VERSIONS": REST_FRAMEWORK_ALLOWED_VERSIONS,
@@ -198,9 +198,8 @@
         "nautobot.core.api.parsers.NautobotCSVParser",
     ),
     "DEFAULT_SCHEMA_CLASS": "nautobot.core.api.schema.NautobotAutoSchema",
-    # Version to use if the client doesn't request otherwise.
-    # This should only change (if at all) with Nautobot major (breaking) releases.
-    "DEFAULT_VERSION": "2.0",
+    # Version to use if the client doesn't request otherwise. Default to current (i.e. latest)
+    "DEFAULT_VERSION": REST_FRAMEWORK_VERSION,
     "DEFAULT_VERSIONING_CLASS": "nautobot.core.api.versioning.NautobotAPIVersioning",
     "ORDERING_PARAM": "sort",  # This is not meant to be changed by users, but is used internally by the API
     "PAGE_SIZE": None,
diff --git a/tasks.py b/tasks.py
--- a/tasks.py
+++ b/tasks.py
@@ -651,9 +651,9 @@ def check_schema(context, api_version=None):
     else:
         nautobot_version = get_nautobot_version()
         # logic equivalent to nautobot.core.settings REST_FRAMEWORK_ALLOWED_VERSIONS - keep them in sync!
-        current_major, current_minor = nautobot_version.split(".")[:2]
-        assert current_major == "2", f"check_schemas version calc must be updated to handle version {current_major}"
-        api_versions = [f"{current_major}.{minor}" for minor in range(0, int(current_minor) + 1)]
+        current_major, current_minor = [int(v) for v in nautobot_version.split(".")[:2]]
+        assert current_major == 2, f"check_schemas version calc must be updated to handle version {current_major}"
+        api_versions = [f"{current_major}.{minor}" for minor in range(0, current_minor + 1)]
 
     for api_vers in api_versions:
         command = f"nautobot-server spectacular --api-version {api_vers} --validate --fail-on-warn --file /dev/null"


Test patch:
diff --git a/nautobot/core/testing/schema.py b/nautobot/core/testing/schema.py
--- a/nautobot/core/testing/schema.py
+++ b/nautobot/core/testing/schema.py
@@ -4,6 +4,7 @@
 from django.conf import settings
 from django.core.management import call_command
 from django.test import tag
+from rest_framework.settings import api_settings
 
 from nautobot.core.testing import views
 
@@ -18,7 +19,7 @@ def setUpTestData(cls):
             # We could load the schema from the /api/swagger.yaml endpoint in setUp(self) via self.client,
             # but it's fairly expensive to do so. Better to do so only once per class.
             cls.schemas = {}
-            for api_version in settings.REST_FRAMEWORK_ALLOWED_VERSIONS:
+            for api_version in api_settings.ALLOWED_VERSIONS:
                 out = StringIO()
                 err = StringIO()
                 call_command("spectacular", "--api-version", api_version, stdout=out, stderr=err)
diff --git a/nautobot/core/tests/test_api.py b/nautobot/core/tests/test_api.py
--- a/nautobot/core/tests/test_api.py
+++ b/nautobot/core/tests/test_api.py
@@ -1,3 +1,4 @@
+from copy import deepcopy
 import csv
 from io import BytesIO, StringIO
 import json
@@ -11,11 +12,13 @@
 from constance.test import override_config
 from rest_framework import status
 from rest_framework.exceptions import ParseError
+from rest_framework.settings import api_settings
 
 from nautobot.circuits.models import Provider
 from nautobot.core import testing
 from nautobot.core.api.parsers import NautobotCSVParser
 from nautobot.core.api.renderers import NautobotCSVRenderer
+from nautobot.core.api.versioning import NautobotAPIVersioning
 from nautobot.dcim import models as dcim_models
 from nautobot.dcim.api import serializers as dcim_serializers
 from nautobot.extras import choices
@@ -178,27 +181,61 @@ class APIVersioningTestCase(testing.APITestCase):
     Testing our custom API versioning, NautobotAPIVersioning.
     """
 
+    OVERRIDE_REST_FRAMEWORK = deepcopy(settings.REST_FRAMEWORK)
+    # For Nautobot 2.0, the default/only supported API version is 2.0, which limits our ability to test with multiple
+    # or min/max versions. For test purposes we force there to be some extra versions available.
+    EXTRA_ALLOWED_VERSIONS = [
+        f"{settings.VERSION_MAJOR - 1}.99",
+        *settings.REST_FRAMEWORK["ALLOWED_VERSIONS"],
+        f"{settings.VERSION_MAJOR}.{settings.VERSION_MINOR + 1}",
+    ]
+    OVERRIDE_REST_FRAMEWORK["ALLOWED_VERSIONS"] = EXTRA_ALLOWED_VERSIONS
+    OVERRIDE_REST_FRAMEWORK["DEFAULT_VERSION"] = EXTRA_ALLOWED_VERSIONS[-1]
+
     def test_default_version(self):
-        """Test that a request with no specific API version gets the default version."""
+        """Test that a request with no specific API version gets the default version, which is the current version."""
         url = reverse("api-root")
         response = self.client.get(url, **self.header)
         self.assertHttpStatus(response, 200)
         self.assertIn("API-Version", response)
-        self.assertEqual(response["API-Version"], settings.REST_FRAMEWORK["DEFAULT_VERSION"])
+        self.assertEqual(response["API-Version"], api_settings.DEFAULT_VERSION)
+        self.assertEqual(response["API-Version"], f"{settings.VERSION_MAJOR}.{settings.VERSION_MINOR}")
+
+        with override_settings(REST_FRAMEWORK=self.OVERRIDE_REST_FRAMEWORK):
+            response = self.client.get(url, **self.header)
+            self.assertHttpStatus(response, 200)
+            self.assertIn("API-Version", response)
+            self.assertEqual(response["API-Version"], self.OVERRIDE_REST_FRAMEWORK["DEFAULT_VERSION"])
+            self.assertEqual(response["API-Version"], f"{settings.VERSION_MAJOR}.{settings.VERSION_MINOR + 1}")
+
+    def test_allowed_versions(self):
+        """Test that all expected versions are supported."""
+        for minor_version in range(0, settings.VERSION_MINOR + 1):
+            version = f"{settings.VERSION_MAJOR}.{minor_version}"
+            self.assertIn(version, settings.REST_FRAMEWORK["ALLOWED_VERSIONS"])
+            self.assertIn(version, api_settings.ALLOWED_VERSIONS)
+            self.assertIn(version, NautobotAPIVersioning.allowed_versions)
 
     def test_header_version(self):
         """Test that the API version can be specified via the HTTP Accept header."""
         url = reverse("api-root")
 
-        min_version = settings.REST_FRAMEWORK["ALLOWED_VERSIONS"][0]
+        min_version = api_settings.ALLOWED_VERSIONS[0]
         self.set_api_version(min_version)
         response = self.client.get(url, **self.header)
         self.assertHttpStatus(response, 200)
         self.assertIn("API-Version", response)
         self.assertEqual(response["API-Version"], min_version)
 
-        max_version = settings.REST_FRAMEWORK["ALLOWED_VERSIONS"][-1]
-        self.set_api_version(max_version)
+        default_version = api_settings.DEFAULT_VERSION
+        self.set_api_version(default_version)
+        response = self.client.get(url, **self.header)
+        self.assertHttpStatus(response, 200)
+        self.assertIn("API-Version", response)
+        self.assertEqual(response["API-Version"], default_version)
+
+        max_version = api_settings.ALLOWED_VERSIONS[-1]
+        self.set_api_version(min_version)
         response = self.client.get(url, **self.header)
         self.assertHttpStatus(response, 200)
         self.assertIn("API-Version", response)
@@ -208,20 +245,49 @@ def test_header_version(self):
         self.set_api_version(invalid_version)
         response = self.client.get(url, **self.header)
         self.assertHttpStatus(response, 406)  # Not Acceptable
-        for version in settings.REST_FRAMEWORK["ALLOWED_VERSIONS"]:
+        for version in api_settings.ALLOWED_VERSIONS:
             self.assertIn(version, response.data["detail"])
 
+        # Also test with explicitly added additional allowed versions
+        with override_settings(REST_FRAMEWORK=self.OVERRIDE_REST_FRAMEWORK):
+            min_version = self.EXTRA_ALLOWED_VERSIONS[0]
+            self.set_api_version(min_version)
+            response = self.client.get(url, **self.header)
+            self.assertHttpStatus(response, 200)
+            self.assertIn("API-Version", response)
+            self.assertEqual(response["API-Version"], min_version)
+
+            max_version = self.EXTRA_ALLOWED_VERSIONS[-1]
+            self.set_api_version(max_version)
+            response = self.client.get(url, **self.header)
+            self.assertHttpStatus(response, 200)
+            self.assertIn("API-Version", response)
+            self.assertEqual(response["API-Version"], max_version)
+
+            invalid_version = "0.0"
+            self.set_api_version(invalid_version)
+            response = self.client.get(url, **self.header)
+            self.assertHttpStatus(response, 406)  # Not Acceptable
+            for version in self.EXTRA_ALLOWED_VERSIONS:
+                self.assertIn(version, response.data["detail"])
+
     def test_query_version(self):
         """Test that the API version can be specified via a query parameter."""
         url = reverse("api-root")
 
-        min_version = settings.REST_FRAMEWORK["ALLOWED_VERSIONS"][0]
+        min_version = api_settings.ALLOWED_VERSIONS[0]
         response = self.client.get(f"{url}?api_version={min_version}", **self.header)
         self.assertHttpStatus(response, 200)
         self.assertIn("API-Version", response)
         self.assertEqual(response["API-Version"], min_version)
 
-        max_version = settings.REST_FRAMEWORK["ALLOWED_VERSIONS"][-1]
+        default_version = api_settings.DEFAULT_VERSION
+        response = self.client.get(f"{url}?api_version={default_version}", **self.header)
+        self.assertHttpStatus(response, 200)
+        self.assertIn("API-Version", response)
+        self.assertEqual(response["API-Version"], default_version)
+
+        max_version = api_settings.ALLOWED_VERSIONS[-1]
         response = self.client.get(f"{url}?api_version={max_version}", **self.header)
         self.assertHttpStatus(response, 200)
         self.assertIn("API-Version", response)
@@ -230,15 +296,36 @@ def test_query_version(self):
         invalid_version = "0.0"
         response = self.client.get(f"{url}?api_version={invalid_version}", **self.header)
         self.assertHttpStatus(response, 404)
-        for version in settings.REST_FRAMEWORK["ALLOWED_VERSIONS"]:
+        for version in api_settings.ALLOWED_VERSIONS:
             self.assertIn(version, response.data["detail"])
 
+        # Also test with explicitly added additional allowed versions
+        with override_settings(REST_FRAMEWORK=self.OVERRIDE_REST_FRAMEWORK):
+            min_version = self.EXTRA_ALLOWED_VERSIONS[0]
+            response = self.client.get(f"{url}?api_version={min_version}", **self.header)
+            self.assertHttpStatus(response, 200)
+            self.assertIn("API-Version", response)
+            self.assertEqual(response["API-Version"], min_version)
+
+            max_version = self.EXTRA_ALLOWED_VERSIONS[-1]
+            response = self.client.get(f"{url}?api_version={max_version}", **self.header)
+            self.assertHttpStatus(response, 200)
+            self.assertIn("API-Version", response)
+            self.assertEqual(response["API-Version"], max_version)
+
+            invalid_version = "0.0"
+            response = self.client.get(f"{url}?api_version={invalid_version}", **self.header)
+            self.assertHttpStatus(response, 404)
+            for version in self.EXTRA_ALLOWED_VERSIONS:
+                self.assertIn(version, response.data["detail"])
+
+    @override_settings(REST_FRAMEWORK=OVERRIDE_REST_FRAMEWORK)
     def test_header_and_query_version(self):
         """Test the behavior when the API version is specified in both the Accept header *and* a query parameter."""
         url = reverse("api-root")
 
-        min_version = settings.REST_FRAMEWORK["ALLOWED_VERSIONS"][0]
-        max_version = settings.REST_FRAMEWORK["ALLOWED_VERSIONS"][-1]
+        min_version = self.EXTRA_ALLOWED_VERSIONS[0]
+        max_version = self.EXTRA_ALLOWED_VERSIONS[-1]
         # Specify same version both as Accept header and as query parameter (valid)
         self.set_api_version(max_version)
         response = self.client.get(f"{url}?api_version={max_version}", **self.header)
@@ -246,11 +333,10 @@ def test_header_and_query_version(self):
         self.assertIn("API-Version", response)
         self.assertEqual(response["API-Version"], max_version)
 
-        if min_version != max_version:
-            # Specify different versions in Accept header and query parameter (invalid)
-            response = self.client.get(f"{url}?api_version={min_version}", **self.header)
-            self.assertHttpStatus(response, 400)
-            self.assertIn("Version mismatch", response.data["detail"])
+        # Specify different versions in Accept header and query parameter (invalid)
+        response = self.client.get(f"{url}?api_version={min_version}", **self.header)
+        self.assertHttpStatus(response, 400)
+        self.assertIn("Version mismatch", response.data["detail"])
 
 
 class LookupTypeChoicesTestCase(testing.APITestCase):