Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

grpc-json: add option to convert gRPC status into JSON body (#3383) #8009

Merged
merged 10 commits into from
Sep 19, 2019
Merged
Show file tree
Hide file tree
Changes from 7 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
21 changes: 21 additions & 0 deletions api/envoy/config/filter/http/transcoder/v2/transcoder.proto
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,25 @@ message GrpcJsonTranscoder {
// not know them beforehand. Otherwise use ``ignored_query_parameters``.
// Defaults to false.
bool ignore_unknown_query_parameters = 8;

// Whether to convert gRPC status headers to JSON.
// When trailer indicates a gRPC error and there was no HTTP body, take ``google.rpc.Status``
// from the ``grpc-status-details-bin`` header and use it as JSON body.
// If there was no such header, make ``google.rpc.Status`` out of the ``grpc-status`` and
// ``grpc-message`` headers.
// The error details types must be present in the ``proto_descriptor``.
//
// Example:
//
// .. code-block:: proto
//
// import "google/rpc/error_details.proto";
//
// message Dummy {
// google.rpc.RequestInfo request_info = 1;
// }
//
// If a gRPC service uses the types from the ``google/rpc/error_details.proto``, its proto files
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not grokking the example or this paragraph. Where does Dummy appear? In config? On the wire?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the proto generating the descriptor set for transcoding.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs in this file already have two examples (for the match_incoming_request_route and the ignored_query_parameters options) that show user proto files used for making their proto descriptor.
And this is just another example of what should be written in user proto files.

This Dummy type is not used anywhere, but it's required as its contents ensures dependencies on types that have to be in a proto descriptor set.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for api approval: @mattklein123 since @htuch is OOO, do you have suggestion here? IMO this is enough given the examples in other fields does similar thing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH I don't understand this example either, but I don't have a lot of context. Perhaps you could describe what the output looks like given a particular response?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is some context:
Upstream server responds with an error in the following headers frame:

grpc-status: 5
grpc-status-details-bin: CAUSElJlc291cmNlIG5vdCBmb3VuZA

The grpc-status-details-bin header contains base64 encoded protobuf google.rpc.Status message. We transcode it into

HTTP/1.1 404 Not Found
grpc-status: 5
content-type: application/json
...

{"code":5,"message":"Resource not found"}

Now, google.rpc.Status has the optional "details" field, which can hold arbitrary user-defined types. E.g. grpc-status-details-bin: CAUSBUVycm9yGjYKKXR5cGUuZ29vZ2xlYXBpcy5jb20vaGVsbG93b3JsZC5IZWxsb1JlcGx5EgkKB2RldGFpbHM will be transcoded into

{"code":5,"message":"Error","details":[{"@type":"type.googleapis.com/helloworld.HelloReply","message":"details"}]}

Since Envoy knows nothing about this helloworld.HelloReply type, it should be in the proto descriptor set provided via config. And the example says "make sure that your details types are in the the proto descriptor set". "If those details types aren't used in any message types of your service, make a dummy message type for them".

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ascheglov actually, I don't think you really need the message dummy, only a include of error_details.proto or just pass error_details.proto to protoc when you generate the descriptor should work. protoc doesn't drop unused message descriptors when it generate them.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you potentially just update the description/docs to read more like #8009 (comment)? That makes sense to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ascheglov actually, I don't think you really need the message dummy, only a include of error_details.proto or just pass error_details.proto to protoc when you generate the descriptor should work. protoc doesn't drop unused message descriptors when it generate them.

Yes, that's true. With protoc --include_imports it's enough to just add an import.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// should reference at least one of those types.
bool convert_grpc_status = 9;
}
1 change: 1 addition & 0 deletions docs/root/intro/version_history.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Version history
* fault: added overrides for default runtime keys in :ref:`HTTPFault <envoy_api_msg_config.filter.http.fault.v2.HTTPFault>` filter.
* grpc: added :ref:`AWS IAM grpc credentials extension <envoy_api_file_envoy/config/grpc_credential/v2alpha/aws_iam.proto>` for AWS-managed xDS.
* grpc-json: added support for :ref:`ignoring unknown query parameters<envoy_api_field_config.filter.http.transcoder.v2.GrpcJsonTranscoder.ignore_unknown_query_parameters>`.
* grpc-json: added support for :ref:`the grpc-status-details-bin header<envoy_api_field_config.filter.http.transcoder.v2.GrpcJsonTranscoder.convert_grpc_status>`.
* header to metadata: added :ref:`PROTOBUF_VALUE <envoy_api_enum_value_config.filter.http.header_to_metadata.v2.Config.ValueType.PROTOBUF_VALUE>` and :ref:`ValueEncode <envoy_api_enum_config.filter.http.header_to_metadata.v2.Config.ValueEncode>` to support protobuf Value and Base64 encoding.
* http: added the ability to reject HTTP/1.1 requests with invalid HTTP header values, using the runtime feature `envoy.reloadable_features.strict_header_validation`.
* http: added the ability to :ref:`merge adjacent slashes<envoy_api_field_config.filter.network.http_connection_manager.v2.HttpConnectionManager.merge_slashes>` in the path.
Expand Down
1 change: 1 addition & 0 deletions include/envoy/http/header_map.h
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ class HeaderEntry {
HEADER_FUNC(GrpcAcceptEncoding) \
HEADER_FUNC(GrpcMessage) \
HEADER_FUNC(GrpcStatus) \
HEADER_FUNC(GrpcStatusDetailsBin) \
ascheglov marked this conversation as resolved.
Show resolved Hide resolved
HEADER_FUNC(GrpcTimeout) \
HEADER_FUNC(Host) \
HEADER_FUNC(KeepAlive) \
Expand Down
1 change: 1 addition & 0 deletions source/common/grpc/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ envoy_cc_library(
"//source/common/buffer:buffer_lib",
"//source/common/buffer:zero_copy_input_stream_lib",
"//source/common/common:assert_lib",
"//source/common/common:base64_lib",
"//source/common/common:empty_string",
"//source/common/common:enum_to_int",
"//source/common/common:hash_lib",
Expand Down
24 changes: 23 additions & 1 deletion source/common/grpc/common.cc
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include "common/buffer/buffer_impl.h"
#include "common/buffer/zero_copy_input_stream_impl.h"
#include "common/common/assert.h"
#include "common/common/base64.h"
#include "common/common/empty_string.h"
#include "common/common/enum_to_int.h"
#include "common/common/fmt.h"
Expand Down Expand Up @@ -54,7 +55,7 @@ absl::optional<Status::GrpcStatus> Common::getGrpcStatus(const Http::HeaderMap&

uint64_t grpc_status_code;
if (!grpc_status_header || grpc_status_header->value().empty()) {
return {};
return absl::nullopt;
}
if (!absl::SimpleAtoi(grpc_status_header->value().getStringView(), &grpc_status_code) ||
grpc_status_code > Status::GrpcStatus::MaximumValid) {
Expand All @@ -68,6 +69,27 @@ std::string Common::getGrpcMessage(const Http::HeaderMap& trailers) {
return entry ? std::string(entry->value().getStringView()) : EMPTY_STRING;
}

absl::optional<google::rpc::Status>
Common::getGrpcStatusDetailsBin(const Http::HeaderMap& trailers) {
const Http::HeaderEntry* details_header = trailers.GrpcStatusDetailsBin();
if (!details_header) {
return absl::nullopt;
}

// Some implementations use non-padded base64 encoding for grpc-status-details-bin.
auto decoded_value = Base64::decodeWithoutPadding(details_header->value().getStringView());
if (decoded_value.empty()) {
return absl::nullopt;
}

google::rpc::Status status;
if (!status.ParseFromString(decoded_value)) {
return absl::nullopt;
}

return {std::move(status)};
}

Buffer::InstancePtr Common::serializeToGrpcFrame(const Protobuf::Message& message) {
// http://www.grpc.io/docs/guides/wire.html
// Reserve enough space for the entire message and the 5 byte header.
Expand Down
9 changes: 9 additions & 0 deletions source/common/grpc/common.h
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ class Common {
*/
static std::string getGrpcMessage(const Http::HeaderMap& trailers);

/**
* Returns the decoded google.rpc.Status message from a given set of trailers, if present.
* @param trailers the trailers to parse.
* @return std::unique_ptr<google::rpc::Status> the gRPC status message or empty pointer if no
* grpc-status-details-bin trailer found or it was invalid.
*/
static absl::optional<google::rpc::Status>
getGrpcStatusDetailsBin(const Http::HeaderMap& trailers);

/**
* Parse gRPC header 'grpc-timeout' value to a duration in milliseconds.
* @param request_headers the header map from which to extract the value of 'grpc-timeout' header.
Expand Down
1 change: 1 addition & 0 deletions source/common/http/headers.h
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ class HeaderValues {
const LowerCaseString GrpcStatus{"grpc-status"};
const LowerCaseString GrpcTimeout{"grpc-timeout"};
const LowerCaseString GrpcAcceptEncoding{"grpc-accept-encoding"};
const LowerCaseString GrpcStatusDetailsBin{"grpc-status-details-bin"};
const LowerCaseString Host{":authority"};
const LowerCaseString HostLegacy{"host"};
const LowerCaseString KeepAlive{"keep-alive"};
Expand Down
1 change: 1 addition & 0 deletions source/common/protobuf/protobuf.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include "google/protobuf/any.pb.h"
#include "google/protobuf/descriptor.h"
#include "google/protobuf/descriptor.pb.h"
#include "google/protobuf/descriptor_database.h"
lizan marked this conversation as resolved.
Show resolved Hide resolved
#include "google/protobuf/empty.pb.h"
#include "google/protobuf/io/coded_stream.h"
#include "google/protobuf/io/zero_copy_stream.h"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,13 @@ JsonTranscoderConfig::JsonTranscoderConfig(
}

for (const auto& file : descriptor_set.file()) {
if (descriptor_pool_.BuildFile(file) == nullptr) {
throw EnvoyException("transcoding_filter: Unable to build proto descriptor pool");
}
addFileDescriptor(file);
}

convert_grpc_status_ = proto_config.convert_grpc_status();
if (convert_grpc_status_) {
addBuiltinSymbolDescriptor("google.protobuf.Any");
addBuiltinSymbolDescriptor("google.rpc.Status");
}

PathMatcherBuilder<const Protobuf::MethodDescriptor*> pmb;
Expand Down Expand Up @@ -164,10 +168,34 @@ JsonTranscoderConfig::JsonTranscoderConfig(
ignore_unknown_query_parameters_ = proto_config.ignore_unknown_query_parameters();
}

void JsonTranscoderConfig::addFileDescriptor(const Protobuf::FileDescriptorProto& file) {
if (descriptor_pool_.BuildFile(file) == nullptr) {
throw EnvoyException("transcoding_filter: Unable to build proto descriptor pool");
}
}

void JsonTranscoderConfig::addBuiltinSymbolDescriptor(const std::string& symbol_name) {
if (descriptor_pool_.FindFileContainingSymbol(symbol_name) != nullptr) {
return;
}

auto* builtin_pool = Protobuf::DescriptorPool::generated_pool();
if (!builtin_pool) {
return;
}

Protobuf::DescriptorPoolDatabase pool_database(*builtin_pool);
Protobuf::FileDescriptorProto file_proto;
pool_database.FindFileContainingSymbol(symbol_name, &file_proto);
addFileDescriptor(file_proto);
}

bool JsonTranscoderConfig::matchIncomingRequestInfo() const {
return match_incoming_request_route_;
}

bool JsonTranscoderConfig::convertGrpcStatus() const { return convert_grpc_status_; }

ProtobufUtil::Status JsonTranscoderConfig::createTranscoder(
const Http::HeaderMap& headers, ZeroCopyInputStream& request_input,
google::grpc::transcoding::TranscoderInputStream& response_input,
Expand Down Expand Up @@ -244,6 +272,14 @@ JsonTranscoderConfig::methodToRequestInfo(const Protobuf::MethodDescriptor* meth
return ProtobufUtil::Status();
}

ProtobufUtil::Status
JsonTranscoderConfig::translateProtoMessageToJson(const Protobuf::Message& message,
ascheglov marked this conversation as resolved.
Show resolved Hide resolved
std::string* json_out) {
return ProtobufUtil::BinaryToJsonString(
type_helper_->Resolver(), Grpc::Common::typeUrl(message.GetDescriptor()->full_name()),
message.SerializeAsString(), json_out, print_options_);
}

JsonTranscoderFilter::JsonTranscoderFilter(JsonTranscoderConfig& config) : config_(config) {}

Http::FilterHeadersStatus JsonTranscoderFilter::decodeHeaders(Http::HeaderMap& headers,
Expand Down Expand Up @@ -385,6 +421,8 @@ Http::FilterDataStatus JsonTranscoderFilter::encodeData(Buffer::Instance& data,
return Http::FilterDataStatus::Continue;
}

has_body_ = true;

// TODO(dio): Add support for streaming case.
if (has_http_body_output_) {
buildResponseFromHttpBodyOutput(*response_headers_, data);
Expand Down Expand Up @@ -420,25 +458,42 @@ Http::FilterTrailersStatus JsonTranscoderFilter::encodeTrailers(Http::HeaderMap&

if (data.length()) {
encoder_callbacks_->addEncodedData(data, true);
has_body_ = true;
}

if (method_->server_streaming()) {
// For streaming case, the headers are already sent, so just continue here.
return Http::FilterTrailersStatus::Continue;
}

// If there was no previous headers frame, this |trailers| map is our |response_headers_|,
// so there is no need to copy headers from one to the other.
bool is_trailers_only_response = response_headers_ == &trailers;

const absl::optional<Grpc::Status::GrpcStatus> grpc_status =
Grpc::Common::getGrpcStatus(trailers);
bool status_converted_to_json = grpc_status && maybeConvertGrpcStatus(*grpc_status, trailers);

if (!grpc_status || grpc_status.value() == Grpc::Status::GrpcStatus::InvalidCode) {
response_headers_->Status()->value(enumToInt(Http::Code::ServiceUnavailable));
} else {
response_headers_->Status()->value(Grpc::Utility::grpcToHttpStatus(grpc_status.value()));
response_headers_->insertGrpcStatus().value(enumToInt(grpc_status.value()));
if (!status_converted_to_json && !is_trailers_only_response) {
response_headers_->insertGrpcStatus().value(enumToInt(grpc_status.value()));
}
}

const Http::HeaderEntry* grpc_message_header = trailers.GrpcMessage();
if (grpc_message_header) {
response_headers_->insertGrpcMessage().value(*grpc_message_header);
if (status_converted_to_json && is_trailers_only_response) {
// Drop the gRPC status headers, we already have them in the JSON body.
response_headers_->removeGrpcStatus();
response_headers_->removeGrpcMessage();
response_headers_->removeGrpcStatusDetailsBin();
} else if (!status_converted_to_json && !is_trailers_only_response) {
// Copy the grpc-message header if it exists.
const Http::HeaderEntry* grpc_message_header = trailers.GrpcMessage();
if (grpc_message_header) {
response_headers_->insertGrpcMessage().value(*grpc_message_header);
}
}

// remove Trailer headers if the client connection was http/1
Expand Down Expand Up @@ -496,6 +551,56 @@ void JsonTranscoderFilter::buildResponseFromHttpBodyOutput(Http::HeaderMap& resp
}
}

bool JsonTranscoderFilter::maybeConvertGrpcStatus(Grpc::Status::GrpcStatus grpc_status,
Http::HeaderMap& trailers) {
if (!config_.convertGrpcStatus()) {
return false;
}

// We do not support responses with a separate trailer frame.
// TODO(ascheglov): remove this if after HCM can buffer data added from |encodeTrailers|.
if (response_headers_ != &trailers) {
return false;
}

// Send a serialized status only if there was no body.
if (has_body_) {
return false;
}

if (grpc_status == Grpc::Status::GrpcStatus::Ok ||
grpc_status == Grpc::Status::GrpcStatus::InvalidCode) {
return false;
}

auto status_details = Grpc::Common::getGrpcStatusDetailsBin(trailers);
if (!status_details) {
// If no rpc.Status object was sent in the grpc-status-details-bin header,
// construct it from the grpc-status and grpc-message headers.
status_details.emplace();
status_details->set_code(grpc_status);

auto grpc_message_header = trailers.GrpcMessage();
if (grpc_message_header) {
auto message = grpc_message_header->value().getStringView();
status_details->set_message(message.data(), message.size());
}
}

std::string json_status;
auto translate_status = config_.translateProtoMessageToJson(*status_details, &json_status);
if (!translate_status.ok()) {
ENVOY_LOG(debug, "Transcoding status error {}", translate_status.ToString());
return false;
}

response_headers_->insertContentType().value().setReference(
Http::Headers::get().ContentTypeValues.Json);
Buffer::OwnedImpl status_data(json_status);
encoder_callbacks_->addEncodedData(status_data, false);
return true;
}

bool JsonTranscoderFilter::hasHttpBodyAsOutputType() {
return method_->output_type()->full_name() == google::api::HttpBody::descriptor()->full_name();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,25 @@ class JsonTranscoderConfig : public Logger::Loggable<Logger::Id::config> {
std::unique_ptr<google::grpc::transcoding::Transcoder>& transcoder,
const Protobuf::MethodDescriptor*& method_descriptor);

/**
* Converts an arbitrary protobuf message to JSON.
*/
ProtobufUtil::Status translateProtoMessageToJson(const Protobuf::Message& message,
std::string* json_out);

/**
* If true, skip clearing the route cache after the incoming request has been modified.
* This allows Envoy to select the upstream cluster based on the incoming request
* rather than the outgoing.
*/
bool matchIncomingRequestInfo() const;

/**
* If true, when trailer indicates a gRPC error and there was no HTTP body,
* make google.rpc.Status out of gRPC status headers and use it as JSON body.
*/
bool convertGrpcStatus() const;

private:
/**
* Convert method descriptor to RequestInfo that needed for transcoding library
Expand All @@ -83,13 +95,17 @@ class JsonTranscoderConfig : public Logger::Loggable<Logger::Id::config> {
google::grpc::transcoding::RequestInfo* info);

private:
void addFileDescriptor(const Protobuf::FileDescriptorProto& file);
void addBuiltinSymbolDescriptor(const std::string& symbol_name);

Protobuf::DescriptorPool descriptor_pool_;
google::grpc::transcoding::PathMatcherPtr<const Protobuf::MethodDescriptor*> path_matcher_;
std::unique_ptr<google::grpc::transcoding::TypeHelper> type_helper_;
Protobuf::util::JsonPrintOptions print_options_;

bool match_incoming_request_route_{false};
bool ignore_unknown_query_parameters_{false};
bool convert_grpc_status_{false};
};

using JsonTranscoderConfigSharedPtr = std::shared_ptr<JsonTranscoderConfig>;
Expand Down Expand Up @@ -125,6 +141,7 @@ class JsonTranscoderFilter : public Http::StreamFilter, public Logger::Loggable<
private:
bool readToBuffer(Protobuf::io::ZeroCopyInputStream& stream, Buffer::Instance& data);
void buildResponseFromHttpBodyOutput(Http::HeaderMap& response_headers, Buffer::Instance& data);
bool maybeConvertGrpcStatus(Grpc::Status::GrpcStatus grpc_status, Http::HeaderMap& trailers);
bool hasHttpBodyAsOutputType();

JsonTranscoderConfig& config_;
Expand All @@ -139,6 +156,7 @@ class JsonTranscoderFilter : public Http::StreamFilter, public Logger::Loggable<

bool error_{false};
bool has_http_body_output_{false};
bool has_body_{false};
};

} // namespace GrpcJsonTranscoder
Expand Down
22 changes: 22 additions & 0 deletions test/common/grpc/common_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,28 @@ TEST(GrpcContextTest, GetGrpcTimeout) {
// so we don't test for them.
}

TEST(GrpcCommonTest, GrpcStatusDetailsBin) {
Http::TestHeaderMapImpl empty_trailers;
EXPECT_FALSE(Common::getGrpcStatusDetailsBin(empty_trailers));

Http::TestHeaderMapImpl invalid_value{{"grpc-status-details-bin", "invalid"}};
EXPECT_FALSE(Common::getGrpcStatusDetailsBin(invalid_value));

Http::TestHeaderMapImpl unpadded_value{
{"grpc-status-details-bin", "CAUSElJlc291cmNlIG5vdCBmb3VuZA"}};
auto status = Common::getGrpcStatusDetailsBin(unpadded_value);
ASSERT_TRUE(status);
EXPECT_EQ(Status::GrpcStatus::NotFound, status->code());
EXPECT_EQ("Resource not found", status->message());

Http::TestHeaderMapImpl padded_value{
{"grpc-status-details-bin", "CAUSElJlc291cmNlIG5vdCBmb3VuZA=="}};
status = Common::getGrpcStatusDetailsBin(padded_value);
ASSERT_TRUE(status);
EXPECT_EQ(Status::GrpcStatus::NotFound, status->code());
EXPECT_EQ("Resource not found", status->message());
}

TEST(GrpcContextTest, ToGrpcTimeout) {
Http::HeaderString value;

Expand Down
Loading