This package provides everything you need to run a backend service.


# service.route

Use this decorator to declare routes on your service. If your function does not return a subclass of a Starlette response ( https://www.starlette.io/responses/ ), its output will automatically be serialized to JSON using an encoder that serializes UUIDs, dates, and datetimes to strings. Functions can be `async` or not.

Arguments:

* `path` the path to your endpoint, beginning with a '/'. If omitted, the function name will be used.
* `data` an op-dev [type validation spec] (#-Type-validation) that will be applied to the passed in data - the call will fail with a 400 response if the input data does not match
* `profile` an op-dev [type validation spec] (#-Type-validation) that will be applied to the validated JWT in the call's auth header - the call will fail with a 403 if the input profile does not match
* `methods` an array of the HTTP methods to accept

The decorated function can accept any / all of the following arguments:

* request: the underlying Starlette request object ( https://www.starlette.io/requests/ )
* ip_address: the client's IP address as a string (with any fowarding IP addresses from middleware removed)
* user_agent: the client's user agent string
* profile: the validated profile included in the JWT header passed into the request - this is the output of your `@service.auth` function and can only be generated by your service
* data: the deserialized data passed in by the   only relevant for requests op-dev 

``` python
@service.route(path=None, data=None, profile=None, methods=[ 'POST' ])
def public_endpoint():
  ...

@service.route(data={ 'name': str })
def hello(data):
  return 'Hello ' + data['name']

@service.route(profile={ 'user_id': str }, data={ 'name': str })
async def hello_auth(profile, data):
  return 'Hello ' + data['name'] + '(user_id: ' + profile['user_id'] + ')'
```

> TODO: don't pass `ip_address` as a string, pass it as an actual python object ( https://docs.python.org/3/library/ipaddress.html )


# service.auth

Use this decorator to wrap the function that is responsible for validating authenticated access to your service and returning the profile object that will be encoded into a JWT and passed back to your service when clients make calls to other endpoints. Keep this object as small as possible as it will be included in every request. Do not include `iat` or `exp` - those fields will be injected automatically.

``` python
@service.auth()
def auth(method, payload):
  return { 'email': '...' }

@service.route(profile={ 'email': str })
def validated_route(profile):
  ...
```

> TODO: this is not yet implemented, you implementation would look something like the following for Google:
``` python
from google.oauth2 import id_token
from google.auth.transport import requests

@service.route('/get-jwt', { 'payload': non_empty_str })
def get_jwt(data):
  client_id = config['google_app_client_id'] + '.apps.googleusercontent.com'
    google_profile = id_token.verify_oauth2_token(data['payload'], requests.Request(), client_id)
    if google_profile['iss'] in [ 'accounts.google.com', 'https://accounts.google.com' ]:
      return service.jwt.encode({ 'email': google_profile['email'] })
    raise AuthenticationException()
```


# service.task

A task decorated with the `@service.task` decorator is guaranteed to run to completion. If the host on which it is running is terminated, the work will be picked up by a task with the same 'task_type_id' on another host (the task type ID is derived automatically from the function name if one is not provided). Functions decorated thusly can be called normally, or they can started asynchronously using 'submit' and 'result' as in the following example.

Arguments:

* `task_type_id` uniquely identifies this task
* `max_workers` the max number of workers (threads) that can be working on this task at once
* `heartbeat_s` how frequently other hosts will check to see if this task has been abandoned - defaults to start at 25 seconds and gradually climb to a max of 5 minutes. You could set a short time for short-running tasks that you want to enture will complete quickly, and set a long time for long-running tasks that don't need to get picked up right away if they get interrupted. Note that short hearbeats will cause more DB calls to be made.

``` python
@service.task(task_type_id=None, max_workers=64, heartbeat_s=None)
def will_complete(arg_1, arg_2):
  ...

# Start an asynchronous task - this task is guaranteed to run to completion and may be run on another host if no capacity is available, though preference is given to the host we are already on
async_task = will_complete.submit(arg_1, arg_2)

# Do some other work
# ...

# Get the results of the task - if the task produced an exception, it will be raised here
async_task_result = will_complete.result()
```

> TODO: add management / visibilty of running tasks to the op-dev dashboard


# service.init

Use this decorator around zero-argument functions that should be run when a service worker first starts up.

* `block` whether or not execution of this function should block the service from coming up or run in the background as it becomes available. A common use case is cache-warmup: during local development, you likely don't want to block the service from being available while a task warmup job completes, but you would in production.

``` python
@service.init(block=False)
def warm_up_caches():
  ...
```


# env

The `op_service.env` module exposes global variables that hold the service's configuration, and provide information about what version of code is running.

``` python
from op_service.env import OP_DEV_CONFIG, OP_DEV_METADATA
```


# Type validation

Authenticated JWT profiles and JSON data passed into functions decorated with `@service.route` via the `profile` and `data` args are automatically validated based on the type validation format you provide. The `validate` function can also be called directly (see examples below).

A type validation *input_format* can be a dictionary, a single-element list, or a single-argument callable that returns the parsed *input_data*. These objects can be nested. For lists, the input_format's first element (which must also be an input_format) will be applied to all elements in the input_data list. You can specify optional dictionary keys in the input format by enclosing them in square brackets.

``` python
from op_service.type_validation import validate

validate(None, None) # None
validate(5, int) # 5
validate("not_an_int", int) # InvalidInput exception
validate({ "n": 5 }, None) # None
validate({ "n": 5 }, { "n": int }) # { "n": 5 } (note that this creates a copy of the original dictionary)
validate({ "n": "5" }, { "n": int }) # { "n": 5 } (note that 5 is now an int because int(5) == 5)
validate({ "n": "not_an_int" }, { "n": int }) # InvalidInput exception
validate({ "a": 1 }, { "a": int, "[b]": int }) # { "a": 1 } (the lack of the optional "b" parameter is ignored)
validate({ "a": 1, "b": 2 }, { "a": int, "[b]": int }) # { "a": 1, "b": 2 }
validate({ "a": 1, "b": "not_an_int" }, { "a": int, "[b]": int }) # InvalidInput exception
validate([ 1, "2" ], [ int ]) # [ 1, 2 ]
validate([ 1, "not_an_int" ], [ int ]) # InvalidInput exception
```

The `op_service.type_validation_functions` module provides several commonly-used callables that can be used to validate input:

* `regex(r'[a-z]+')`
* `one_of('A', 'B', 'C')`
* `domain_name`
* `int_range('[4, 10]')`
* `numeric_range('[2.5, 5)')`
* `numeric`
* `iso_date_str`
* `iso_datetime_str`
* `non_empty_str`
* `uuid_str`
* `json_blob`
* `file_upload(content_types=[ 'image/jpeg' ], max_size=1024)`
* `is_true`
* `truthy(str)`
* `nullable(str)`
* `non_nullable(str)`
* `anything`


---


# Development

Use the following to install locally directly from this package:

``` bash
pip3 uninstall op-service -y
python3 setup.py clean --all && \
python3 setup.py build && \
python3 setup.py install --force && \
rm -rf build dist op_service.egg-info
```

In order to test the service outside of the context of the op-dev CLI, you need to specify an `OP_DEV_CONFIG` environmental variable:

``` bash
OP_DEV_CONFIG='{"op_alarm": {"type": "null", "config": {}}, "op_storage": {"type": "memory", "config": {}}, "jwt": {"secret": "123", "exp_s": 3600}}'
```

``` python
from op_service import service
```


To build the packages for distribution (or for testing in the context of other packages / services), first build it for
distribution in the 'dist' directory using this command (note that you'll want to make sure to increment the version
number so downstream dependencies pick up the changes), then make it available locally using a pypi package server:

``` bash
python3 setup.py clean --all && python3 setup.py build && python3 setup.py sdist && pypi-server run -p 8081 .
```

Then from downstream packages / service, you can link to the package using `--extra-index-url` - you can do this inside
a `requirements.txt` file, but then you'd have to be careful to increment the version number every time you made a
change, and it would force the Docker layer to reinstall ALL your dependencies every time. Therefore, I like to simply
add this as a last line in the Dockerfile:

```
RUN pip install --no-deps --force-reinstall --index-url http://host.docker.internal:8081/simple/ --trusted-host host.docker.internal op-service
```

You also used the following to automatically reload when the docker image for the dependent package rebuilds (cache
busting) for rapid iteration:

```
ADD "https://www.random.org/cgi-bin/randbyte?nbytes=10&format=h" skipcache
RUN pip install --no-deps --force-reinstall --index-url http://host.docker.internal:8081/simple/ --trusted-host host.docker.internal op-service
```
