Skip to content

Commit

Permalink
Merge pull request #114 from bird-house/request-hooks
Browse files Browse the repository at this point in the history
  • Loading branch information
fmigneault committed May 11, 2022
2 parents c9bf424 + 7cfe3a1 commit 348fc74
Show file tree
Hide file tree
Showing 15 changed files with 211 additions and 32 deletions.
5 changes: 5 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ Changes
Unreleased
==========

Changes:

* Add request and response hooks operations to adapter allowing derived implementations to modify OWS proxied requests
and returned responses from the service. The default adapter applies no modifications to the original definitions.

0.6.2 (2021-12-01)
==================

Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@ pyramid_oauthlib>=0.4.1
oauthlib<3
requests_oauthlib<1.2.0
PyJWT>=2
# typing extension required for TypedDict
typing_extensions; python_version < "3.8"
1 change: 1 addition & 0 deletions requirements_dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ sphinx
nbsphinx
bump2version
twine
mock
17 changes: 9 additions & 8 deletions tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,16 @@ def dummy_request(dbsession):


class BaseTest(unittest.TestCase):
settings = {
'sqlalchemy.url': 'sqlite:///:memory:',
'twitcher.username': 'testuser',
'twitcher.password': 'testpassword',
'twitcher.token.type': 'custom_token',
'twitcher.token.secret': 'testsecret',
}

def setUp(self):
self.config = testing.setUp(
settings={
'sqlalchemy.url': 'sqlite:///:memory:',
'twitcher.username': 'testuser',
'twitcher.password': 'testpassword',
'twitcher.token.type': 'custom_token',
'twitcher.token.secret': 'testsecret',
})
self.config = testing.setUp(settings=self.settings)
self.config.include('twitcher.models')
settings = self.config.get_settings()

Expand Down
2 changes: 1 addition & 1 deletion tests/functional/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@


class FunctionalTest(BaseTest):
def test_app(self):
def get_test_app(self):
app = webtest.TestApp(
self.config.make_wsgi_app(),
extra_environ={'db.session': self.session, 'tm.active': True})
Expand Down
91 changes: 91 additions & 0 deletions tests/functional/test_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import json
import mock

from twitcher.adapter.default import DefaultAdapter
from twitcher.owssecurity import OWSSecurityInterface
from twitcher.owsregistry import OWSRegistry
from twitcher.store import ServiceStore

from ..common import dummy_request
from .base import FunctionalTest


class AdapterWithHooks(DefaultAdapter):
def owssecurity_factory(self):
class DummyOWSSecurity(OWSSecurityInterface):
def verify_request(self, request): return True # noqa: E704
return DummyOWSSecurity()

def request_hook(self, request, service):
request.headers["X-Hook-Test-Service"] = service["name"]
return request

def response_hook(self, response, service):
# must edit body using text content,
# json property re-generates from it, cannot set value direct on dict returned by it
data = json.loads(response.text)
data["Hook-Test-Service"] = service["name"]
response.body = json.dumps(data).encode("UTF-8")
return response


class TestAdapterWithHooks(FunctionalTest):
@property
def settings(self):
adapter_name = '{}.{}'.format(AdapterWithHooks.__module__, AdapterWithHooks.__name__)
settings = super(TestAdapterWithHooks, self).settings.copy()
settings.update({
'twitcher.adapter': adapter_name
})
return settings

def setUp(self):
super(TestAdapterWithHooks, self).setUp()
self.init_database()
service_store = ServiceStore(dummy_request(dbsession=self.session))
self.reg = OWSRegistry(servicestore=service_store)

self.test_service_name = "test_adapter_svc"
self.test_service = {
'url': 'http://localhost/wps',
'name': self.test_service_name,
'type': 'wps',
'auth': 'token',
'public': False,
'verify': True,
'purl': 'http://myservice/wps'}
resp = self.reg.register_service(**self.test_service)
assert resp == self.test_service

self.config.include('twitcher.owsproxy')
self.app = self.get_test_app()

def test_request_response_hooks(self):
test_request_handle = []

def mocked_request(method, url, data, headers, **_):
_req = dummy_request(self.session)
_req.method = method
_req.url = url
_req.headers = headers
_req.body = data
test_request_handle.append(_req)
_resp = _req.response
_resp.content_type = "application/json"
_resp.status_code = 200
_resp.body = json.dumps({"response": "ok"}).encode("UTF-8")
_resp.content = _resp.body
_resp.ok = True
return _resp

with mock.patch("requests.request", side_effect=mocked_request):
resp = self.app.get(f'/ows/proxy/{self.test_service_name}?service=wps&request=getcapabilities')
assert resp.status_code == 200
assert resp.content_type == "application/json"

# check added header by request hook
assert test_request_handle
assert test_request_handle[0].headers.get("X-Hook-Test-Service") == self.test_service_name

# check added body content by response hook
assert resp.json == {"response": "ok", "Hook-Test-Service": self.test_service_name}
2 changes: 1 addition & 1 deletion tests/functional/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def setUp(self):
self.init_store()

self.config.include('twitcher.api')
self.app = self.test_app()
self.app = self.get_test_app()

@pytest.mark.skip(reason="not working")
def test_register_service_and_unregister_it(self):
Expand Down
2 changes: 1 addition & 1 deletion tests/functional/test_oauth2_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def setUp(self):
self.config.include('twitcher.oauth2')
self.config.add_route('compute', '/api/compute')
self.config.add_view(compute_view, route_name='compute', renderer='json')
self.app = self.test_app()
self.app = self.get_test_app()

def test_compute_with_param(self):
access_token = self.create_token()
Expand Down
2 changes: 1 addition & 1 deletion tests/functional/test_owsproxy_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def setUp(self):
self.init_store()

self.config.include('twitcher.owsproxy')
self.app = self.test_app()
self.app = self.get_test_app()

@pytest.mark.online
def test_getcaps(self):
Expand Down
5 changes: 3 additions & 2 deletions tests/test_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from twitcher.adapter.base import AdapterInterface
from twitcher.adapter.default import DefaultAdapter
from twitcher.interface import OWSSecurityInterface

from pyramid.testing import DummyRequest
from pathlib import Path
import pytest
Expand Down Expand Up @@ -30,7 +31,7 @@ def test_adapter_factory_none_specified():

# noinspection PyAbstractClass,PyMethodMayBeStatic
class DummyAdapter(AdapterInterface):
def owssecurity_factory(self, request):
def owssecurity_factory(self):
class DummyOWSSecurity(OWSSecurityInterface):
def verify_request(self, request): return True # noqa: E704
return DummyOWSSecurity()
Expand Down Expand Up @@ -101,6 +102,6 @@ def test_adapter_factory_TestAdapter_invalid_raised():
def test_adapter_factory_call_owssecurity_factory():
settings = {'twitcher.adapter': DummyAdapter({}).name}
adapter = get_adapter_factory(settings)
security = adapter.owssecurity_factory(DummyRequest())
security = adapter.owssecurity_factory()
assert isinstance(security, OWSSecurityInterface)
assert security.verify_request(DummyRequest()) is True, "Requested adapter should have been called."
35 changes: 33 additions & 2 deletions twitcher/adapter/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

from typing import TYPE_CHECKING
if TYPE_CHECKING:
from twitcher.typedefs import AnySettingsContainer, JSON
from twitcher.interface import OWSSecurityInterface, OWSRegistryInterface
from pyramid.config import Configurator
from pyramid.request import Request
from pyramid.response import Response

from twitcher.interface import OWSSecurityInterface, OWSRegistryInterface
from twitcher.models.service import ServiceConfig
from twitcher.typedefs import AnySettingsContainer, JSON


class AdapterInterface(object):
Expand Down Expand Up @@ -54,3 +57,31 @@ def owsproxy_config(self, container):
Returns the 'owsproxy' implementation of the adapter.
"""
raise NotImplementedError

def request_hook(self, request, service):
# type: (Request, ServiceConfig) -> Request
"""
Apply modifications onto the request before sending it.
.. versionadded:: 0.7.0
Request members employed after this hook is called include:
- :meth:`Request.headers`
- :meth:`Request.method`
- :meth:`Request.body`
This method can modified those members to adapt the request for specific service logic.
"""
raise NotImplementedError

def response_hook(self, response, service):
# type: (Response, ServiceConfig) -> Response
"""
Apply modifications onto the response from sent request.
.. versionadded:: 0.7.0
The received response from the proxied service is normally returned directly.
This method can modify the response to adapt it for specific service logic.
"""
raise NotImplementedError
6 changes: 6 additions & 0 deletions twitcher/adapter/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,9 @@ def owsproxy_config(self, container):
if not isinstance(container, Configurator):
container = self.configurator_factory(container)
owsproxy_defaultconfig(container)

def request_hook(self, request, service):
return request

def response_hook(self, response, service):
return response
21 changes: 18 additions & 3 deletions twitcher/models/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,23 @@
String,
)
from sqlalchemy.ext.hybrid import hybrid_property
from typing import Union
from typing import TYPE_CHECKING, Union

from .meta import Base

if TYPE_CHECKING:
from twitcher.typedefs import TypedDict

ServiceConfig = TypedDict("ServiceConfig", {
"url": str,
"name": str,
"type": str,
"purl": str,
"auth": str,
"public": bool,
"verify": bool
}, total=True)


class Service(Base):
__tablename__ = 'services'
Expand All @@ -21,20 +34,22 @@ class Service(Base):

@hybrid_property
def verify(self):
# type: () -> bool
if self._verify == 1:
return True
return False

@verify.setter
def verify(self, verify: Union[bool, int]):
def verify(self, verify: Union[bool, int]) -> None:
self._verify = int(verify)

@property
def public(self):
def public(self) -> bool:
"""Return true if public access."""
return self.auth not in ['token', 'cert']

def json(self):
# type: () -> ServiceConfig
return {
'url': self.url,
'name': self.name,
Expand Down

0 comments on commit 348fc74

Please sign in to comment.