From 5d33231dc6a3d63589dbc3ffe685ba7ab681e260 Mon Sep 17 00:00:00 2001 From: Aliaksei Dziauho Date: Tue, 23 Jun 2026 17:04:01 +0200 Subject: [PATCH] expfmt: add NegotiateFormats and NegotiateFormatsIncludingOpenMetrics Add NegotiateFormats and NegotiateFormatsIncludingOpenMetrics that return all recognised formats from the Accept header in descending preference order, allowing servers that support only a subset of formats to pick the first one they can serve without reimplementing the negotiation loop. Rewrite Negotiate and NegotiateIncludingOpenMetrics to delegate to the new functions. Unify the shared negotiation logic into a private negotiateFormats helper parameterised by an includeOpenMetrics flag, eliminating the code duplication between the two variants. Tests use []string acceptHeaderValues to exercise both comma-separated and multi-header Accept values. The implementation joins multiple Accept header values before parsing to correctly handle both forms. Fixes #928. Signed-off-by: Aliaksei Dziauho --- expfmt/encode.go | 81 ++++++++-------- expfmt/encode_test.go | 214 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+), 39 deletions(-) diff --git a/expfmt/encode.go b/expfmt/encode.go index 73c24dfbc..7c6270e42 100644 --- a/expfmt/encode.go +++ b/expfmt/encode.go @@ -17,6 +17,7 @@ import ( "fmt" "io" "net/http" + "strings" "github.com/munnerz/goautoneg" dto "github.com/prometheus/client_model/go" @@ -61,32 +62,17 @@ func (ec encoderCloser) Close() error { // as the support is still experimental. To include the option to negotiate // FmtOpenMetrics, use NegotiateIncludingOpenMetrics. func Negotiate(h http.Header) Format { - escapingScheme := Format(fmt.Sprintf("; escaping=%s", Format(model.NameEscapingScheme.String()))) - for _, ac := range goautoneg.ParseAccept(h.Get(hdrAccept)) { - if escapeParam := ac.Params[model.EscapingKey]; escapeParam != "" { - switch Format(escapeParam) { - case model.AllowUTF8, model.EscapeUnderscores, model.EscapeDots, model.EscapeValues: - escapingScheme = Format("; escaping=" + escapeParam) - default: - // If the escaping parameter is unknown, ignore it. - } - } - ver := ac.Params["version"] - if ac.Type+"/"+ac.SubType == ProtoType && ac.Params["proto"] == ProtoProtocol { - switch ac.Params["encoding"] { - case "delimited": - return FmtProtoDelim + escapingScheme - case "text": - return FmtProtoText + escapingScheme - case "compact-text": - return FmtProtoCompact + escapingScheme - } - } - if ac.Type == "text" && ac.SubType == "plain" && (ver == TextVersion || ver == "") { - return FmtText + escapingScheme - } - } - return FmtText + escapingScheme + return NegotiateFormats(h)[0] +} + +// NegotiateFormats works like Negotiate but returns all recognised formats +// from the Accept header in descending preference order instead of stopping +// at the first match. If no recognised formats are found, the slice contains +// FmtText as the default fallback. This function will never include +// FmtOpenMetrics in the result. To include OpenMetrics formats, use +// NegotiateFormatsIncludingOpenMetrics. +func NegotiateFormats(h http.Header) []Format { + return negotiateFormats(h, false) } // NegotiateIncludingOpenMetrics works like Negotiate but includes @@ -94,8 +80,24 @@ func Negotiate(h http.Header) Format { // temporary and will disappear once FmtOpenMetrics is fully supported and as // such may be negotiated by the normal Negotiate function. func NegotiateIncludingOpenMetrics(h http.Header) Format { - escapingScheme := Format(fmt.Sprintf("; escaping=%s", Format(model.NameEscapingScheme.String()))) - for _, ac := range goautoneg.ParseAccept(h.Get(hdrAccept)) { + return NegotiateFormatsIncludingOpenMetrics(h)[0] +} + +// NegotiateFormatsIncludingOpenMetrics works like NegotiateIncludingOpenMetrics +// but returns all recognised formats from the Accept header in descending +// preference order instead of stopping at the first match. If no recognised +// formats are found, the slice contains FmtText as the default fallback. +// This allows a server that supports only a subset of formats to iterate +// through the slice and pick the first format it can serve. +func NegotiateFormatsIncludingOpenMetrics(h http.Header) []Format { + return negotiateFormats(h, true) +} + +func negotiateFormats(h http.Header, includeOpenMetrics bool) []Format { + baseEscapingScheme := Format(fmt.Sprintf("; escaping=%s", Format(model.NameEscapingScheme.String()))) + var formats []Format + for _, ac := range goautoneg.ParseAccept(strings.Join(h.Values(hdrAccept), ", ")) { + escapingScheme := baseEscapingScheme if escapeParam := ac.Params[model.EscapingKey]; escapeParam != "" { switch Format(escapeParam) { case model.AllowUTF8, model.EscapeUnderscores, model.EscapeDots, model.EscapeValues: @@ -108,26 +110,27 @@ func NegotiateIncludingOpenMetrics(h http.Header) Format { if ac.Type+"/"+ac.SubType == ProtoType && ac.Params["proto"] == ProtoProtocol { switch ac.Params["encoding"] { case "delimited": - return FmtProtoDelim + escapingScheme + formats = append(formats, FmtProtoDelim+escapingScheme) case "text": - return FmtProtoText + escapingScheme + formats = append(formats, FmtProtoText+escapingScheme) case "compact-text": - return FmtProtoCompact + escapingScheme + formats = append(formats, FmtProtoCompact+escapingScheme) } - } - if ac.Type == "text" && ac.SubType == "plain" && (ver == TextVersion || ver == "") { - return FmtText + escapingScheme - } - if ac.Type+"/"+ac.SubType == OpenMetricsType && (ver == OpenMetricsVersion_0_0_1 || ver == OpenMetricsVersion_1_0_0 || ver == "") { + } else if ac.Type == "text" && ac.SubType == "plain" && (ver == TextVersion || ver == "") { + formats = append(formats, FmtText+escapingScheme) + } else if includeOpenMetrics && ac.Type+"/"+ac.SubType == OpenMetricsType && (ver == OpenMetricsVersion_0_0_1 || ver == OpenMetricsVersion_1_0_0 || ver == "") { switch ver { case OpenMetricsVersion_1_0_0: - return FmtOpenMetrics_1_0_0 + escapingScheme + formats = append(formats, FmtOpenMetrics_1_0_0+escapingScheme) default: - return FmtOpenMetrics_0_0_1 + escapingScheme + formats = append(formats, FmtOpenMetrics_0_0_1+escapingScheme) } } } - return FmtText + escapingScheme + if len(formats) == 0 { + formats = append(formats, FmtText+baseEscapingScheme) + } + return formats } // NewEncoder returns a new encoder based on content type negotiation. All diff --git a/expfmt/encode_test.go b/expfmt/encode_test.go index 04e94c711..20fbf0cdf 100644 --- a/expfmt/encode_test.go +++ b/expfmt/encode_test.go @@ -98,6 +98,111 @@ func TestNegotiate(t *testing.T) { } } +func TestNegotiateFormats(t *testing.T) { + acceptValuePrefix := "application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily" + tests := []struct { + name string + acceptHeaderValues []string + expectedFmts []string + }{ + { + name: "empty accept header returns FmtText fallback", + acceptHeaderValues: []string{}, + expectedFmts: []string{"text/plain; version=0.0.4; charset=utf-8; escaping=underscores"}, + }, + { + name: "single protobuf delimited", + acceptHeaderValues: []string{acceptValuePrefix + ";encoding=delimited"}, + expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores"}, + }, + { + name: "single protobuf text", + acceptHeaderValues: []string{acceptValuePrefix + ";encoding=text"}, + expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=text; escaping=underscores"}, + }, + { + name: "single protobuf compact-text", + acceptHeaderValues: []string{acceptValuePrefix + ";encoding=compact-text"}, + expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=compact-text; escaping=underscores"}, + }, + { + name: "plain text", + acceptHeaderValues: []string{"text/plain;version=0.0.4"}, + expectedFmts: []string{"text/plain; version=0.0.4; charset=utf-8; escaping=underscores"}, + }, + { + name: "openmetrics is not recognised", + acceptHeaderValues: []string{"application/openmetrics-text;version=1.0.0"}, + expectedFmts: []string{"text/plain; version=0.0.4; charset=utf-8; escaping=underscores"}, + }, + { + name: "multiple formats returned in preference order", + acceptHeaderValues: []string{ + acceptValuePrefix + ";encoding=delimited;q=0.6", + "text/plain;version=0.0.4;q=0.2", + }, + expectedFmts: []string{ + "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores", + "text/plain; version=0.0.4; charset=utf-8; escaping=underscores", + }, + }, + { + name: "multiple formats with openmetrics skipped", + acceptHeaderValues: []string{ + acceptValuePrefix + ";encoding=delimited;q=0.6", + "application/openmetrics-text;version=1.0.0;q=0.5", + "text/plain;version=0.0.4;q=0.2", + }, + expectedFmts: []string{ + "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores", + "text/plain; version=0.0.4; charset=utf-8; escaping=underscores", + }, + }, + { + name: "escaping param is respected", + acceptHeaderValues: []string{acceptValuePrefix + ";encoding=delimited;escaping=allow-utf-8"}, + expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=allow-utf-8"}, + }, + { + name: "unknown escaping param falls back to default", + acceptHeaderValues: []string{acceptValuePrefix + ";encoding=delimited;escaping=bogus"}, + expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores"}, + }, + { + name: "single header with multiple comma-separated formats", + acceptHeaderValues: []string{ + acceptValuePrefix + ";encoding=delimited;q=0.6," + + "application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5," + + "text/plain;version=0.0.4;q=0.2", + }, + expectedFmts: []string{ + "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores", + "text/plain; version=0.0.4; charset=utf-8; escaping=underscores", + }, + }, + } + + oldDefault := model.NameEscapingScheme + model.NameEscapingScheme = model.UnderscoreEscaping + defer func() { + model.NameEscapingScheme = oldDefault + }() + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + h := http.Header{} + for _, v := range test.acceptHeaderValues { + h.Add(hdrAccept, v) + } + actualFmts := NegotiateFormats(h) + require.Len(t, actualFmts, len(test.expectedFmts), "number of returned formats") + for i, expected := range test.expectedFmts { + assert.Equal(t, expected, string(actualFmts[i]), "format at index %d", i) + } + }) + } +} + func TestNegotiateIncludingOpenMetrics(t *testing.T) { acceptValuePrefix := "application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily" tests := []struct { @@ -200,6 +305,115 @@ func TestNegotiateIncludingOpenMetrics(t *testing.T) { } } +func TestNegotiateFormatsIncludingOpenMetrics(t *testing.T) { + acceptValuePrefix := "application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily" + tests := []struct { + name string + acceptHeaderValues []string + expectedFmts []string + }{ + { + name: "empty accept header returns FmtText fallback", + acceptHeaderValues: []string{}, + expectedFmts: []string{"text/plain; version=0.0.4; charset=utf-8; escaping=values"}, + }, + { + name: "single protobuf delimited", + acceptHeaderValues: []string{acceptValuePrefix + ";encoding=delimited"}, + expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=values"}, + }, + { + name: "single OM 1.0.0", + acceptHeaderValues: []string{"application/openmetrics-text;version=1.0.0"}, + expectedFmts: []string{"application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=values"}, + }, + { + name: "single OM 0.0.1", + acceptHeaderValues: []string{"application/openmetrics-text;version=0.0.1"}, + expectedFmts: []string{"application/openmetrics-text; version=0.0.1; charset=utf-8; escaping=values"}, + }, + { + name: "single OM no version", + acceptHeaderValues: []string{"application/openmetrics-text"}, + expectedFmts: []string{"application/openmetrics-text; version=0.0.1; charset=utf-8; escaping=values"}, + }, + { + name: "OM invalid version is not recognised", + acceptHeaderValues: []string{"application/openmetrics-text;version=0.0.4"}, + expectedFmts: []string{"text/plain; version=0.0.4; charset=utf-8; escaping=values"}, + }, + { + name: "multiple formats returned in preference order", + acceptHeaderValues: []string{ + acceptValuePrefix + ";encoding=delimited;q=0.6", + "application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5", + "text/plain;version=0.0.4;q=0.2", + }, + expectedFmts: []string{ + "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=values", + "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=allow-utf-8", + "text/plain; version=0.0.4; charset=utf-8; escaping=values", + }, + }, + { + name: "server supporting only OM and text/plain can skip protobuf", + acceptHeaderValues: []string{ + acceptValuePrefix + ";encoding=delimited;q=0.6", + "application/openmetrics-text;version=1.0.0;q=0.5", + "text/plain;version=0.0.4;q=0.2", + }, + expectedFmts: []string{ + "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=values", + "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=values", + "text/plain; version=0.0.4; charset=utf-8; escaping=values", + }, + }, + { + name: "escaping param is respected", + acceptHeaderValues: []string{acceptValuePrefix + ";encoding=delimited;escaping=underscores"}, + expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores"}, + }, + { + name: "unknown escaping param falls back to default", + acceptHeaderValues: []string{acceptValuePrefix + ";encoding=delimited;escaping=bogus"}, + expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=values"}, + }, + { + name: "single header with multiple comma-separated formats", + acceptHeaderValues: []string{ + acceptValuePrefix + ";encoding=delimited;q=0.6," + + "application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5," + + "text/plain;version=0.0.4;q=0.2", + }, + expectedFmts: []string{ + "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=values", + "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=allow-utf-8", + "text/plain; version=0.0.4; charset=utf-8; escaping=values", + }, + }, + } + + oldDefault := model.NameEscapingScheme + model.NameEscapingScheme = model.ValueEncodingEscaping + defer func() { + model.NameEscapingScheme = oldDefault + }() + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + h := http.Header{} + for _, v := range test.acceptHeaderValues { + h.Add(hdrAccept, v) + } + actualFmts := NegotiateFormatsIncludingOpenMetrics(h) + require.Len(t, actualFmts, len(test.expectedFmts), "number of returned formats") + for i, expected := range test.expectedFmts { + assert.Equal(t, expected, string(actualFmts[i]), "format at index %d", i) + } + }) + } +} + func TestEncode(t *testing.T) { metric1 := &dto.MetricFamily{ Name: proto.String("foo_metric"),