diff --git a/expfmt/encode.go b/expfmt/encode.go index 73c24dfb..7c6270e4 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 04e94c71..20fbf0cd 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"),