HOPE: | 3 |
Title: | Dependency Injection |
Author(s): | Timothy Crosley [email protected] |
Status: | Provisional |
Type: | Standard Track |
Created: | 25-May-2019 |
Updated: | 27-May-2019 |
This HOPE proposes adding a robust and universally available dependency injection system to Hug.
This system would be built with the intention of replacing Hug's current directive
system, one of the first Python microservice dependency injection systems, solving the shortcomings identified upon extensive usage:
- Directives aren't clearly separated from type annotations.
- It is impossible to have both a directive and type hint for a single parameter.
- It isn't easy to swap out directives for full HTTP stack testing (through direct calls make it easy to substitute out).
- Directives aren't nestable.
This HOPE proposes a new dependency injection system with an aim for clarity and reuse.
The API of the new system would consist of a provide
decorator for defining available dependencies, and a requires
function for using them:
@provide(name: Union[FunctionName, bool, str] = FunctionName, *, api: hug.API = None, override: bool = False)
requires(name_or_function: Union[str, Callable], *args, **kwargs) -> Any
These two calls are meant to replace the old Directive
system:
@directive(apply_globally: bool = False, api: hug.API = None)
#def timer(...):
# pass
Which was used in the following manner:
@hug.get()
def my_endpoint(hug_timer):
pass
# OR in the more modern form:
@hug.get()
def my_endpoint(timer: hug.directives.timer):
pass
Defining a dependency will happen via a new hug provide
decorator:
@hug.provide
def mysql_connection(host, port, ...):
...
By default, the name will be the name of the function, but the first argument sent to that directive should enable redefining it:
@hug.provide("mysql")
def mysql_connection(host, port, ...):
...
It should also be possible to specify different dependency providers for different interfaces:
# Multiple interfaces using one provider
@hug.provide("mysql", interfaces=['http', 'websocket'])
def mysql_web():
...
# For a provider for a single interface, this shorthand can be used
@hug.cli.provide("mysql")
def mysql_cli():
...
# The following would also be a valid way to apply to multiple interfaces
@hug.http.provide("mysql")
@hug.cli.provide("mysql")
def mysql():
...
# Or the following would also be a valid way to apply to multiple interfaces
@hug.http.provide
@hug.cli.provide
def mysql():
...
If you only want to enable provider usage locally, and don't want to allow it to be overridden, you can explicitly set it to stay unnamed:
@hug.provide(name=False)
def mysql_connection(host, port, ...):
...
Dependencies will be pulled into individual endpoints via a require
decorator, and provided to the calling function
via an eponymous argument
@hug.http
@hug.require.mysql
def hello_world(mysql):
pass
Arguments that are passed to the requires
function will be passed directly to the provide
function.
This will be done in the same manner as Python's funtools.partial
and follow the same function signature.
Any non-provided arguments will then require the dependency to pull them from the current application action,
like for normal hug calls, or via dependency injection. A key point: Any dependency can have unlimited sub-dependencies.
For unnamed dependencies, the function itself should be passed into the requires function:
import json
@hug.provide(scope="module"):
def shared_configuration(config_file_location="config_file.json"):
with open(config_file_location) as config_file:
return json.loads(config_file.read())
@hug.http
@hug.require.shared_configuration
def hello_world(shared_configuration):
pass
If a named dependency is passed to requires in this fashion, it will raise an InvalidNamedDependencyUsage
exception.
Dependencies providers will be stored within the Hug API module level singleton, within a dependency_providers
dictionary.
If a second provide
function is defined by default it will raise an ExistingDependency
exception. However, if that second definition defines override=True
, it will simply take the place of the first - similar to defining a second function with the same name. If a dependency provider is only meant for a particular set of interfaces, it will be stored on the sub interface API object within the singleton. Hug will first check the per-interface dict of providers, and only fall through to the global dict if the interface specific one does not have the requested provider.
Overriding a dependency in this system is straight forward: You update the dictionary to point to your new dependency providing function. You do this by creating a new provide against the existing provide API singleton, with the override
parameter set to True
.
For instance, in py.test
's conftest.py, you could store a series of dependency providers targeting the API you intend to test:
@hug.provide("mysql", override_api=production_api_im_testing)
def replace_mysql_with_mock():
return mock.MagicMock()
The following was rejected because it over-complicated the concept, leading to more new APIs then required.
Let's say you reuse a single set of parameters for every endpoint within an API module. Currently, in Hug, the simplest thing to do is redefine these parameters in every function. This is inconsistent with the Don't Repeat Yourself (DRY) principle. In this HOPE we are proposing to allow automatic nesting of any hug decorated endpoint in the same manner as full dependencies, albeit without the ability to easily swap them out for testing. Here's the proposed API for this feature:
@hug.http
def my_first_endpoint(argument_1, argument2):
...
@hug.http
def my_second_endpoint(first_two_arguments: Dict=my_first_endpoint, argument_3):
....
This would provide a natural and straight forward way to compose together API endpoints for optimal reuse.