From 23dc4226d4b3d2deaa1616d9321dfc6f1703eefe Mon Sep 17 00:00:00 2001
From: Emmanuel Cazenave <ecazenave@entrouvert.com>
Date: Tue, 18 Oct 2022 16:56:07 +0200
Subject: [PATCH] base: add api_version mechanism (#70425)

---
 debian/control                 |  1 +
 passerelle/base/models.py      |  6 +++++
 passerelle/utils/api.py        |  2 ++
 passerelle/utils/validation.py |  9 +++++++
 passerelle/views.py            |  4 +++
 setup.py                       |  1 +
 tests/test_generic_endpoint.py | 47 ++++++++++++++++++++++++++++++++++
 7 files changed, 70 insertions(+)

diff --git a/debian/control b/debian/control
index 6d4131e0..f46b5551 100644
--- a/debian/control
+++ b/debian/control
@@ -24,6 +24,7 @@ Depends: python3-cmislib,
          python3-jsonschema,
          python3-levenshtein,
          python3-lxml,
+         python3-packaging,
          python3-pdfrw,
          python3-pil,
          python3-pycryptodome,
diff --git a/passerelle/base/models.py b/passerelle/base/models.py
index 892dcc56..68cb33e4 100644
--- a/passerelle/base/models.py
+++ b/passerelle/base/models.py
@@ -40,6 +40,7 @@ from passerelle.utils import ImportSiteError
 from passerelle.utils.api import endpoint
 from passerelle.utils.jsonresponse import APIError
 from passerelle.utils.sftp import SFTP, SFTPField
+from passerelle.utils.validation import version_match
 
 KEYTYPE_CHOICES = (
     ('API', _('API Key')),
@@ -145,6 +146,7 @@ class BaseResource(models.Model):
     manager_view_template_name = None
     manager_form_base_class = GenericConnectorForm
     hide_description_fields = []
+    api_version = None
 
     # permission descriptions
     _can_access_description = _('Access is limited to the following API users:')
@@ -284,6 +286,10 @@ class BaseResource(models.Model):
                 if endpoint_name == 'up' and hasattr(self.check_status, 'not_implemented'):
                     # hide automatic up endpoint if check_status method is not implemented
                     continue
+                if self.api_version is not None and method.endpoint_info.api_version is not None:
+                    if not version_match(self.api_version, method.endpoint_info.api_version):
+                        continue
+
                 for http_method in method.endpoint_info.methods:
                     # duplicate information to give each method its own entry
                     endpoint_info = copy.copy(method.endpoint_info)
diff --git a/passerelle/utils/api.py b/passerelle/utils/api.py
index 28c188e1..90cd5641 100644
--- a/passerelle/utils/api.py
+++ b/passerelle/utils/api.py
@@ -59,6 +59,7 @@ class endpoint:
         datasource=False,
         # helper to define the POST json schema
         post_json_schema=None,
+        api_version=None,
     ):
         self.perm = perm
         self.methods = methods or ['get']
@@ -103,6 +104,7 @@ class endpoint:
         if json_schema_response:
             self.response_schemas['application/json'] = json_schema_response
         self.datasource = datasource
+        self.api_version = api_version
 
     def __call__(self, func):
         func.endpoint_info = self
diff --git a/passerelle/utils/validation.py b/passerelle/utils/validation.py
index 6988c3a3..6080e9c8 100644
--- a/passerelle/utils/validation.py
+++ b/passerelle/utils/validation.py
@@ -14,9 +14,18 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+from packaging.specifiers import SpecifierSet
+from packaging.version import Version
+
 
 def is_number(string):
     if hasattr(string, 'isdecimal'):
         return string.isdecimal() and [ord(c) < 256 for c in string]
     else:  # str PY2
         return string.isdigit()
+
+
+def version_match(api_version, api_version_spec):
+    api_version = Version(api_version)
+    api_version_spec = SpecifierSet(api_version_spec)
+    return api_version in api_version_spec
diff --git a/passerelle/views.py b/passerelle/views.py
index 5ebb24f2..32983255 100644
--- a/passerelle/views.py
+++ b/passerelle/views.py
@@ -56,6 +56,7 @@ from passerelle.utils.conversion import normalize
 from passerelle.utils.json import unflatten
 from passerelle.utils.jsonresponse import APIError, JSONEncoder
 from passerelle.utils.paginator import InfinitePaginator
+from passerelle.utils.validation import version_match
 
 from .forms import ResourceLogSearchForm
 from .utils import is_authorized, to_json
@@ -429,6 +430,9 @@ class GenericEndpointView(GenericConnectorMixin, SingleObjectMixin, View):
                 continue
             if not method.endpoint_info.name == kwargs.get('endpoint'):
                 continue
+            if self.connector.api_version is not None and method.endpoint_info.api_version is not None:
+                if not version_match(self.connector.api_version, method.endpoint_info.api_version):
+                    continue
             if method.endpoint_info.pattern:
                 pattern = re_path(method.endpoint_info.pattern, method)
                 match = pattern.resolve(kwargs.get('rest') or '')
diff --git a/setup.py b/setup.py
index 8f5c347c..9aa905ef 100755
--- a/setup.py
+++ b/setup.py
@@ -165,6 +165,7 @@ setup(
         'pytz',
         'vobject',
         'Levenshtein',
+        'packaging',
         'python-ldap',
         'pyOpenSSL',
         'roman',
diff --git a/tests/test_generic_endpoint.py b/tests/test_generic_endpoint.py
index 4d43d8a7..c90208cb 100644
--- a/tests/test_generic_endpoint.py
+++ b/tests/test_generic_endpoint.py
@@ -349,6 +349,7 @@ def test_endpoint_decorator():
 class FakeJSONConnector:
     slug = 'connector-json'
     log_level = 'DEBUG'
+    api_version = None
 
     FOO_SCHEMA = {
         'properties': {
@@ -448,6 +449,7 @@ def test_endpoint_decorator_pre_process(db, app):
 class FakeConnectorDatasource:
     slug = 'connector-datasource'
     log_level = 'DEBUG'
+    api_version = None
 
     payload = {
         'data': [
@@ -1051,3 +1053,48 @@ def test_view_connector(db, app, monkeypatch, admin_user):
     with patch_init, patch_object:
         response = app.get(url)
         assert len(response.pyquery('#description')) == 1
+
+
+class FakeAPIVersionConnector(DummyConnectorBase):
+    def get_connector_slug(self):
+        return 'connector-api-version'
+
+    @endpoint(api_version='>2')
+    # pylint: disable=disallowed-name
+    def foo(self, request):
+        return {'data': 'foo'}
+
+
+def test_endpoint_decorator_api_version(db, app):
+    connector = FakeAPIVersionConnector()
+    connector.id = 33
+
+    patch_init = mock.patch('passerelle.views.GenericConnectorMixin.init_stuff')
+    patch_object = mock.patch('passerelle.views.GenericEndpointView.get_object', return_value=connector)
+
+    url_foo = reverse(
+        'generic-endpoint',
+        kwargs={
+            'connector': 'connector-api-version',
+            'slug': 'connector-api-version',
+            'endpoint': 'foo',
+        },
+    )
+    connector.api_version = '3'
+    with patch_init, patch_object:
+        resp = app.get(url_foo)
+    assert resp.json['err'] == 0
+    assert resp.json['data'] == 'foo'
+
+    connector.api_version = '2'
+    with patch_init, patch_object:
+        resp = app.get(url_foo, status=404)
+
+
+def test_endpoints_infos_api_version(db, app):
+    connector = FakeAPIVersionConnector()
+    assert len(connector.get_endpoints_infos()) == 1
+    connector.api_version = '3'
+    assert len(connector.get_endpoints_infos()) == 1
+    connector.api_version = '2'
+    assert len(connector.get_endpoints_infos()) == 0
-- 
2.30.2

