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..9c20223 --- /dev/null +++ b/jsonapi_requests/orm/deferred_auth_api_model.py @@ -0,0 +1,84 @@ +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__}. " + "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..692eb4b --- /dev/null +++ b/jsonapi_requests/orm/flask/api_model.py @@ -0,0 +1,35 @@ +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..0f34fea --- /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"]