diff --git a/api/envoy/config/filter/http/transformation/v2/transformation_filter.proto b/api/envoy/config/filter/http/transformation/v2/transformation_filter.proto index 9230fdeb6..25849dc4a 100644 --- a/api/envoy/config/filter/http/transformation/v2/transformation_filter.proto +++ b/api/envoy/config/filter/http/transformation/v2/transformation_filter.proto @@ -153,6 +153,19 @@ message Transformation { // Extractions can be used to extract information from the request/response. // The extracted information can then be referenced in template fields. message Extraction { + // The mode of operation for the extraction. + enum Mode { + // Default mode. Extract the value of the subgroup-th capturing group. + EXTRACT = 0; + // Replace the value of the subgroup-th capturing group with the replacement_text. + // Note: replacement_text must be set for this mode. + SINGLE_REPLACE = 1; + // Replace all matches of the regex in the source with the replacement_text. + // Note: replacement_text must be set for this mode. + // Note: subgroup is ignored for this mode. configuration will fail if subgroup is set. + // Note: restrictions on the regex are different for this mode. See the regex field for more details. + REPLACE_ALL = 2; + } // The source of the extraction oneof source { @@ -162,15 +175,37 @@ message Extraction { google.protobuf.Empty body = 4; } - // Only strings matching this regular expression will be part of the - // extraction. The most simple value for this field is '.*', which matches the - // whole source. The field is required. If extraction fails the result is an - // empty value. + // The regex field specifies the regular expression used for matching against the source content. This field is required. + // - In EXTRACT mode, the entire source must match the regex. The subgroup-th capturing group, + // if specified, determines which part of the match is extracted. if the regex does not match the source + // the result of the extraction will be an empty value. + // - In SINGLE_REPLACE mode, the regex also needs to match the entire source. The subgroup-th capturing group + // is targeted for replacement with the replacement_text. if the regex does not match the source + // the result of the extraction will be the source itself. + // - In REPLACE_ALL mode, the regex is applied repeatedly to find all occurrences within the source that match. + // Each matching occurrence is replaced with the replacement_text, and the subgroup field is not used. if the + // regex does not match the source the result of the extraction will be the source itself. string regex = 2; // If your regex contains capturing groups, use this field to determine which // group should be selected. + // For EXTRACT and SINGLE_REPLACE, refers to the portion of the text + // to extract/replace. + // Config will be rejected if this is specified in REPLACE_ALL mode. uint32 subgroup = 3; + + // Used in SINGLE_REPLACE and REPLACE_ALL modes. + // `replacement_text` is used to format the substitution for matched sequences in the input string + // - In SINGLE_REPLACE mode, the content in the subgroup-th capturing group is replaced with the `replacement_text`. + // - In REPLACE_ALL mode, each sequence matching the specified regex in the in the input is replaced with the `replacement_text`. + // The replacement_text may contain special syntax, such as $1, $2, etc., to refer to captured groups within the regular expression. + // The value contained within `replacement_text` is treated as a string, and is passed to std::regex_replace as the replacement string. + // see https://en.cppreference.com/w/cpp/regex/regex_replace for more details. + google.protobuf.StringValue replacement_text = 5; + + // The mode of operation for the extraction. + // Defaults to EXTRACT. + Mode mode = 6; } // Defines a transformation template. diff --git a/changelog/v1.27.3-patch2/extractor_regex_replace.yaml b/changelog/v1.27.3-patch2/extractor_regex_replace.yaml new file mode 100644 index 000000000..fc7d119b4 --- /dev/null +++ b/changelog/v1.27.3-patch2/extractor_regex_replace.yaml @@ -0,0 +1,7 @@ +changelog: +- type: NEW_FEATURE + resolvesIssue: false + issueLink: https://github.com/solo-io/gloo/issues/8706 + description: > + Update transformation filter extractors to support regex + replace/replace all operations on extracted values. \ No newline at end of file diff --git a/source/extensions/filters/http/transformation/inja_transformer.cc b/source/extensions/filters/http/transformation/inja_transformer.cc index 02c430249..c5022de7e 100644 --- a/source/extensions/filters/http/transformation/inja_transformer.cc +++ b/source/extensions/filters/http/transformation/inja_transformer.cc @@ -56,7 +56,9 @@ getHeader(const Http::RequestOrResponseHeaderMap &header_map, Extractor::Extractor(const envoy::api::v2::filter::http::Extraction &extractor) : headername_(extractor.header()), body_(extractor.has_body()), group_(extractor.subgroup()), - extract_regex_(Solo::Regex::Utility::parseStdRegex(extractor.regex())) { + extract_regex_(Solo::Regex::Utility::parseStdRegex(extractor.regex())), + replacement_text_(extractor.has_replacement_text() ? std::make_optional(extractor.replacement_text().value()) : std::nullopt), + mode_(extractor.mode()) { // mark count == number of sub groups, and we need to add one for match number // 0 so we test for < instead of <= see: // http://www.cplusplus.com/reference/regex/basic_regex/mark_count/ @@ -65,6 +67,26 @@ Extractor::Extractor(const envoy::api::v2::filter::http::Extraction &extractor) fmt::format("group {} requested for regex with only {} sub groups", group_, extract_regex_.mark_count())); } + + switch (mode_) { + case ExtractionApi::EXTRACT: + break; + case ExtractionApi::SINGLE_REPLACE: + if (!replacement_text_.has_value()) { + throw EnvoyException("SINGLE_REPLACE mode set but no replacement text provided"); + } + break; + case ExtractionApi::REPLACE_ALL: + if (!replacement_text_.has_value()) { + throw EnvoyException("REPLACE_ALL mode set but no replacement text provided"); + } + if (group_ != 0) { + throw EnvoyException("REPLACE_ALL mode set but subgroup is not 0"); + } + break; + default: + throw EnvoyException("Unknown mode"); + } } absl::string_view @@ -84,6 +106,37 @@ Extractor::extract(Http::StreamFilterCallbacks &callbacks, } } +std::string +Extractor::extractDestructive(Http::StreamFilterCallbacks &callbacks, + const Http::RequestOrResponseHeaderMap &header_map, + GetBodyFunc &body) const { + // determines which destructive extraction function to call based on the mode + auto extractFunc = [&](Http::StreamFilterCallbacks& callbacks, absl::string_view sv) { + switch (mode_) { + case ExtractionApi::SINGLE_REPLACE: + return replaceIndividualValue(callbacks, sv); + case ExtractionApi::REPLACE_ALL: + return replaceAllValues(callbacks, sv); + default: + // Handle unknown mode + throw EnvoyException("Cannot use extractDestructive with unsupported mode"); + } + }; + + if (body_) { + const std::string &string_body = body(); + absl::string_view sv(string_body); + return extractFunc(callbacks, sv); + } else { + const Http::HeaderMap::GetResult header_entries = getHeader(header_map, headername_); + if (header_entries.empty()) { + return ""; + } + const auto &header_value = header_entries[0]->value().getStringView(); + return extractFunc(callbacks, header_value); + } +} + absl::string_view Extractor::extractValue(Http::StreamFilterCallbacks &callbacks, absl::string_view value) const { @@ -105,6 +158,63 @@ Extractor::extractValue(Http::StreamFilterCallbacks &callbacks, return ""; } +// Match a regex against the input value and replace the matched subgroup with the replacement_text_ value +std::string +Extractor::replaceIndividualValue(Http::StreamFilterCallbacks &callbacks, + absl::string_view value) const { + std::match_results regex_result; + + // if there are no matches, return the original input value + if (!std::regex_search(value.begin(), value.end(), regex_result, extract_regex_)) { + ENVOY_STREAM_LOG(debug, "replaceIndividualValue: extractor regex did not match input. Returning input", callbacks); + return std::string(value.begin(), value.end()); + } + + // if the subgroup specified is greater than the number of subgroups in the regex, return the original input value + if (group_ >= regex_result.size()) { + // this should never happen as we test this in the ctor. + ASSERT("no such group in the regex"); + ENVOY_STREAM_LOG(debug, "replaceIndividualValue: invalid group specified for regex. Returning input", callbacks); + return std::string(value.begin(), value.end()); + } + + // if the regex doesn't match the entire input value, return the original input value + if (regex_result[0].length() != long(value.length())) { + ENVOY_STREAM_LOG(debug, "replaceIndividualValue: Regex did not match entire input value. This is not allowed in SINGLE_REPLACE mode. Returning input", callbacks); + return std::string(value.begin(), value.end()); + } + + // Create a new string with the maximum possible length after replacement + auto max_possible_length = value.length() + replacement_text_.value().length(); + std::string replaced; + replaced.reserve(max_possible_length); + + auto subgroup_start = regex_result[group_].first; + auto subgroup_end = regex_result[group_].second; + + // Copy the initial part of the string until the match + replaced.assign(value.begin(), subgroup_start); + + // Append the replacement text + replaced += replacement_text_.value(); + + // Append the remaining part of the string after the match + replaced.append(subgroup_end, value.end()); + + return replaced; +} + +// Match a regex against the input value and replace all instances of the regex with the replacement_text_ value +std::string +Extractor::replaceAllValues(Http::StreamFilterCallbacks&, + absl::string_view value) const { + std::string input(value.begin(), value.end()); + std::string replaced; + + // replace all instances of the regex in the input value with the replacement_text_ value + return std::regex_replace(input, extract_regex_, replacement_text_.value(), std::regex_constants::match_not_null); +} + // A TransformerInstance is constructed by the InjaTransformer constructor at config time // on the main thread. It access thread-local storage which is populated during the // InjaTransformer::transform method call, which happens on the request path on any @@ -181,6 +291,11 @@ json TransformerInstance::extracted_callback(const inja::Arguments &args) const if (value_it != ctx.extractions_->end()) { return value_it->second; } + + const auto destructive_value_it = ctx.destructive_extractions_->find(name); + if (destructive_value_it != ctx.destructive_extractions_->end()) { + return destructive_value_it->second; + } return ""; } @@ -546,26 +661,70 @@ void InjaTransformer::transform(Http::RequestOrResponseHeaderMap &header_map, } // get the extractions std::unordered_map extractions; + std::unordered_map destructive_extractions; + if (advanced_templates_) { - extractions.reserve(extractors_.size()); + auto extractions_size = 0; + auto destructive_extractions_size = 0; + for (const auto &named_extractor : extractors_) { + switch(named_extractor.second.mode()) { + case ExtractionApi::REPLACE_ALL: + case ExtractionApi::SINGLE_REPLACE: { + destructive_extractions_size++; + break; + } + case ExtractionApi::EXTRACT: { + extractions_size++; + break; + } + default: { + PANIC_DUE_TO_CORRUPT_ENUM + } + } + } + + extractions.reserve(extractions_size); + destructive_extractions.reserve(destructive_extractions_size); } for (const auto &named_extractor : extractors_) { const std::string &name = named_extractor.first; - if (advanced_templates_) { - extractions[name] = - named_extractor.second.extract(callbacks, header_map, get_body); - } else { - absl::string_view name_to_split = name; - json *current = &json_body; + + // prepare variables for non-advanced_templates_ scenario + absl::string_view name_to_split; + json* current = nullptr; + if (!advanced_templates_) { + name_to_split = name; + current = &json_body; for (size_t pos = name_to_split.find("."); pos != std::string::npos; pos = name_to_split.find(".")) { auto &&field_name = name_to_split.substr(0, pos); current = &(*current)[std::string(field_name)]; name_to_split = name_to_split.substr(pos + 1); } - (*current)[std::string(name_to_split)] = - named_extractor.second.extract(callbacks, header_map, get_body); + } + + switch(named_extractor.second.mode()) { + case ExtractionApi::REPLACE_ALL: + case ExtractionApi::SINGLE_REPLACE: { + if (advanced_templates_) { + destructive_extractions[name] = named_extractor.second.extractDestructive(callbacks, header_map, get_body); + } else { + (*current)[std::string(name_to_split)] = named_extractor.second.extractDestructive(callbacks, header_map, get_body); + } + break; + } + case ExtractionApi::EXTRACT: { + if (advanced_templates_) { + extractions[name] = named_extractor.second.extract(callbacks, header_map, get_body); + } else { + (*current)[std::string(name_to_split)] = named_extractor.second.extract(callbacks, header_map, get_body); + } + break; + } + default: { + PANIC_DUE_TO_CORRUPT_ENUM + } } } @@ -584,6 +743,7 @@ void InjaTransformer::transform(Http::RequestOrResponseHeaderMap &header_map, typed_tls_data.request_headers_ = request_headers; typed_tls_data.body_ = &get_body; typed_tls_data.extractions_ = &extractions; + typed_tls_data.destructive_extractions_ = &destructive_extractions; typed_tls_data.context_ = &json_body; typed_tls_data.environ_ = &environ_; typed_tls_data.cluster_metadata_ = cluster_metadata; diff --git a/source/extensions/filters/http/transformation/inja_transformer.h b/source/extensions/filters/http/transformation/inja_transformer.h index 50819316e..97861da47 100644 --- a/source/extensions/filters/http/transformation/inja_transformer.h +++ b/source/extensions/filters/http/transformation/inja_transformer.h @@ -25,6 +25,7 @@ namespace HttpFilters { namespace Transformation { using GetBodyFunc = std::function; +using ExtractionApi = envoy::api::v2::filter::http::Extraction; struct ThreadLocalTransformerContext : public ThreadLocal::ThreadLocalObject { public: @@ -33,6 +34,7 @@ struct ThreadLocalTransformerContext : public ThreadLocal::ThreadLocalObject { const Http::RequestOrResponseHeaderMap *header_map_; const Http::RequestHeaderMap *request_headers_; const GetBodyFunc *body_; + const std::unordered_map *destructive_extractions_; const std::unordered_map *extractions_; const nlohmann::json *context_; const std::unordered_map *environ_; @@ -82,15 +84,24 @@ class Extractor : Logger::Loggable { absl::string_view extract(Http::StreamFilterCallbacks &callbacks, const Http::RequestOrResponseHeaderMap &header_map, GetBodyFunc &body) const; - + std::string extractDestructive(Http::StreamFilterCallbacks &callbacks, + const Http::RequestOrResponseHeaderMap &header_map, + GetBodyFunc &body) const; + const ExtractionApi::Mode& mode() const { return mode_; } private: absl::string_view extractValue(Http::StreamFilterCallbacks &callbacks, absl::string_view value) const; + std::string replaceIndividualValue(Http::StreamFilterCallbacks &callbacks, + absl::string_view value) const; + std::string replaceAllValues(Http::StreamFilterCallbacks &callbacks, + absl::string_view value) const; const Http::LowerCaseString headername_; const bool body_; const unsigned int group_; const std::regex extract_regex_; + const std::optional replacement_text_; + const ExtractionApi::Mode mode_; }; class InjaTransformer : public Transformer { diff --git a/test/extensions/filters/http/transformation/BUILD b/test/extensions/filters/http/transformation/BUILD index 194cc01ae..33ccc718a 100644 --- a/test/extensions/filters/http/transformation/BUILD +++ b/test/extensions/filters/http/transformation/BUILD @@ -28,6 +28,21 @@ envoy_gloo_cc_test( ], ) +envoy_gloo_cc_test( + name = "inja_transformer_replace_test", + srcs = ["inja_transformer_replace_test.cc"], + repository = "@envoy", + deps = [ + "//source/extensions/filters/http/transformation:inja_transformer_lib", + "@envoy//source/common/common:random_generator_lib", + "@envoy//source/common/common:base64_lib", + "@envoy//test/test_common:environment_lib", + "@envoy//test/mocks/http:http_mocks", + "@envoy//test/mocks/server:server_mocks", + "@envoy//test/mocks/upstream:upstream_mocks", + ], +) + envoy_cc_test_binary( name = "inja_transformer_speed_test", srcs = ["inja_transformer_speed_test.cc"], diff --git a/test/extensions/filters/http/transformation/inja_transformer_replace_test.cc b/test/extensions/filters/http/transformation/inja_transformer_replace_test.cc new file mode 100644 index 000000000..ed3f18386 --- /dev/null +++ b/test/extensions/filters/http/transformation/inja_transformer_replace_test.cc @@ -0,0 +1,463 @@ +#include "source/extensions/filters/http/solo_well_known_names.h" +#include "source/extensions/filters/http/transformation/inja_transformer.h" +#include "source/common/common/base64.h" +#include "source/common/common/random_generator.h" + +#include "test/mocks/common.h" +#include "test/mocks/http/mocks.h" +#include "test/mocks/server/mocks.h" +#include "test/mocks/thread_local/mocks.h" +#include "test/mocks/upstream/mocks.h" +#include "test/test_common/environment.h" + +#include "fmt/format.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include + +using testing::_; +using testing::AtLeast; +using testing::HasSubstr; +using testing::Invoke; +using testing::Return; +using testing::ReturnPointee; +using testing::ReturnRef; +using testing::SaveArg; +using testing::WithArg; + +using json = nlohmann::json; + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Transformation { + +using TransformationTemplate = + envoy::api::v2::filter::http::TransformationTemplate; + +namespace { +std::function empty_body = [] { return EMPTY_STRING; }; +} + +class TransformerInstanceTest : public testing::Test { +protected: + NiceMock rng_; + NiceMock tls_; +}; + +TEST(Extraction, ExtractAndReplaceValueFromBodySubgroup) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex(".*(body)"); + extractor.set_subgroup(1); + extractor.mutable_replacement_text()->set_value("BAZ"); + extractor.set_mode(ExtractionApi::SINGLE_REPLACE); + + NiceMock callbacks; + std::string body("not json body"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)); + + EXPECT_EQ("not json BAZ", res); +} + +TEST(Extraction, ExtractAndReplaceValueFromFullBody) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}}; + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex(".*"); + extractor.set_subgroup(0); + extractor.mutable_replacement_text()->set_value("BAZ"); + extractor.set_mode(ExtractionApi::SINGLE_REPLACE); + + NiceMock callbacks; + std::string body("not json body"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)); + + EXPECT_EQ("BAZ", res); +} + +// Note to maintainers: if we don't use the `match_not_null` format specifier +// when calling std::regex_replace, this regex will match the input string twice +// and the replacement will be applied twice. Because we are using the `match_not_null` +// format specifier, the regex will only match the input string once and the replacement +// will only be applied once. +TEST(Extraction, ExtractAndReplaceAllFromFullBody) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex(".*"); + extractor.set_subgroup(0); + extractor.mutable_replacement_text()->set_value("BAZ"); + extractor.set_mode(ExtractionApi::REPLACE_ALL); + + NiceMock callbacks; + std::string body("not json body"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)); + + EXPECT_EQ("BAZ", res); +} + +TEST(Extraction, AttemptReplaceFromPartialMatch) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + // Unless we are in `REPLACE_ALL` mode, we require regexes to match the entire target string + // because this only matches a substring, it should not be replaced + extractor.set_regex("body"); + extractor.set_subgroup(0); + extractor.mutable_replacement_text()->set_value("BAZ"); + extractor.set_mode(ExtractionApi::SINGLE_REPLACE); + + NiceMock callbacks; + std::string body("not json body"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)); + + EXPECT_EQ(body, res); +} + +TEST(Extraction, AttemptReplaceFromPartialMatchNonNilSubgroup) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + // Unless we are in `REPLACE_ALL` mode, we require regexes to match the entire target string + // because this only matches a substring, it should not be replaced + // Note -- the subgroup in the regex is introduced here so that this config is not + // rejected when constructing the extractor + extractor.set_regex("(body)"); + extractor.set_subgroup(1); + extractor.mutable_replacement_text()->set_value("BAZ"); + extractor.set_mode(ExtractionApi::SINGLE_REPLACE); + + NiceMock callbacks; + std::string body("not json body"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)); + + EXPECT_EQ(body, res); +} + +TEST(Extraction, AttemptReplaceFromNoMatchNonNilSubgroup) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex("(does not match)"); + extractor.set_subgroup(1); + extractor.mutable_replacement_text()->set_value("BAZ"); + extractor.set_mode(ExtractionApi::SINGLE_REPLACE); + + NiceMock callbacks; + std::string body("not json body"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)); + + EXPECT_EQ(body, res); +} + +TEST(Extraction, ReplaceFromFullLiteralMatch) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex("not json body"); + extractor.set_subgroup(0); + extractor.mutable_replacement_text()->set_value("BAZ"); + extractor.set_mode(ExtractionApi::SINGLE_REPLACE); + + NiceMock callbacks; + std::string body("not json body"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)); + + EXPECT_EQ("BAZ", res); +} + +TEST(Extraction, AttemptToReplaceFromInvalidSubgroup) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex(".*"); + extractor.set_subgroup(1); + extractor.mutable_replacement_text()->set_value("BAZ"); + extractor.set_mode(ExtractionApi::SINGLE_REPLACE); + + NiceMock callbacks; + std::string body("not json body"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + EXPECT_THROW_WITH_MESSAGE(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc), EnvoyException, "group 1 requested for regex with only 0 sub groups"); +} + +TEST(Extraction, ReplaceInNestedSubgroups) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex(".*(not (json) body)"); + extractor.set_subgroup(2); + auto replacement_text = "BAZ"; + extractor.mutable_replacement_text()->set_value(replacement_text); + extractor.set_mode(ExtractionApi::SINGLE_REPLACE); + + NiceMock callbacks; + std::string body("not json body"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)); + + EXPECT_EQ("not BAZ body", res); +} + +TEST(Extraction, ReplaceWithSubgroupUnset) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex(".*(not (json) body)"); + // subgroup is unset + auto replacement_text = "BAZ"; + extractor.mutable_replacement_text()->set_value(replacement_text); + extractor.set_mode(ExtractionApi::SINGLE_REPLACE); + + NiceMock callbacks; + std::string body("not json body"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)); + + EXPECT_EQ("BAZ", res); +} + +TEST(Extraction, ReplaceNoMatch) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex("this will not match the input string"); + extractor.set_subgroup(0); + extractor.mutable_replacement_text()->set_value("BAZ"); + extractor.set_mode(ExtractionApi::SINGLE_REPLACE); + + NiceMock callbacks; + std::string body("not json body"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)); + + EXPECT_EQ(body, res); +} + +TEST(Extraction, ReplacementTextLongerThanOriginalString) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex(".*(body)"); + extractor.set_subgroup(1); + extractor.mutable_replacement_text()->set_value("this is a longer string than the original"); + extractor.set_mode(ExtractionApi::SINGLE_REPLACE); + + NiceMock callbacks; + std::string body("not json body"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)); + + EXPECT_EQ("not json this is a longer string than the original", res); +} + +TEST(Extraction, NilReplace) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex(".*(body)"); + extractor.set_subgroup(1); + extractor.mutable_replacement_text()->set_value(""); + extractor.set_mode(ExtractionApi::SINGLE_REPLACE); + + NiceMock callbacks; + std::string body("not json body"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)); + + EXPECT_EQ("not json ", res); +} + +TEST(Extraction, NilReplaceWithSubgroupUnset) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}}; + + // subgroup is unset + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex(".*(body)"); + extractor.mutable_replacement_text()->set_value(""); + extractor.set_mode(ExtractionApi::SINGLE_REPLACE); + + NiceMock callbacks; + std::string body("not json body"); + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, empty_body)); + + EXPECT_EQ("", res); +} + +TEST(Extraction, HeaderReplaceHappyPath) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}, {"foo", "bar"}}; + + ExtractionApi extractor; + extractor.set_header("foo"); + extractor.set_regex("bar"); + extractor.set_subgroup(0); + extractor.mutable_replacement_text()->set_value("BAZ"); + extractor.set_mode(ExtractionApi::SINGLE_REPLACE); + + NiceMock callbacks; + std::string body("not json body"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)); + + EXPECT_EQ("BAZ", res); +} + +TEST(Extraction, ReplaceAllWithReplacementTextUnset) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}, {"foo", "bar"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex("bar"); + extractor.set_subgroup(0); + extractor.set_mode(ExtractionApi::REPLACE_ALL); + + NiceMock callbacks; + std::string body("bar bar bar"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + + EXPECT_THROW(std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)), EnvoyException); +} + +TEST(Extraction, ReplaceAllWithSubgroupSet) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}, {"foo", "bar"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex(".*(bar).*"); + // Note that the regex contains enough capture groups + // that this (in theory) could be valid subgroup + extractor.set_subgroup(1); + extractor.mutable_replacement_text()->set_value("BAZ"); + // However, subgroup needs to be unset (i.e., 0) for replace all to work + // so this config should be rejected + extractor.set_mode(ExtractionApi::REPLACE_ALL); + + NiceMock callbacks; + std::string body("bar bar bar"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + + EXPECT_THROW(std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)), EnvoyException); +} + +TEST(Extraction, ReplaceAllHappyPath) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}, {"foo", "bar"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex("bar"); + extractor.set_subgroup(0); + extractor.mutable_replacement_text()->set_value("BAZ"); + extractor.set_mode(ExtractionApi::REPLACE_ALL); + + NiceMock callbacks; + std::string body("bar bar bar"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)); + + EXPECT_EQ("BAZ BAZ BAZ", res); +} + +TEST(Extraction, IndividualReplaceIdentity) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}, {"foo", "bar"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex(".*(bar).*"); + extractor.set_subgroup(1); + extractor.mutable_replacement_text()->set_value("bar"); + extractor.set_mode(ExtractionApi::SINGLE_REPLACE); + + NiceMock callbacks; + std::string body("bar bar bar"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)); + + EXPECT_EQ("bar bar bar", res); +} + +TEST(Extraction, ReplaceAllIdentity) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}, {"foo", "bar"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex("bar"); + extractor.set_subgroup(0); + extractor.mutable_replacement_text()->set_value("bar"); + extractor.set_mode(ExtractionApi::REPLACE_ALL); + + NiceMock callbacks; + std::string body("bar bar bar"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)); + + EXPECT_EQ("bar bar bar", res); +} + +TEST(Extraction, ReplaceAllNoMatch) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}, {"foo", "bar"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex("this will not match the input string"); + extractor.set_subgroup(0); + extractor.mutable_replacement_text()->set_value("BAZ"); + extractor.set_mode(ExtractionApi::REPLACE_ALL); + + NiceMock callbacks; + std::string body("not json body"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)); + + EXPECT_EQ("not json body", res); +} + +TEST(Extraction, ReplaceAllCapture) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}, {"foo", "bar"}}; + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex("(not) (json) (body)"); + extractor.set_subgroup(0); + extractor.mutable_replacement_text()->set_value("$2 $3"); + extractor.set_mode(ExtractionApi::REPLACE_ALL); + + NiceMock callbacks; + std::string body("not json body"); + GetBodyFunc bodyfunc = [&body]() -> const std::string & { return body; }; + + std::string res(Extractor(extractor).extractDestructive(callbacks, headers, bodyfunc)); + + EXPECT_EQ("json body", res); +} + +} // namespace Transformation +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/transformation/inja_transformer_test.cc b/test/extensions/filters/http/transformation/inja_transformer_test.cc index 367ca6a85..f0fd7e6d0 100644 --- a/test/extensions/filters/http/transformation/inja_transformer_test.cc +++ b/test/extensions/filters/http/transformation/inja_transformer_test.cc @@ -51,6 +51,7 @@ void fill_slot( const Http::RequestHeaderMap *request_headers, GetBodyFunc &body, const std::unordered_map &extractions, + const std::unordered_map &destructive_extractions, const nlohmann::json &context, const std::unordered_map &environ, const envoy::config::core::v3::Metadata *cluster_metadata) { @@ -62,6 +63,7 @@ void fill_slot( typed_slot.request_headers_ = request_headers; typed_slot.body_ = &body; typed_slot.extractions_ = &extractions; + typed_slot.destructive_extractions_ = &destructive_extractions; typed_slot.context_ = &context; typed_slot.environ_ = &environ; typed_slot.cluster_metadata_ = cluster_metadata; @@ -72,12 +74,13 @@ TEST_F(TransformerInstanceTest, ReplacesValueFromContext) { originalbody["field1"] = "value1"; Http::TestRequestHeaderMapImpl headers; std::unordered_map extractions; + std::unordered_map destructive_extractions; std::unordered_map env; envoy::config::core::v3::Metadata *cluster_metadata{}; auto slot = tls_.allocateSlot(); fill_slot(slot, - headers, &headers, empty_body, extractions, originalbody, env, cluster_metadata); + headers, &headers, empty_body, extractions, destructive_extractions, originalbody, env, cluster_metadata); TransformerInstance t(*slot, rng_); auto res = t.render(t.parse("{{field1}}")); @@ -93,12 +96,13 @@ TEST_F(TransformerInstanceTest, ReplacesValueFromInlineHeader) { Http::TestRequestHeaderMapImpl headers{ {":method", "GET"}, {":authority", "www.solo.io"}, {":path", path}}; std::unordered_map extractions; + std::unordered_map destructive_extractions; std::unordered_map env; envoy::config::core::v3::Metadata *cluster_metadata{}; auto slot = tls_.allocateSlot(); fill_slot(slot, - headers, &headers, empty_body, extractions, originalbody, env, cluster_metadata); + headers, &headers, empty_body, extractions, destructive_extractions, originalbody, env, cluster_metadata); TransformerInstance t(*slot, rng_); @@ -116,12 +120,13 @@ TEST_F(TransformerInstanceTest, ReplacesValueFromCustomHeader) { {":path", "/getsomething"}, {"x-custom-header", header}}; std::unordered_map extractions; + std::unordered_map destructive_extractions; std::unordered_map env; envoy::config::core::v3::Metadata *cluster_metadata{}; auto slot = tls_.allocateSlot(); fill_slot(slot, - headers, &headers, empty_body, extractions, originalbody, env, cluster_metadata); + headers, &headers, empty_body, extractions, destructive_extractions, originalbody, env, cluster_metadata); TransformerInstance t(*slot, rng_); @@ -133,6 +138,7 @@ TEST_F(TransformerInstanceTest, ReplacesValueFromCustomHeader) { TEST_F(TransformerInstanceTest, ReplaceFromExtracted) { json originalbody; std::unordered_map extractions; + std::unordered_map destructive_extractions; absl::string_view field = "res"; extractions["f"] = field; Http::TestRequestHeaderMapImpl headers; @@ -141,7 +147,7 @@ TEST_F(TransformerInstanceTest, ReplaceFromExtracted) { auto slot = tls_.allocateSlot(); fill_slot(slot, - headers, &headers, empty_body, extractions, originalbody, env, cluster_metadata); + headers, &headers, empty_body, extractions, destructive_extractions, originalbody, env, cluster_metadata); TransformerInstance t(*slot, rng_); @@ -153,6 +159,7 @@ TEST_F(TransformerInstanceTest, ReplaceFromExtracted) { TEST_F(TransformerInstanceTest, ReplaceFromNonExistentExtraction) { json originalbody; std::unordered_map extractions; + std::unordered_map destructive_extractions; extractions["foo"] = absl::string_view("bar"); Http::TestRequestHeaderMapImpl headers; std::unordered_map env; @@ -160,7 +167,7 @@ TEST_F(TransformerInstanceTest, ReplaceFromNonExistentExtraction) { auto slot = tls_.allocateSlot(); fill_slot(slot, - headers, &headers, empty_body, extractions, originalbody, env, cluster_metadata); + headers, &headers, empty_body, extractions, destructive_extractions, originalbody, env, cluster_metadata); TransformerInstance t(*slot, rng_); @@ -172,6 +179,7 @@ TEST_F(TransformerInstanceTest, ReplaceFromNonExistentExtraction) { TEST_F(TransformerInstanceTest, Environment) { json originalbody; std::unordered_map extractions; + std::unordered_map destructive_extractions; Http::TestRequestHeaderMapImpl headers; std::unordered_map env; envoy::config::core::v3::Metadata *cluster_metadata{}; @@ -179,7 +187,7 @@ TEST_F(TransformerInstanceTest, Environment) { auto slot = tls_.allocateSlot(); fill_slot(slot, - headers, &headers, empty_body, extractions, originalbody, env, cluster_metadata); + headers, &headers, empty_body, extractions, destructive_extractions, originalbody, env, cluster_metadata); TransformerInstance t(*slot, rng_); @@ -190,6 +198,7 @@ TEST_F(TransformerInstanceTest, Environment) { TEST_F(TransformerInstanceTest, EmptyEnvironment) { json originalbody; std::unordered_map extractions; + std::unordered_map destructive_extractions; Http::TestRequestHeaderMapImpl headers; std::unordered_map env; @@ -197,7 +206,7 @@ TEST_F(TransformerInstanceTest, EmptyEnvironment) { auto slot = tls_.allocateSlot(); fill_slot(slot, - headers, &headers, empty_body, extractions, originalbody, env, cluster_metadata); + headers, &headers, empty_body, extractions, destructive_extractions, originalbody, env, cluster_metadata); TransformerInstance t(*slot, rng_); @@ -208,6 +217,7 @@ TEST_F(TransformerInstanceTest, EmptyEnvironment) { TEST_F(TransformerInstanceTest, ClusterMetadata) { json originalbody; std::unordered_map extractions; + std::unordered_map destructive_extractions; Http::TestRequestHeaderMapImpl headers; std::unordered_map env; @@ -219,7 +229,7 @@ TEST_F(TransformerInstanceTest, ClusterMetadata) { auto slot = tls_.allocateSlot(); fill_slot(slot, - headers, &headers, empty_body, extractions, originalbody, env, &cluster_metadata); + headers, &headers, empty_body, extractions, destructive_extractions, originalbody, env, &cluster_metadata); TransformerInstance t(*slot, rng_); @@ -230,6 +240,7 @@ TEST_F(TransformerInstanceTest, ClusterMetadata) { TEST_F(TransformerInstanceTest, EmptyClusterMetadata) { json originalbody; std::unordered_map extractions; + std::unordered_map destructive_extractions; Http::TestRequestHeaderMapImpl headers; std::unordered_map env; @@ -237,7 +248,7 @@ TEST_F(TransformerInstanceTest, EmptyClusterMetadata) { auto slot = tls_.allocateSlot(); fill_slot(slot, - headers, &headers, empty_body, extractions, originalbody, env, cluster_metadata); + headers, &headers, empty_body, extractions, destructive_extractions, originalbody, env, cluster_metadata); TransformerInstance t(*slot, rng_); @@ -248,6 +259,7 @@ TEST_F(TransformerInstanceTest, EmptyClusterMetadata) { TEST_F(TransformerInstanceTest, RequestHeaders) { json originalbody; std::unordered_map extractions; + std::unordered_map destructive_extractions; Http::TestResponseHeaderMapImpl response_headers{{":status", "200"}}; Http::TestRequestHeaderMapImpl request_headers{{":method", "GET"}}; @@ -256,7 +268,7 @@ TEST_F(TransformerInstanceTest, RequestHeaders) { auto slot = tls_.allocateSlot(); fill_slot(slot, - response_headers, &request_headers, empty_body, extractions, originalbody, env, cluster_metadata); + response_headers, &request_headers, empty_body, extractions, destructive_extractions, originalbody, env, cluster_metadata); TransformerInstance t(*slot, rng_); @@ -269,7 +281,7 @@ TEST(Extraction, ExtractIdFromHeader) { Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":authority", "www.solo.io"}, {":path", "/users/123"}}; - envoy::api::v2::filter::http::Extraction extractor; + ExtractionApi extractor; extractor.set_header(":path"); extractor.set_regex("/users/(\\d+)"); extractor.set_subgroup(1); @@ -284,7 +296,7 @@ TEST(Extraction, ExtractorWorkWithNewlines) { Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":authority", "www.solo.io"}, {":path", "/users/123"}}; - envoy::api::v2::filter::http::Extraction extractor; + ExtractionApi extractor; extractor.mutable_body(); extractor.set_regex("[\\S\\s]*"); extractor.set_subgroup(0); @@ -302,7 +314,7 @@ TEST(Extraction, ExtractorFail) { Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":authority", "www.solo.io"}, {":path", "/users/123"}}; - envoy::api::v2::filter::http::Extraction extractor; + ExtractionApi extractor; extractor.set_header(":path"); extractor.set_regex("ILLEGAL REGEX \\ \\ \\ \\ a\\ \\a\\ a\\ \\d+)"); extractor.set_subgroup(1); @@ -314,7 +326,7 @@ TEST(Extraction, ExtractorFailOnOutOfRangeGroup) { Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":authority", "www.solo.io"}, {":path", "/users/123"}}; - envoy::api::v2::filter::http::Extraction extractor; + ExtractionApi extractor; extractor.set_header(":path"); extractor.set_regex("(\\d+)"); extractor.set_subgroup(123); @@ -332,7 +344,7 @@ TEST_F(TransformerTest, transform) { {":path", "/users/123"}}; Buffer::OwnedImpl body("{\"a\":\"456\"}"); - envoy::api::v2::filter::http::Extraction extractor; + ExtractionApi extractor; extractor.set_header(":path"); extractor.set_regex("/users/(\\d+)"); extractor.set_subgroup(1); @@ -364,7 +376,7 @@ TEST_F(TransformerTest, transformSimple) { {":path", "/users/123"}}; Buffer::OwnedImpl body("{\"a\":\"456\"}"); - envoy::api::v2::filter::http::Extraction extractor; + ExtractionApi extractor; extractor.set_header(":path"); extractor.set_regex("/users/(\\d+)"); extractor.set_subgroup(1); @@ -458,7 +470,7 @@ TEST_F(TransformerTest, transformSimpleNestedStructs) { {":path", "/users/123"}}; Buffer::OwnedImpl body("{\"a\":\"456\"}"); - envoy::api::v2::filter::http::Extraction extractor; + ExtractionApi extractor; extractor.set_header(":path"); extractor.set_regex("/users/(\\d+)"); extractor.set_subgroup(1); @@ -523,7 +535,7 @@ TEST_F(TransformerTest, transformMergeExtractorsToBody) { transformation.mutable_merge_extractors_to_body(); - envoy::api::v2::filter::http::Extraction extractor; + ExtractionApi extractor; extractor.set_header(":path"); extractor.set_regex("/users/(\\d+)"); extractor.set_subgroup(1); @@ -540,6 +552,42 @@ TEST_F(TransformerTest, transformMergeExtractorsToBody) { EXPECT_EQ("{\"ext1\":\"123\"}", res); } +TEST_F(TransformerTest, transformMergeReplaceExtractorsToBody) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, + {":authority", "www.solo.io"}, + {"x-test", "789"}, + {":path", "/users/123"}}; + // in passthrough mode the filter gives us an empty body + std::string emptyBody = ""; + Buffer::OwnedImpl body(emptyBody); + + TransformationTemplate transformation; + + transformation.mutable_merge_extractors_to_body(); + + ExtractionApi extractor; + extractor.set_header(":path"); + extractor.set_regex("/users/(\\d+)"); + extractor.set_subgroup(1); + extractor.mutable_replacement_text()->set_value("456"); + extractor.set_mode(ExtractionApi::SINGLE_REPLACE); + (*transformation.mutable_extractors())["ext1"] = extractor; + + transformation.set_advanced_templates(false); + + InjaTransformer transformer(transformation, rng_, google::protobuf::BoolValue(), tls_); + NiceMock callbacks; + + transformer.transform(headers, &headers, body, callbacks); + + std::string res = body.toString(); + + // With replacement text, we replace the portion of the header value + // in the specified subgroup with the replacement text, and then the + // value of the replaced input is the value of the extraction. + EXPECT_EQ("{\"ext1\":\"/users/456\"}", res); +} + TEST_F(TransformerTest, transformBodyNotSet) { Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":authority", "www.solo.io"}, @@ -574,7 +622,7 @@ TEST_F(InjaTransformerTest, transformWithHyphens) { {":path", "/accounts/764b.0f_0f-7319-4b29-bbd0-887a39705a70"}}; Buffer::OwnedImpl body("{}"); - envoy::api::v2::filter::http::Extraction extractor; + ExtractionApi extractor; extractor.set_header(":path"); extractor.set_regex("/accounts/([\\-._[:alnum:]]+)"); extractor.set_subgroup(1); @@ -622,10 +670,11 @@ TEST_F(InjaTransformerTest, DontParseBodyAndExtractFromIt) { transformation.set_parse_body_behavior(TransformationTemplate::DontParse); transformation.set_advanced_templates(true); - envoy::api::v2::filter::http::Extraction extractor; + ExtractionApi extractor; extractor.mutable_body(); extractor.set_regex("not ([\\-._[:alnum:]]+) body"); extractor.set_subgroup(1); + extractor.set_mode(ExtractionApi::EXTRACT); (*transformation.mutable_extractors())["param"] = extractor; transformation.mutable_body()->set_text("{{extraction(\"param\")}}"); @@ -637,6 +686,63 @@ TEST_F(InjaTransformerTest, DontParseBodyAndExtractFromIt) { EXPECT_EQ(body.toString(), "json"); } +TEST_F(InjaTransformerTest, DontParseBodyAndExtractFromReplacementText) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}}; + Buffer::OwnedImpl body("not json body"); + + TransformationTemplate transformation; + transformation.set_parse_body_behavior(TransformationTemplate::DontParse); + transformation.set_advanced_templates(true); + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex("not ([\\-._[:alnum:]]+) body"); + extractor.set_subgroup(1); + extractor.mutable_replacement_text()->set_value("JSON"); + extractor.set_mode(ExtractionApi::SINGLE_REPLACE); + (*transformation.mutable_extractors())["param"] = extractor; + + transformation.mutable_body()->set_text("{{extraction(\"param\")}}"); + + InjaTransformer transformer(transformation, rng_, google::protobuf::BoolValue(), tls_); + + NiceMock callbacks; + transformer.transform(headers, &headers, body, callbacks); + EXPECT_EQ(body.toString(), "not JSON body"); +} + +TEST_F(InjaTransformerTest, DestructiveAndNonDestructiveExtractors) { + Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}}; + Buffer::OwnedImpl body("not json body"); + + TransformationTemplate transformation; + transformation.set_parse_body_behavior(TransformationTemplate::DontParse); + transformation.set_advanced_templates(true); + + ExtractionApi extractor; + extractor.mutable_body(); + extractor.set_regex("not ([\\-._[:alnum:]]+) body"); + extractor.set_subgroup(1); + extractor.set_mode(ExtractionApi::EXTRACT); + (*transformation.mutable_extractors())["param"] = extractor; + + ExtractionApi destructive_extractor; + destructive_extractor.mutable_body(); + destructive_extractor.set_regex("not ([\\-._[:alnum:]]+) body"); + destructive_extractor.set_subgroup(1); + destructive_extractor.mutable_replacement_text()->set_value("JSON"); + destructive_extractor.set_mode(ExtractionApi::SINGLE_REPLACE); + (*transformation.mutable_extractors())["destructive_param"] = destructive_extractor; + + transformation.mutable_body()->set_text("{{extraction(\"param\")}} {{extraction(\"destructive_param\")}}"); + + InjaTransformer transformer(transformation, rng_, google::protobuf::BoolValue(), tls_); + + NiceMock callbacks; + transformer.transform(headers, &headers, body, callbacks); + EXPECT_EQ(body.toString(), "json not JSON body"); +} + TEST_F(InjaTransformerTest, UseBodyFunction) { Http::TestRequestHeaderMapImpl headers{{":method", "GET"}, {":path", "/foo"}}; TransformationTemplate transformation;