Skip to content

Commit

Permalink
Merge pull request #6 from villainy/client-interceptor
Browse files Browse the repository at this point in the history
Add support for client interceptors
  • Loading branch information
d5h authored Dec 31, 2020
2 parents b01d7ab + 44c6998 commit 30b5173
Show file tree
Hide file tree
Showing 10 changed files with 609 additions and 25 deletions.
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Changelog
All notable changes to this project will be documented in this file.

The format is based roughly 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).

## [0.13.0] - 2020-12-27
### Added
- Client-side interceptors (thanks Michael Morgan!)

### Changed (breaking)
- Added a `context` parameter to special case functions in the `testing` module

### Fixed
- Build issue caused by [pip upgrade](https://github.com/cjolowicz/hypermodern-python/issues/174#issuecomment-745364836)
- Docs not building correctly in nox

## [0.12.0] - 2020-10-07
### Added
- Support for all streaming RPCs

## [0.11.0] - 2020-07-24
### Added
- Expose some imports from the top-level

### Changed (breaking)
- Rename to `ServerInterceptor` (do not intend to make breaking name changes after this)

## [0.10.0] - 2020-07-23
### Added
- `status_on_unknown_exception` to `ExceptionToStatusInterceptor`
- `py.typed` (so `mypy` will type check the package)

### Fixed
- Allow protobuf version 4.0.x which is coming out soon and is backwards compatible
- Testing in autodocs
- Turn on xdoctest
- Prevent autodoc from outputting default namedtuple docs

### Changed (breaking)
- Rename `Interceptor` to `ServiceInterceptor`

## [0.9.0] - 2020-07-22
### Added
- The `testing` module
- Some helper functions
- Improved test coverage

### Fixed
- Protobuf compatibility improvements

## [0.8.0] - 2020-07-19
### Added
- An `Interceptor` base class, to make it easy to define your own service interceptors
- An `ExceptionToStatusInterceptor` interceptor
69 changes: 69 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ $ pip install grpc-interceptor[testing]

# Usage

## Server Interceptor

To define your own interceptor (we can use `ExceptionToStatusInterceptor` as an example):

```python
Expand Down Expand Up @@ -99,6 +101,73 @@ helper functions so they can call `context.abort` or `context.set_code`. It allo
the more Pythonic approach of just raising an exception from anywhere in the code,
and having it be handled automatically.

## Client Interceptor

We will use an invocation metadata injecting interceptor as an example of defining
a client interceptor:

```python
from grpc_interceptor import ClientCallDetails, ClientInterceptor

class MetadataClientInterceptor(ClientInterceptor):

def intercept(
self,
method: Callable,
request_or_iterator: Any,
call_details: grpc.ClientCallDetails,
):
"""Override this method to implement a custom interceptor.
This method is called for all unary and streaming RPCs. The interceptor
implementation should call `method` using a `grpc.ClientCallDetails` and the
`request_or_iterator` object as parameters. The `request_or_iterator`
parameter may be type checked to determine if this is a singluar request
for unary RPCs or an iterator for client-streaming or client-server streaming
RPCs.
Args:
method: A function that proceeds with the invocation by executing the next
interceptor in the chain or invoking the actual RPC on the underlying
channel.
request_or_iterator: RPC request message or iterator of request messages
for streaming requests.
call_details: Describes an RPC to be invoked.
Returns:
The type of the return should match the type of the return value received
by calling `method`. This is an object that is both a
`Call <https://grpc.github.io/grpc/python/grpc.html#grpc.Call>`_ for the
RPC and a `Future <https://grpc.github.io/grpc/python/grpc.html#grpc.Future>`_.
The actual result from the RPC can be got by calling `.result()` on the
value returned from `method`.
"""
new_details = ClientCallDetails(
call_details.method,
call_details.timeout,
[("authorization", "Bearer mysecrettoken")],
call_details.credentials,
call_details.wait_for_ready,
call_details.compression,
)

return method(request_or_iterator, new_details)
```

Now inject your interceptor when you create the ``grpc`` channel:

```python
interceptors = [MetadataClientInterceptor()]
with grpc.insecure_channel("grpc-server:50051") as channel:
channel = grpc.intercept_channel(channel, *interceptors)
...
```

Client interceptors can also be used to retry RPCs that fail due to specific errors, or
a host of other use cases. There are some basic approaches in the tests to get you
started.

# Documentation

Read the [complete documentation here](https://grpc-interceptor.readthedocs.io/).
91 changes: 82 additions & 9 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ The ``grpc_interceptor`` package provides the following:
that set the gRPC status code correctly (rather than the default of every exception
resulting in an ``UNKNOWN`` status code). This is something for which pretty much any
service will have a use.
* A ``ClientInterceptor`` base class, to make it easy to define your own client-side interceptors.
Do not confuse this with the ``grpc.ClientInterceptor`` class.
* An optional testing framework. If you're writing your own interceptors, this is useful.
If you're just using ``ExceptionToStatusInterceptor`` then you don't need this.

Expand All @@ -52,7 +54,10 @@ To also install the testing framework:
Usage
-----

To define your own interceptor (we can use a simplified version of
Server Interceptors
^^^^^^^^^^^^^^^^^^^

To define your own server interceptor (we can use a simplified version of
``ExceptionToStatusInterceptor`` as an example):

.. code-block:: python
Expand Down Expand Up @@ -127,6 +132,74 @@ helper functions so they can call ``context.abort`` or ``context.set_code``. It
the more Pythonic approach of just raising an exception from anywhere in the code,
and having it be handled automatically.

Client Interceptors
^^^^^^^^^^^^^^^^^^^

We will use an invocation metadata injecting interceptor as an example of defining
a client interceptor:

.. code-block:: python
from grpc_interceptor import ClientCallDetails, ClientInterceptor
class MetadataClientInterceptor(ClientInterceptor):
def intercept(
self,
method: Callable,
request_or_iterator: Any,
call_details: grpc.ClientCallDetails,
):
"""Override this method to implement a custom interceptor.
This method is called for all unary and streaming RPCs. The interceptor
implementation should call `method` using a `grpc.ClientCallDetails` and the
`request_or_iterator` object as parameters. The `request_or_iterator`
parameter may be type checked to determine if this is a singluar request
for unary RPCs or an iterator for client-streaming or client-server streaming
RPCs.
Args:
method: A function that proceeds with the invocation by executing the next
interceptor in the chain or invoking the actual RPC on the underlying
channel.
request_or_iterator: RPC request message or iterator of request messages
for streaming requests.
call_details: Describes an RPC to be invoked.
Returns:
The type of the return should match the type of the return value received
by calling `method`. This is an object that is both a
`Call <https://grpc.github.io/grpc/python/grpc.html#grpc.Call>`_ for the
RPC and a `Future <https://grpc.github.io/grpc/python/grpc.html#grpc.Future>`_.
The actual result from the RPC can be got by calling `.result()` on the
value returned from `method`.
"""
new_details = ClientCallDetails(
call_details.method,
call_details.timeout,
[("authorization", "Bearer mysecrettoken")],
call_details.credentials,
call_details.wait_for_ready,
call_details.compression,
)
return method(request_or_iterator, new_details)
Now inject your interceptor when you create the ``grpc`` channel:

.. code-block:: python
interceptors = [MetadataClientInterceptor()]
with grpc.insecure_channel("grpc-server:50051") as channel:
channel = grpc.intercept_channel(channel, *interceptors)
...
Client interceptors can also be used to retry RPCs that fail due to specific errors, or
a host of other use cases. There are some basic approaches in the tests to get you
started.

Testing
-------

Expand All @@ -137,10 +210,13 @@ framework, and also allows chaining interceptors.

The crux of the testing framework is the ``dummy_client`` context manager. It provides
a client to a gRPC service, which by defaults echos the ``input`` field of the request
to the ``output`` field of the response. You can also provide a ``special_cases`` dict
which tells the service to call arbitrary functions when the input matches a key in the
dict. This allows you to test things like exceptions being thrown. Here's an example
(again using ``ExceptionToStatusInterceptor``):
to the ``output`` field of the response.

You can also provide a ``special_cases`` dict which tells the service to call arbitrary
functions when the input matches a key in the dict. This allows you to test things like
exceptions being thrown.

Here's an example (again using ``ExceptionToStatusInterceptor``):

.. code-block:: python
Expand All @@ -162,7 +238,4 @@ dict. This allows you to test things like exceptions being thrown. Here's an exa
Limitations
-----------

These are the current limitations, although supporting these is possible. Contributions
or requests are welcome.

* The package only provides service interceptors.
Contributions or requests are welcome for any limitations you may find.
3 changes: 2 additions & 1 deletion noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def coverage(session):
@nox.session(python="3.8")
def docs(session):
"""Build the documentation."""
session.run("poetry", "install", "--no-dev", external=True)
session.run("poetry", "install", "--no-dev", "-E", "testing", external=True)
install_with_constraints(session, "sphinx")
session.run("sphinx-build", "docs", "docs/_build")

Expand Down Expand Up @@ -107,6 +107,7 @@ def install_with_constraints(session, *args, **kwargs):
"--dev",
"--format=requirements.txt",
f"--output={requirements}",
"--without-hashes",
external=True,
)
session.install(f"--constraint={requirements}", *args, **kwargs)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "grpc-interceptor"
version = "0.12.0"
version = "0.13.0"
description = "Simplifies gRPC interceptors"
license = "MIT"
readme = "README.md"
Expand Down
3 changes: 3 additions & 0 deletions src/grpc_interceptor/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
"""Simplified Python gRPC interceptors."""

from grpc_interceptor.client import ClientCallDetails, ClientInterceptor
from grpc_interceptor.exception_to_status import ExceptionToStatusInterceptor
from grpc_interceptor.server import MethodName, parse_method_name, ServerInterceptor


__all__ = [
"ClientCallDetails",
"ClientInterceptor",
"ExceptionToStatusInterceptor",
"MethodName",
"parse_method_name",
Expand Down
Loading

0 comments on commit 30b5173

Please sign in to comment.