Repository: ithaka/apiron

Issue:
Syntax sugar for endpoint calls
**Is your feature request related to a problem? Please describe.**
It is verbose to do the following for every endpoint call:

```python
from apiron.client import ServiceCaller

from some.place import MyService

MYSERVICE = MyService()

response = ServiceCaller.call(
    MYSERVICE,
    MYSERVICE.some_endpoint,
    params={'foo': 'bar'},
)
```

**Describe the solution you'd like**
Syntax sugar over this interaction could be made to look like this:

```python
from some.place import MyService

response = MyService.some_endpoint(
    params={'foo': 'bar'},
)
```

**Describe alternatives you've considered**
People can [sort of do this themselves](https://github.com/teffalump/orthanc_rest_client/blob/96369b80151cc1d3191aeadb1c5ed49c40e0b1be/orthanc_rest_client/orthanc.py#L40-L46) but we've received feedback that this would be nice from more than one person. This would clean up a decent amount of code in large projects.

**Additional context**
This will require solving the need for instantiating the service (`MYSERVICE = MyService()`) and will require some metaprogramming to pass the `Service`'s information along to the `Endpoint` being called.

Discussion:

Patch:
diff --git a/.travis.yml b/.travis.yml
index b6568ad..6d0a69c 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,9 +1,11 @@
+dist: xenial
 language: python
 cache: pip
 python:
   - '3.4'
   - '3.5'
   - '3.6'
+  - '3.7'
 script:
   - pytest
 install:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 82f9199..92bc0bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,11 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ## [Unreleased]
 ### Added
 - `pytest.ini` for `pytest` configuration
+- Syntax sugar for calling endpoints
 
 ### Changed
 - Update tests to use `pytest`-style `assert`s and fixtures (`unittest.mock` usage is still in place, for now)
 - Make `--cov=apiron` the default when running `pytest`
 - Make test output terse by default (`-v` when running restores previous behavior; `-vv` gives explicit test list)
+- An endpoint's `stub_response` can optionally be a callable, for returning dynamic values in response to parameters
 
 ### Fixed
 - End PyPI server URL with a slash to avoid a redirect, allowing deployment of build artifacts during release
diff --git a/README.md b/README.md
index 34aa54e..46c75b6 100644
--- a/README.md
+++ b/README.md
@@ -38,23 +38,14 @@ class GitHub(Service):
 
 ## Interacting with a service
 
-Once your service definition is in place, you can interact with its endpoints
-using the `ServiceCaller`:
+Once your service definition is in place, you can interact with its endpoints:
 
 ```python
-from apiron.client import ServiceCaller
-
-github = GitHub()
-
-response = ServiceCaller.call(
-    github,
-    github.user,
+response = GitHub.user(
     path_kwargs={'username': 'defunkt'},
 )  # {"name": "Chris Wanstrath", ...}
 
-response = ServiceCaller.call(
-    github,
-    github.repo,
+response = GitHub.repo(
     path_kwargs={'org': 'github', 'repo': 'hub'},
 )  # {"description": "hub helps you win at git.", ...}
 ```
diff --git a/apiron/endpoint/endpoint.py b/apiron/endpoint/endpoint.py
index f9624fc..9dc84be 100644
--- a/apiron/endpoint/endpoint.py
+++ b/apiron/endpoint/endpoint.py
@@ -12,6 +12,17 @@ class Endpoint:
     A basic service endpoint that responds with the default ``Content-Type`` for that endpoint
     """
 
+    def __call__(self, *args, **kwargs):
+        """
+        Used to provide syntax sugar on top of :func:`apiron.client.ServiceCaller.call`.
+        The callable attribute is set dynamically by the :class:`Service` subclass this endpoint is a part of.
+        Arguments are identical to those of :func:`apiron.client.ServiceCaller.call`
+        """
+        if hasattr(self, 'callable'):
+            return self.callable(*args, **kwargs)
+        else:
+            raise TypeError('Endpoints are only callable in conjunction with a Service class.')
+
     def __init__(self, path='/', default_method='GET', default_params=None, required_params=None):
         """
         :param str path:
diff --git a/apiron/endpoint/stub.py b/apiron/endpoint/stub.py
index ca9869e..89239c0 100644
--- a/apiron/endpoint/stub.py
+++ b/apiron/endpoint/stub.py
@@ -15,13 +15,13 @@ class StubEndpoint:
             provided to the client's ``call`` method.
             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]
+                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]
 
         :param ``**kwargs``:
             Arbitrary parameters that can match the intended real endpoint.
diff --git a/apiron/service/base.py b/apiron/service/base.py
index e2d0f7b..5aceb6e 100644
--- a/apiron/service/base.py
+++ b/apiron/service/base.py
@@ -1,11 +1,46 @@
-class Service:
+from functools import partial
+
+from apiron.client import ServiceCaller
+from apiron.endpoint import Endpoint
+
+
+class ServiceMeta(type):
+    @property
+    def required_headers(cls):
+        return cls().required_headers
+
+    def __getattribute__(cls, *args):
+        attribute = type.__getattribute__(cls, *args)
+        if isinstance(attribute, Endpoint):
+            attribute.callable = partial(ServiceCaller.call, cls, attribute)
+        return attribute
+
+    def __str__(cls):
+        service_name = getattr(cls, 'service_name', None)
+        return service_name or cls.domain
+
+    def __repr__(cls):
+        if hasattr(cls, 'service_name'):
+            return '{klass}(service_name={service_name}, host_resolver={host_resolver})'.format(
+                klass=cls.__name__,
+                service_name=cls.service_name,
+                host_resolver=cls.host_resolver_class.__name__,
+            )
+        else:
+            return '{klass}(domain={domain})'.format(klass=cls.__name__, domain=cls.domain)
+
+
+class Service(metaclass=ServiceMeta):
     """
     A base class for low-level services.
 
     A service has a domain off of which one or more endpoints stem.
     """
 
-    def get_hosts(self):
+    required_headers = {}
+
+    @classmethod
+    def get_hosts(cls):
         """
         The fully-qualified hostnames that correspond to this service.
         These are often determined by asking a load balancer or service discovery mechanism.
@@ -15,22 +50,10 @@ class Service:
         :rtype:
             list
         """
-        return [self.domain]
-
-    @property
-    def required_headers(self):
-        """
-        Headers that are required when making calls to this :class:`Service`
-
-        :return:
-            Pairs of required header name, header value when calling this service
-        :rtype:
-            dict
-        """
-        return {}
+        return [cls.domain]
 
     def __str__(self):
-        return self.domain
+        return str(self.__class__)
 
     def __repr__(self):
-        return '{klass}(domain={domain})'.format(klass=self.__class__.__name__, domain=self.domain)
+        return repr(self.__class__)
diff --git a/apiron/service/discoverable.py b/apiron/service/discoverable.py
index 8f31a5b..28f62fc 100644
--- a/apiron/service/discoverable.py
+++ b/apiron/service/discoverable.py
@@ -14,11 +14,7 @@ class DiscoverableService(Service):
         return cls.host_resolver_class.resolve(cls.service_name)
 
     def __str__(self):
-        return self.service_name
+        return str(self.__class__)
 
     def __repr__(self):
-        return '{klass}(service_name={service_name}, host_resolver={host_resolver})'.format(
-            klass=self.__class__.__name__,
-            service_name=self.service_name,
-            host_resolver=self.host_resolver_class.__name__,
-        )
+        return repr(self.__class__)
diff --git a/docs/advanced-usage.rst b/docs/advanced-usage.rst
index b81d2e2..0ca933a 100644
--- a/docs/advanced-usage.rst
+++ b/docs/advanced-usage.rst
@@ -17,7 +17,7 @@ Service and endpoints
     from apiron.service.base import Service
     from apiron.endpoint import Endpoint, JsonEndpoint, StreamingEndpoint
 
-    class HttpBinService(Service):
+    class HttpBin(Service):
         domain = 'https://httpbin.org'
 
         getter = JsonEndpoint(path='/get')
@@ -36,42 +36,38 @@ Using all the features
 
     import requests
 
-    from apiron.client import ServiceCaller, Timeout
+    from apiron.client import Timeout
 
-    from test_service import HttpBinService
-
-    httpbin = HttpBinService()
+    from test_service import HttpBin
 
     # A normal old GET call
-    ServiceCaller.call(httpbin, httpbin.getter, params={'foo': 'bar'})
+    HttpBin.getter(params={'foo': 'bar'})
 
     # A normal old POST call
-    ServiceCaller.call(httpbin, httpbin.poster, data={'foo': 'bar'})
+    HttpBin.poster(data={'foo': 'bar'})
 
     # A GET call with parameters formatted into the path
-    ServiceCaller.call(httpbin, httpbin.anything, path_kwargs={'anything': 42})
+    HttpBin.anything(path_kwargs={'anything': 42})
 
     # A GET call with a 500 response, raises RetryError since we successfully tried but got a bad response
     try:
-        ServiceCaller.call(httpbin, httpbin.status, path_kwargs={'status_code': 500})
+        HttpBin.status(path_kwargs={'status_code': 500})
     except requests.exceptions.RetryError:
         pass
 
     # A GET call to a slow endpoint, raises ConnectionError since our connection failed
     try:
-        ServiceCaller.call(httpbin, httpbin.slow)
+        HttpBin.slow()
     except requests.exceptions.ConnectionError:
         pass
 
     # A GET call to a slow endpoint with a longer timeout
-    ServiceCaller.call(
-        httpbin,
-        httpbin.slow,
+    HttpBin.slow(
         timeout_spec=Timeout(connection_timeout=1, read_timeout=6)
     )
 
     # A streaming response
-    response = ServiceCaller.call(httpbin, httpbin.streamer, path_kwargs={'num_lines': 20})
+    response = HttpBin.streamer(path_kwargs={'num_lines': 20})
     for chunk in response:
         print(chunk)
 
@@ -88,19 +84,19 @@ Here is an example where the resolver application always resolves to ``https://w
     from apiron.client import ServiceCaller
     from apiron.service.discoverable import DiscoverableService
 
-    class AlwaysGoogleResolver:
+    class Eureka:
         @staticmethod
         def resolve(service_name):
-            return ['https://www.google.com']
+            hosts = ...  # get host list from Eureka
+
+    class AuthenticationService(DiscoverableService):
+        service_name = 'authentication-service'
+        host_resolver_class = Eureka
 
-    class GoogleService(DiscoverableService):
-        service_name = 'google-service'
-        host_resolver_class = AlwaysGoogleResolver
+        auth = Endpoint(path='/auth')
 
-        search = Endpoint(path='/search')
 
-    google = GoogleService()
-    ServiceCaller.call(google, google.search, params={'q': 'rare puppers'})
+    response = AuthenticationService.auth(data={'user': 'Gandalf', 'password': 'Mellon'})
 
 An application may wish to use a load balancer application
 or a more complex service discovery mechanism (like Netflix's `Eureka <https://github.com/Netflix/eureka>`_)
@@ -112,11 +108,10 @@ Workflow consistency
 ********************
 
 It's common to have an existing :class:`requests.Session` object you'd like to use to make additional requests.
-This is enabled in ``apiron`` with the ``session`` argument to :func:`apiron.client.ServiceCaller.call`.
+This is enabled in ``apiron`` with the ``session`` argument to an endpoint call.
 The passed in session object will be used to send the request.
 This is useful for workflows where cookies or other information need to persist across multiple calls.
 
 It's often more useful in logs to know which module initiated the code doing the logging.
-``apiron`` allows for an existing logger object to be passed
-into :func:`apiron.client.ServiceCaller.call` as well
+``apiron`` allows for an existing logger object to be passed to an endpoint call using the ``logger`` argument
 so that logs will indicate the caller module rather than :mod:`apiron.client`.
diff --git a/docs/getting-started.rst b/docs/getting-started.rst
index 8b3e3aa..a2b7310 100644
--- a/docs/getting-started.rst
+++ b/docs/getting-started.rst
@@ -39,19 +39,11 @@ using the :class:`ServiceCaller <apiron.client.ServiceCaller>`:
 
 .. code-block:: python
 
-    from apiron.client import ServiceCaller
-
-    github = GitHub()
-
-    response = ServiceCaller.call(
-        github,
-        github.user,
+    response = GitHub.user(
         path_kwargs={'username': 'defunkt'},
     )  # {"name": "Chris Wanstrath", ...}
 
-    response = ServiceCaller.call(
-        github,
-        github.repo,
+    response = GitHub.repo(
         path_kwargs={'org': 'github', 'repo': 'hub'},
     )  # {"description": "hub helps you win at git.", ...}
 


Test patch:
diff --git a/tests/service/test_base.py b/tests/service/test_base.py
index 0da6562..5b48222 100644
--- a/tests/service/test_base.py
+++ b/tests/service/test_base.py
@@ -5,20 +5,26 @@ from apiron.service.base import Service
 
 @pytest.fixture(scope='class')
 def service():
-    service = Service()
-    service.domain = 'http://foo.com'
-    return service
+    class SomeService(Service):
+        domain = 'http://foo.com'
+    return SomeService
 
 
 class TestService:
     def test_get_hosts_returns_domain(self, service):
         assert ['http://foo.com'] == service.get_hosts()
 
-    def test_str_method(self, service):
+    def test_str_method_on_class(self, service):
         assert 'http://foo.com' == str(service)
 
-    def test_repr_method(self, service):
-        assert 'Service(domain=http://foo.com)' == repr(service)
+    def test_str_method_on_instance(self, service):
+        assert 'http://foo.com' == str(service())
+
+    def test_repr_method_on_class(self, service):
+        assert 'SomeService(domain=http://foo.com)' == repr(service)
+
+    def test_repr_method_on_instance(self, service):
+        assert 'SomeService(domain=http://foo.com)' == repr(service())
 
     def test_required_hosts_returns_dictionary(self, service):
         assert {} == service.required_headers
diff --git a/tests/service/test_discoverable.py b/tests/service/test_discoverable.py
index 1616e14..bffb9b8 100644
--- a/tests/service/test_discoverable.py
+++ b/tests/service/test_discoverable.py
@@ -16,15 +16,21 @@ class FakeService(DiscoverableService):
 
 @pytest.fixture(scope='class')
 def service():
-    return FakeService()
+    return FakeService
 
 
 class TestDiscoverableService:
     def test_get_hosts_returns_hosts_from_resolver(self, service):
         assert ['fake'] == service.get_hosts()
 
-    def test_str_method(self, service):
+    def test_str_method_on_class(self, service):
         assert 'fake-service' == str(service)
 
-    def test_repr_method(self, service):
+    def test_str_method_on_instance(self, service):
+        assert 'fake-service' == str(service())
+
+    def test_repr_method_on_class(self, service):
         assert 'FakeService(service_name=fake-service, host_resolver=FakeResolver)' == repr(service)
+
+    def test_repr_method_on_instance(self, service):
+        assert 'FakeService(service_name=fake-service, host_resolver=FakeResolver)' == repr(service())
diff --git a/tests/test_endpoint.py b/tests/test_endpoint.py
index 162e820..07b57bd 100644
--- a/tests/test_endpoint.py
+++ b/tests/test_endpoint.py
@@ -6,9 +6,24 @@ import pytest
 
 from apiron import endpoint
 from apiron.exceptions import UnfulfilledParameterException
+from apiron.service.base import Service
 
+@pytest.fixture
+def service():
+    class SomeService(Service):
+        domain = 'http://foo.com'
+    return SomeService
 
 class TestEndpoint:
+    def test_call(self, service):
+        service.foo = endpoint.Endpoint()
+        service.foo()
+
+    def test_call_without_service_raises_exception(self):
+        foo = endpoint.Endpoint()
+        with pytest.raises(TypeError):
+            foo()
+
     def test_default_attributes_from_constructor(self):
         foo = endpoint.Endpoint()
         assert '/' == foo.path