From 2e6c9851b959d059758addeb20010b748ad1506d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Klopfenstein?= Date: Tue, 30 Dec 2025 13:23:19 +0100 Subject: [PATCH 1/2] Flask ApiModel for Models class definition under the shebang without Auth already setup. --- .../orm/deferred_auth_api_model.py | 82 ++++++++++++++++ jsonapi_requests/orm/flask/api_model.py | 38 ++++++++ tests/test_deferred_auth_orm.py | 95 +++++++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 jsonapi_requests/orm/deferred_auth_api_model.py create mode 100644 jsonapi_requests/orm/flask/api_model.py create mode 100644 tests/test_deferred_auth_orm.py diff --git a/jsonapi_requests/orm/deferred_auth_api_model.py b/jsonapi_requests/orm/deferred_auth_api_model.py new file mode 100644 index 0000000..88ee0aa --- /dev/null +++ b/jsonapi_requests/orm/deferred_auth_api_model.py @@ -0,0 +1,82 @@ +from typing import Callable + +from jsonapi_requests.orm import ApiModel, OrmApi +from jsonapi_requests.orm.api_model import OptionsFactory, Options, ApiModelMetaclass + + +class JSONAPIClientNotFound(KeyError): + pass + + +class DeferredAuthOptionsFactory(OptionsFactory): + """API Options Factory connected to flask.g for jsonapi client fetch.""" + + def __init__(self, klass, klass_attrs): + super().__init__(klass, klass_attrs) + self._api_builder = None + + def get(self): + api = self.api_builder() + return Options(type=self.type, api=api, fields=self.fields, path=self.path) + + @property + def api_builder(self) -> Callable[..., OrmApi]: + return self._api_builder + + @api_builder.setter + def api_builder(self, api_builder: Callable[..., OrmApi]): + self._api_builder = api_builder + + +class DeferredAuthOptionsFactoryMetaclass(type): + + def __init__(cls, name, bases, attrs): + super().__init__(name, bases, attrs) + + +class DeferredAuthAPIModelMetaclass(ApiModelMetaclass, type): + """A Metaclass enabling complex querying of JSONAPI resources, building API config options later than class def.""" + + def __init__(metacls, cls, bases, classdict): + """Defer options construction to a later stage than class definition. + + Args: + metacls: this metaclass instance + cls: the class name for the class to create + bases: the base classes for the class to create + classdict: all the attributes and methods to put inside the __dict__ + """ + super().__init__(cls, bases, classdict) + metacls.classdict = classdict + metacls._options = None + + def __call__(self, *args, **kwargs): + """Defer API configuration at class instantiation time and not definition time. + + Args: + args : tuple, Position only arguments of the new class + kwargs : dict, Keyword only arguments of the new class + + """ + instance = DeferredAuthApiModel.__new__(self) + DeferredAuthApiModel.__init__(instance, *args, **kwargs) + if not hasattr(instance, 'api'): + raise NotImplementedError(f"Instance of class {instance.__class__.__name__} is derived from" + f" metaclass {self.__class__.__name__}. " + f"Therefore it is required to implement interface classmethod" + f" `{instance.__class__.__name__}.api`.") + _options_factory = DeferredAuthOptionsFactory(self, self.classdict) + _options_factory.api_builder = instance.api + self._options = _options_factory.get() + if self._options.api and self._options.type: + self._options.api.type_registry.register(instance.__class__) + return instance + + +class DeferredAuthApiModel(ApiModel, metaclass=DeferredAuthAPIModelMetaclass): + """An ORM Model for PR-API resources objects, able to build complex JSONAPI queries.""" + + @classmethod + def api(cls) -> OrmApi: + raise NotImplementedError("Any class derived from metaclass DeferredAuthAPIModelMetaclass " + "should implement api classmethod.") diff --git a/jsonapi_requests/orm/flask/api_model.py b/jsonapi_requests/orm/flask/api_model.py new file mode 100644 index 0000000..b3fff89 --- /dev/null +++ b/jsonapi_requests/orm/flask/api_model.py @@ -0,0 +1,38 @@ +from typing import Optional + +from jsonapi_requests import Api +from jsonapi_requests.auth import FlaskForwardAuth +from jsonapi_requests.orm import OrmApi +from jsonapi_requests.orm.deferred_auth_api_model import DeferredAuthApiModel + + +class FlaskAuthApiModel(DeferredAuthApiModel): + + @classmethod + def api(cls) -> OrmApi: + return OrmApi( + Api.config( + { + "API_ROOT": cls.api_root(), + "AUTH": cls.auth(), + "VALIDATE_SSL": cls.validate_ssl(), + "TIMEOUT": cls.timeout(), + } + ) + ) + + @classmethod + def auth(cls): + return FlaskForwardAuth() + + @classmethod + def timeout(cls) -> Optional[int]: + raise NotImplementedError() + + @classmethod + def validate_ssl(cls) -> bool: + raise NotImplementedError() + + @classmethod + def api_root(cls) -> str: + raise NotImplementedError() diff --git a/tests/test_deferred_auth_orm.py b/tests/test_deferred_auth_orm.py new file mode 100644 index 0000000..cf3c778 --- /dev/null +++ b/tests/test_deferred_auth_orm.py @@ -0,0 +1,95 @@ +from typing import Optional +from unittest import mock + +import pytest +from flask import Flask +from jsonapi_requests import data + +from jsonapi_requests.orm import AttributeField, RelationField + +from jsonapi_requests.orm.deferred_auth_api_model import DeferredAuthApiModel +from jsonapi_requests.orm.flask.api_model import FlaskAuthApiModel + +@pytest.fixture +def flask_app(): + app = Flask(__name__) + yield app + + +@pytest.fixture +def valid_response(): + response = mock.Mock(status_code=200) + response.json.return_value = {"data": data.JsonApiObject( + type='person', id='123', attributes={'first-name': 'alice'}).as_data()} + return response + + +@pytest.fixture +def request_send_mock(valid_response): + with mock.patch('requests.sessions.Session.send') as mocked: + mocked.return_value = valid_response + yield mocked + + +class FlaskClientToRailsServer(FlaskAuthApiModel): + + @classmethod + def timeout(cls) -> Optional[int]: + return None + + @classmethod + def validate_ssl(cls) -> bool: + return False + + @classmethod + def api_root(cls) -> str: + return "http://some.rails/api/endpoint/" + + +class TestDeferredAuthApiModel: + + def test_class_definition_without_api_meta_attribute(self): + class Person(DeferredAuthApiModel): + class Meta: + type = "person" + path = "patients" + + first_name = AttributeField("first-name") + + def test_class_instantiation_without_api_defined_method_raises_not_implemented(self): + class Person(DeferredAuthApiModel): + class Meta: + type = "person" + path = "patients" + + first_name = AttributeField("first-name") + + with pytest.raises(NotImplementedError): + Person("123") + + +class TestFlaskAuthApiModel: + + def test_class_definition_without_api_meta_attribute(self): + class Person(FlaskAuthApiModel): + class Meta: + type = "person" + path = "http://some/api/endpoint/patients" + + first_name = AttributeField("first-name") + + def test_get_request(self, flask_app, request_send_mock): + class Person(FlaskClientToRailsServer): + class Meta: + type = "person" + path = "persons" + + first_name = AttributeField("first-name") + + with flask_app.app_context(): + with flask_app.test_request_context( + headers={'Authorization': 'Bearer 11111111-1111-1111-1111-111111111111'}): + _ = Person.from_id("123").first_name + args, kwargs = request_send_mock.call_args + headers = args[0].headers + assert 'Bearer 11111111-1111-1111-1111-111111111111' in headers['Authorization'] From 97a6406dae6c5e80e37dd9059508ad2cd6ac7c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Klopfenstein?= Date: Wed, 31 Dec 2025 11:38:23 +0100 Subject: [PATCH 2/2] black formatting --- .../orm/deferred_auth_api_model.py | 18 ++++++++++-------- jsonapi_requests/orm/flask/api_model.py | 15 ++++++--------- tests/test_deferred_auth_orm.py | 16 ++++++++-------- 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/jsonapi_requests/orm/deferred_auth_api_model.py b/jsonapi_requests/orm/deferred_auth_api_model.py index 88ee0aa..9c20223 100644 --- a/jsonapi_requests/orm/deferred_auth_api_model.py +++ b/jsonapi_requests/orm/deferred_auth_api_model.py @@ -29,7 +29,6 @@ def api_builder(self, api_builder: Callable[..., OrmApi]): class DeferredAuthOptionsFactoryMetaclass(type): - def __init__(cls, name, bases, attrs): super().__init__(name, bases, attrs) @@ -60,11 +59,13 @@ def __call__(self, *args, **kwargs): """ instance = DeferredAuthApiModel.__new__(self) DeferredAuthApiModel.__init__(instance, *args, **kwargs) - if not hasattr(instance, 'api'): - raise NotImplementedError(f"Instance of class {instance.__class__.__name__} is derived from" - f" metaclass {self.__class__.__name__}. " - f"Therefore it is required to implement interface classmethod" - f" `{instance.__class__.__name__}.api`.") + if not hasattr(instance, "api"): + raise NotImplementedError( + f"Instance of class {instance.__class__.__name__} is derived from" + f" metaclass {self.__class__.__name__}. " + "Therefore it is required to implement interface classmethod" + f" `{instance.__class__.__name__}.api`." + ) _options_factory = DeferredAuthOptionsFactory(self, self.classdict) _options_factory.api_builder = instance.api self._options = _options_factory.get() @@ -78,5 +79,6 @@ class DeferredAuthApiModel(ApiModel, metaclass=DeferredAuthAPIModelMetaclass): @classmethod def api(cls) -> OrmApi: - raise NotImplementedError("Any class derived from metaclass DeferredAuthAPIModelMetaclass " - "should implement api classmethod.") + raise NotImplementedError( + "Any class derived from metaclass DeferredAuthAPIModelMetaclass should implement api classmethod." + ) diff --git a/jsonapi_requests/orm/flask/api_model.py b/jsonapi_requests/orm/flask/api_model.py index b3fff89..692eb4b 100644 --- a/jsonapi_requests/orm/flask/api_model.py +++ b/jsonapi_requests/orm/flask/api_model.py @@ -7,18 +7,15 @@ class FlaskAuthApiModel(DeferredAuthApiModel): - @classmethod def api(cls) -> OrmApi: return OrmApi( - Api.config( - { - "API_ROOT": cls.api_root(), - "AUTH": cls.auth(), - "VALIDATE_SSL": cls.validate_ssl(), - "TIMEOUT": cls.timeout(), - } - ) + Api.config({ + "API_ROOT": cls.api_root(), + "AUTH": cls.auth(), + "VALIDATE_SSL": cls.validate_ssl(), + "TIMEOUT": cls.timeout(), + }) ) @classmethod diff --git a/tests/test_deferred_auth_orm.py b/tests/test_deferred_auth_orm.py index cf3c778..0f34fea 100644 --- a/tests/test_deferred_auth_orm.py +++ b/tests/test_deferred_auth_orm.py @@ -10,6 +10,7 @@ from jsonapi_requests.orm.deferred_auth_api_model import DeferredAuthApiModel from jsonapi_requests.orm.flask.api_model import FlaskAuthApiModel + @pytest.fixture def flask_app(): app = Flask(__name__) @@ -19,20 +20,20 @@ def flask_app(): @pytest.fixture def valid_response(): response = mock.Mock(status_code=200) - response.json.return_value = {"data": data.JsonApiObject( - type='person', id='123', attributes={'first-name': 'alice'}).as_data()} + response.json.return_value = { + "data": data.JsonApiObject(type="person", id="123", attributes={"first-name": "alice"}).as_data() + } return response @pytest.fixture def request_send_mock(valid_response): - with mock.patch('requests.sessions.Session.send') as mocked: + with mock.patch("requests.sessions.Session.send") as mocked: mocked.return_value = valid_response yield mocked class FlaskClientToRailsServer(FlaskAuthApiModel): - @classmethod def timeout(cls) -> Optional[int]: return None @@ -47,7 +48,6 @@ def api_root(cls) -> str: class TestDeferredAuthApiModel: - def test_class_definition_without_api_meta_attribute(self): class Person(DeferredAuthApiModel): class Meta: @@ -69,7 +69,6 @@ class Meta: class TestFlaskAuthApiModel: - def test_class_definition_without_api_meta_attribute(self): class Person(FlaskAuthApiModel): class Meta: @@ -88,8 +87,9 @@ class Meta: with flask_app.app_context(): with flask_app.test_request_context( - headers={'Authorization': 'Bearer 11111111-1111-1111-1111-111111111111'}): + headers={"Authorization": "Bearer 11111111-1111-1111-1111-111111111111"} + ): _ = Person.from_id("123").first_name args, kwargs = request_send_mock.call_args headers = args[0].headers - assert 'Bearer 11111111-1111-1111-1111-111111111111' in headers['Authorization'] + assert "Bearer 11111111-1111-1111-1111-111111111111" in headers["Authorization"]