Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/source/grpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,64 @@ stages:
status: "OK" # Also the default
```

If the status is not `OK` then there is no response message to check, so specifying a `body` as
well as a non-`OK` `status` is an error.

### Checking errors

The error message which the server returned along with the status - the string passed to
`set_details`/`abort` on the server - is checked with the `error_message` key. As well as a plain
string this can be one of the [type sentinels](./core_concepts/types.md) which match a string,
such as `!re_search`:

```yaml
stages:
- name: Say hello to nobody
grpc_request:
service: helloworld.v1.Greeter/SayHello
body:
name: ""
grpc_response:
status: "INVALID_ARGUMENT"
error_message: !re_search "no name"
```

A server can also attach structured error details to the response. This is not part of gRPC
itself - as described in the [gRPC error handling
documentation](https://grpc.io/docs/guides/error/), it is a Google convention for using gRPC with
protobuf, where the server puts a `google.rpc.Status` containing arbitrary messages into the
`grpc-status-details-bin` trailing metadata. Tavern only officially supports protobuf, so this is
how the `details` key is checked.

Each message in the status is converted to a mapping including the `@type` key identifying it,
and the list of them is matched like any other response body. Fields which the server did not set
are left out, so only the fields actually returned need to be specified even with strict key
checking turned on:

```yaml
stages:
- name: Say hello to nobody
grpc_request:
service: helloworld.v1.Greeter/SayHello
body:
name: ""
grpc_response:
status: "INVALID_ARGUMENT"
details:
- "@type": type.googleapis.com/google.rpc.BadRequest
field_violations:
- field: name
description: "name must not be empty"
```

The protobuf definition of each message in the details has to have been loaded (see below) for it
to be checked - the `google.rpc` messages such as `BadRequest` and `ErrorInfo` are available
already because Tavern depends on them.

In earlier versions of Tavern, `details` was a string that Tavern compared with the error message.
A string `details` still works, but it gives a warning. The next major version will remove it.
Use `error_message` instead.

## Loading protobuf definitions

There are 3 different ways Tavern will try to load the appropriate proto definitions:
Expand Down
37 changes: 37 additions & 0 deletions example/grpc/tavern_grpc_example/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,60 @@
import helloworld_v2_compiled_pb2_grpc as helloworld_pb2_grpc_v2
import helloworld_v3_reflected_pb2 as helloworld_pb2_v3
import helloworld_v3_reflected_pb2_grpc as helloworld_pb2_grpc_v3
from google.protobuf import any_pb2
from google.rpc import code_pb2, error_details_pb2, status_pb2
from grpc_interceptor import ServerInterceptor
from grpc_interceptor.exceptions import GrpcException
from grpc_reflection.v1alpha import reflection
from grpc_status import rpc_status


def abort_with_rich_details(context: grpc.ServicerContext) -> None:
"""Abort the request with a google.rpc.Status

As well as the 'details' string this attaches the 'rich' error details to the response,
which are the arbitrary messages packed into the status.
"""
packed = any_pb2.Any()
packed.Pack(
error_details_pb2.BadRequest(
field_violations=[
error_details_pb2.BadRequest.FieldViolation(
field="name", description="name must not be empty"
)
]
)
)

context.abort_with_status(
rpc_status.to_status(
status_pb2.Status(
code=code_pb2.INVALID_ARGUMENT,
message="no name given",
details=[packed],
)
)
)


class GreeterV1(helloworld_pb2_grpc_v1.GreeterServicer):
def SayHello(self, request, context):
if not request.name:
abort_with_rich_details(context)
return helloworld_pb2_v1.HelloReply(message=f"Hello, {request.name}!")


class GreeterV2(helloworld_pb2_grpc_v2.GreeterServicer):
def SayHello(self, request, context):
if not request.name:
abort_with_rich_details(context)
return helloworld_pb2_v2.HelloReply(message=f"Hello, {request.name}!")


class GreeterV3(helloworld_pb2_grpc_v3.GreeterServicer):
def SayHello(self, request, context):
if not request.name:
abort_with_rich_details(context)
return helloworld_pb2_v3.HelloReply(message=f"Hello, {request.name}!")


Expand Down
167 changes: 167 additions & 0 deletions example/grpc/tests/test_grpc.tavern.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -441,3 +441,170 @@ stages:
status: "GREETINGS"
body:
message: "Hello, Jim!"

---

test_name: Test cannot expect a body along with a non-OK status

includes:
- !include common.yaml

grpc: *grpc_spec

_xfail:
run: "'body' was specified in response, but expected status code was not 'OK'"

stages:
- name: Echo text
grpc_request:
service: helloworld.v1.Greeter/SayHello
body:
name: "Jim"
grpc_response:
status: "ABORTED"
body:
message: "Hello, Jim!"

---

test_name: Test checking the error message of an error response

includes:
- !include common.yaml

grpc: *grpc_spec

stages:
- name: Say hello to nobody
grpc_request:
service: helloworld.v2.Greeter/SayHello
body:
name: ""
grpc_response:
status: "INVALID_ARGUMENT"
error_message: "no name given"

- name: The error message can be matched with a regex as well
grpc_request:
service: helloworld.v2.Greeter/SayHello
body:
name: ""
grpc_response:
status: "INVALID_ARGUMENT"
error_message: !re_search "no name"

---

test_name: Test checking the error details of an error response

includes:
- !include common.yaml

grpc: *grpc_spec

stages:
- name: Say hello to nobody
grpc_request:
service: helloworld.v2.Greeter/SayHello
body:
name: ""
grpc_response:
status: "INVALID_ARGUMENT"
details:
- "@type": type.googleapis.com/google.rpc.BadRequest
field_violations:
- field: name
description: "name must not be empty"

---

test_name: Test the error message of an error response not matching

includes:
- !include common.yaml

grpc: *grpc_spec

_xfail:
run: "expected[\"error_message\"] = 'a completely different message'"

stages:
- name: Say hello to nobody
grpc_request:
service: helloworld.v2.Greeter/SayHello
body:
name: ""
grpc_response:
status: "INVALID_ARGUMENT"
error_message: "a completely different message"

---

test_name: Test the error details of an error response not matching

includes:
- !include common.yaml

grpc: *grpc_spec

_xfail:
run: "expected[\"details\"][\"0\"][\"field_violations\"][\"0\"][\"field\"] = 'greeting'"

stages:
- name: Say hello to nobody
grpc_request:
service: helloworld.v2.Greeter/SayHello
body:
name: ""
grpc_response:
status: "INVALID_ARGUMENT"
details:
- "@type": type.googleapis.com/google.rpc.BadRequest
field_violations:
- field: greeting
description: "name must not be empty"

---

test_name: Test expecting error details when the response has none

includes:
- !include common.yaml

grpc: *grpc_spec

_xfail:
run: "expected error details"

stages:
- name: Echo text
grpc_request:
service: helloworld.v2.Greeter/SayHello
body:
name: "John"
grpc_response:
status: "OK"
details:
- "@type": type.googleapis.com/google.rpc.BadRequest

---

test_name: Test plain string error details are checked against the error message

includes:
- !include common.yaml

grpc: *grpc_spec

# TODO: re-enable once 'details' is changed
# _xfail: verify

stages:
- name: Say hello to nobody
grpc_request:
service: helloworld.v2.Greeter/SayHello
body:
name: ""
grpc_response:
status: "INVALID_ARGUMENT"
details: "no name given"
10 changes: 9 additions & 1 deletion tavern/_core/pydantic_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,23 @@
from pydantic import (
BaseModel,
ConfigDict,
InstanceOf,
ValidationError,
ValidationInfo,
field_validator,
)

from tavern._core import exceptions
from tavern._core.loader import (
AnythingSentinel,
BoolToken,
FloatToken,
ForceIncludeToken,
IntToken,
ListSentinel,
RawStrToken,
RegexSentinel,
StrSentinel,
TypeSentinel,
)

Expand Down Expand Up @@ -218,7 +223,10 @@ class GRPCRequestSpec(_BaseKeyValidator):
class GRPCResponseSpec(_BaseKeyValidator):
body: Optional[dict] = None
status: Optional[Union[str, int, list[str], list[int]]] = None
details: Optional[Union[str, dict]] = None
error_message: Optional[
Union[str, StrSentinel, InstanceOf[RegexSentinel], AnythingSentinel]
] = None
details: Optional[Union[list, str, ListSentinel, AnythingSentinel]] = None
save: Optional[dict] = None


Expand Down
3 changes: 3 additions & 0 deletions tavern/_core/schema/tests.schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ schema;stage:
status:
type: any
func: validate_grpc_status_is_valid_or_list_of_names
error_message:
type: any
required: false
details:
type: any
required: false
Expand Down
19 changes: 17 additions & 2 deletions tavern/_plugins/grpc/jsonschema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,25 @@ definitions:
- type: string
- type: integer

error_message:
description: |
Expected error message on the response - the string passed to set_details/abort by the
server. As well as a plain string this can be a sentinel matching one, like !re_search.
oneOf:
- type: string
- type: object

details:
type: object
description: |
Expected error details attached to the response - the messages packed into the
google.rpc.Status, each one including the '@type' identifying it. A string is deprecated
and is checked against the error message instead.
oneOf:
- type: array
- type: string
- type: object

proto_body:
body:
type: object

timeout:
Expand Down
Loading
Loading