Repository: ithaka/apiron

Issue:
StubEndpoint not callable
**Describe the bug**
Using the syntax released in v2.3.0 for `StubEndpoint`s raises a `TypeError`.

**To Reproduce**
Steps to reproduce the behavior:

```python
from apiron.endpoint.stub import StubEndpoint
from apiron.service.base import Service

class MyService(Service):
    domain = 'myservice.com'

    my_endpoint = StubEndpoint(stub_response=lambda **kwargs: 'response')

MyService.my_endpoint()
# TypeError: 'StubEndpoint' object is not callable
```

**Expected behavior**
Calling the stub endpoint in this way should call `stub_response`, if it's callable, or return the value of `stub_response`.

**What actually happened**
A `TypeError` 😢 

**Environment:**
 - OS: macOS
 - Python version: 3.4.3
 - `apiron` version: 2.3.0

**Additional context**
This is happening since `StubEndpoint`s do not inherit from `Endpoint` and thus do not receive the metaprogramming treatment other `Endpoint` subclasses do. A fix would be to add the expected behavior described above in `ServiceMeta`.

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b472cb2..2bc541d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [Unreleased]
+### Fixed
+- Error in calling a dynamic stub endpoint
+
 ## [2.3.0] - 2019-03-25
 ### Added
 - `pytest.ini` for `pytest` configuration
diff --git a/apiron/client.py b/apiron/client.py
index d0259fd..227b031 100644
--- a/apiron/client.py
+++ b/apiron/client.py
@@ -210,7 +210,7 @@ class ServiceCaller:
                 'Stub call for endpoint defined by {}'
                 .format(getattr(endpoint, 'endpoint_params', {}))
             )
-            if hasattr(endpoint.stub_response, '__call__'):
+            if callable(endpoint.stub_response):
                 return endpoint.stub_response(
                     method=method or endpoint.default_method,
                     path_kwargs=path_kwargs,
diff --git a/apiron/endpoint/stub.py b/apiron/endpoint/stub.py
index 89239c0..85fe5fa 100644
--- a/apiron/endpoint/stub.py
+++ b/apiron/endpoint/stub.py
@@ -1,4 +1,7 @@
-class StubEndpoint:
+from apiron.endpoint.endpoint import Endpoint
+
+
+class StubEndpoint(Endpoint):
     """
     A stub endpoint designed to return a pre-baked response
 
@@ -16,16 +19,15 @@ class StubEndpoint:
             Example of a response-determining function::
 
                 def stub_response(**kwargs):
-                    response_map = {
-                        'param_value': {'stub response': 'for param_key=param_value'},
-                        'default': {'default': 'response'},
-                    }
-                    data_key = kwargs['params'].setdefault('param_key', 'default')
-                    return response_map[data_key]
+                    if kwargs.get('params') and kwargs['params'].get('param_key') == 'param_value':
+                        return {'stub response': 'for param_key=param_value'}
+                    else:
+                        return {'default': 'response'}
 
         :param ``**kwargs``:
             Arbitrary parameters that can match the intended real endpoint.
             These don't do anything for the stub but streamline the interface.
         """
-        self.endpoint_params = kwargs if kwargs else {}
+        self.endpoint_params = kwargs or {}
         self.stub_response = stub_response or 'stub for {}'.format(self.endpoint_params)
+        super().__init__(**kwargs)


Test patch:
diff --git a/tests/test_endpoint.py b/tests/test_endpoint.py
index 07b57bd..f37cbaf 100644
--- a/tests/test_endpoint.py
+++ b/tests/test_endpoint.py
@@ -127,6 +127,7 @@ class TestStreamingEndpoint:
 
 
 class TestStubEndpoint:
+
     def test_stub_response(self):
         """
         Test initializing a stub endpoint with a stub response
@@ -150,3 +151,38 @@ class TestStubEndpoint:
             'required_params': {'param_name'},
         }
         assert expected_params == stub_endpoint.endpoint_params
+
+    def test_call_static(self, service):
+        """
+        Test calling a ``StubEndpoint`` with a static response
+        """
+        service.stub_endpoint = endpoint.StubEndpoint(
+            stub_response='stub response',
+        )
+        actual_response = service.stub_endpoint()
+        expected_response = 'stub response'
+        assert actual_response == expected_response
+
+    def test_call_dynamic(self, service):
+        """
+        Test calling a StubEndpoint with a dynamic response
+        """
+        def _test_case(call_kwargs, expected_response):
+            def stub_response(**kwargs):
+                if kwargs.get('params') and kwargs['params'].get('param_key') == 'param_value':
+                    return {'stub response': 'for param_key=param_value'}
+                else:
+                    return {'default': 'response'}
+
+            service.stub_endpoint = endpoint.StubEndpoint(stub_response=stub_response)
+            actual_response = service.stub_endpoint(**call_kwargs)
+            assert actual_response == expected_response
+
+        _test_case(
+            call_kwargs={},
+            expected_response={'default': 'response'},
+        )
+        _test_case(
+            call_kwargs={'params': {'param_key': 'param_value'}},
+            expected_response={'stub response': 'for param_key=param_value'},
+        )