Skip to content

Commit 578faef

Browse files
committed
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.
1 parent 1b2cb09 commit 578faef

2 files changed

Lines changed: 256 additions & 39 deletions

File tree

expfmt/encode.go

Lines changed: 42 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package expfmt
1616
import (
1717
"fmt"
1818
"io"
19+
"strings"
1920
"net/http"
2021

2122
"github.com/munnerz/goautoneg"
@@ -61,41 +62,42 @@ func (ec encoderCloser) Close() error {
6162
// as the support is still experimental. To include the option to negotiate
6263
// FmtOpenMetrics, use NegotiateIncludingOpenMetrics.
6364
func Negotiate(h http.Header) Format {
64-
escapingScheme := Format(fmt.Sprintf("; escaping=%s", Format(model.NameEscapingScheme.String())))
65-
for _, ac := range goautoneg.ParseAccept(h.Get(hdrAccept)) {
66-
if escapeParam := ac.Params[model.EscapingKey]; escapeParam != "" {
67-
switch Format(escapeParam) {
68-
case model.AllowUTF8, model.EscapeUnderscores, model.EscapeDots, model.EscapeValues:
69-
escapingScheme = Format("; escaping=" + escapeParam)
70-
default:
71-
// If the escaping parameter is unknown, ignore it.
72-
}
73-
}
74-
ver := ac.Params["version"]
75-
if ac.Type+"/"+ac.SubType == ProtoType && ac.Params["proto"] == ProtoProtocol {
76-
switch ac.Params["encoding"] {
77-
case "delimited":
78-
return FmtProtoDelim + escapingScheme
79-
case "text":
80-
return FmtProtoText + escapingScheme
81-
case "compact-text":
82-
return FmtProtoCompact + escapingScheme
83-
}
84-
}
85-
if ac.Type == "text" && ac.SubType == "plain" && (ver == TextVersion || ver == "") {
86-
return FmtText + escapingScheme
87-
}
88-
}
89-
return FmtText + escapingScheme
65+
return NegotiateFormats(h)[0]
66+
}
67+
68+
// NegotiateFormats works like Negotiate but returns all recognised formats
69+
// from the Accept header in descending preference order instead of stopping
70+
// at the first match. If no recognised formats are found, the slice contains
71+
// FmtText as the default fallback. This function will never include
72+
// FmtOpenMetrics in the result. To include OpenMetrics formats, use
73+
// NegotiateFormatsIncludingOpenMetrics.
74+
func NegotiateFormats(h http.Header) []Format {
75+
return negotiateFormats(h, false)
9076
}
9177

9278
// NegotiateIncludingOpenMetrics works like Negotiate but includes
9379
// FmtOpenMetrics as an option for the result. Note that this function is
9480
// temporary and will disappear once FmtOpenMetrics is fully supported and as
9581
// such may be negotiated by the normal Negotiate function.
9682
func NegotiateIncludingOpenMetrics(h http.Header) Format {
97-
escapingScheme := Format(fmt.Sprintf("; escaping=%s", Format(model.NameEscapingScheme.String())))
98-
for _, ac := range goautoneg.ParseAccept(h.Get(hdrAccept)) {
83+
return NegotiateFormatsIncludingOpenMetrics(h)[0]
84+
}
85+
86+
// NegotiateFormatsIncludingOpenMetrics works like NegotiateIncludingOpenMetrics
87+
// but returns all recognised formats from the Accept header in descending
88+
// preference order instead of stopping at the first match. If no recognised
89+
// formats are found, the slice contains FmtText as the default fallback.
90+
// This allows a server that supports only a subset of formats to iterate
91+
// through the slice and pick the first format it can serve.
92+
func NegotiateFormatsIncludingOpenMetrics(h http.Header) []Format {
93+
return negotiateFormats(h, true)
94+
}
95+
96+
func negotiateFormats(h http.Header, includeOpenMetrics bool) []Format {
97+
baseEscapingScheme := Format(fmt.Sprintf("; escaping=%s", Format(model.NameEscapingScheme.String())))
98+
var formats []Format
99+
for _, ac := range goautoneg.ParseAccept(strings.Join(h.Values(hdrAccept), ", ")) {
100+
escapingScheme := baseEscapingScheme
99101
if escapeParam := ac.Params[model.EscapingKey]; escapeParam != "" {
100102
switch Format(escapeParam) {
101103
case model.AllowUTF8, model.EscapeUnderscores, model.EscapeDots, model.EscapeValues:
@@ -108,26 +110,27 @@ func NegotiateIncludingOpenMetrics(h http.Header) Format {
108110
if ac.Type+"/"+ac.SubType == ProtoType && ac.Params["proto"] == ProtoProtocol {
109111
switch ac.Params["encoding"] {
110112
case "delimited":
111-
return FmtProtoDelim + escapingScheme
113+
formats = append(formats, FmtProtoDelim+escapingScheme)
112114
case "text":
113-
return FmtProtoText + escapingScheme
115+
formats = append(formats, FmtProtoText+escapingScheme)
114116
case "compact-text":
115-
return FmtProtoCompact + escapingScheme
117+
formats = append(formats, FmtProtoCompact+escapingScheme)
116118
}
117-
}
118-
if ac.Type == "text" && ac.SubType == "plain" && (ver == TextVersion || ver == "") {
119-
return FmtText + escapingScheme
120-
}
121-
if ac.Type+"/"+ac.SubType == OpenMetricsType && (ver == OpenMetricsVersion_0_0_1 || ver == OpenMetricsVersion_1_0_0 || ver == "") {
119+
} else if ac.Type == "text" && ac.SubType == "plain" && (ver == TextVersion || ver == "") {
120+
formats = append(formats, FmtText+escapingScheme)
121+
} else if includeOpenMetrics && ac.Type+"/"+ac.SubType == OpenMetricsType && (ver == OpenMetricsVersion_0_0_1 || ver == OpenMetricsVersion_1_0_0 || ver == "") {
122122
switch ver {
123123
case OpenMetricsVersion_1_0_0:
124-
return FmtOpenMetrics_1_0_0 + escapingScheme
124+
formats = append(formats, FmtOpenMetrics_1_0_0+escapingScheme)
125125
default:
126-
return FmtOpenMetrics_0_0_1 + escapingScheme
126+
formats = append(formats, FmtOpenMetrics_0_0_1+escapingScheme)
127127
}
128128
}
129129
}
130-
return FmtText + escapingScheme
130+
if len(formats) == 0 {
131+
formats = append(formats, FmtText+baseEscapingScheme)
132+
}
133+
return formats
131134
}
132135

133136
// NewEncoder returns a new encoder based on content type negotiation. All

expfmt/encode_test.go

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,111 @@ func TestNegotiate(t *testing.T) {
9898
}
9999
}
100100

101+
func TestNegotiateFormats(t *testing.T) {
102+
acceptValuePrefix := "application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily"
103+
tests := []struct {
104+
name string
105+
acceptHeaderValues []string
106+
expectedFmts []string
107+
}{
108+
{
109+
name: "empty accept header returns FmtText fallback",
110+
acceptHeaderValues: []string{},
111+
expectedFmts: []string{"text/plain; version=0.0.4; charset=utf-8; escaping=underscores"},
112+
},
113+
{
114+
name: "single protobuf delimited",
115+
acceptHeaderValues: []string{acceptValuePrefix + ";encoding=delimited"},
116+
expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores"},
117+
},
118+
{
119+
name: "single protobuf text",
120+
acceptHeaderValues: []string{acceptValuePrefix + ";encoding=text"},
121+
expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=text; escaping=underscores"},
122+
},
123+
{
124+
name: "single protobuf compact-text",
125+
acceptHeaderValues: []string{acceptValuePrefix + ";encoding=compact-text"},
126+
expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=compact-text; escaping=underscores"},
127+
},
128+
{
129+
name: "plain text",
130+
acceptHeaderValues: []string{"text/plain;version=0.0.4"},
131+
expectedFmts: []string{"text/plain; version=0.0.4; charset=utf-8; escaping=underscores"},
132+
},
133+
{
134+
name: "openmetrics is not recognised",
135+
acceptHeaderValues: []string{"application/openmetrics-text;version=1.0.0"},
136+
expectedFmts: []string{"text/plain; version=0.0.4; charset=utf-8; escaping=underscores"},
137+
},
138+
{
139+
name: "multiple formats returned in preference order",
140+
acceptHeaderValues: []string{
141+
acceptValuePrefix + ";encoding=delimited;q=0.6",
142+
"text/plain;version=0.0.4;q=0.2",
143+
},
144+
expectedFmts: []string{
145+
"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores",
146+
"text/plain; version=0.0.4; charset=utf-8; escaping=underscores",
147+
},
148+
},
149+
{
150+
name: "multiple formats with openmetrics skipped",
151+
acceptHeaderValues: []string{
152+
acceptValuePrefix + ";encoding=delimited;q=0.6",
153+
"application/openmetrics-text;version=1.0.0;q=0.5",
154+
"text/plain;version=0.0.4;q=0.2",
155+
},
156+
expectedFmts: []string{
157+
"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores",
158+
"text/plain; version=0.0.4; charset=utf-8; escaping=underscores",
159+
},
160+
},
161+
{
162+
name: "escaping param is respected",
163+
acceptHeaderValues: []string{acceptValuePrefix + ";encoding=delimited;escaping=allow-utf-8"},
164+
expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=allow-utf-8"},
165+
},
166+
{
167+
name: "unknown escaping param falls back to default",
168+
acceptHeaderValues: []string{acceptValuePrefix + ";encoding=delimited;escaping=bogus"},
169+
expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores"},
170+
},
171+
{
172+
name: "single header with multiple comma-separated formats",
173+
acceptHeaderValues: []string{
174+
acceptValuePrefix + ";encoding=delimited;q=0.6," +
175+
"application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5," +
176+
"text/plain;version=0.0.4;q=0.2",
177+
},
178+
expectedFmts: []string{
179+
"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores",
180+
"text/plain; version=0.0.4; charset=utf-8; escaping=underscores",
181+
},
182+
},
183+
}
184+
185+
oldDefault := model.NameEscapingScheme
186+
model.NameEscapingScheme = model.UnderscoreEscaping
187+
defer func() {
188+
model.NameEscapingScheme = oldDefault
189+
}()
190+
191+
for _, test := range tests {
192+
t.Run(test.name, func(t *testing.T) {
193+
h := http.Header{}
194+
for _, v := range test.acceptHeaderValues {
195+
h.Add(hdrAccept, v)
196+
}
197+
actualFmts := NegotiateFormats(h)
198+
require.Len(t, actualFmts, len(test.expectedFmts), "number of returned formats")
199+
for i, expected := range test.expectedFmts {
200+
assert.Equal(t, expected, string(actualFmts[i]), "format at index %d", i)
201+
}
202+
})
203+
}
204+
}
205+
101206
func TestNegotiateIncludingOpenMetrics(t *testing.T) {
102207
acceptValuePrefix := "application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily"
103208
tests := []struct {
@@ -200,6 +305,115 @@ func TestNegotiateIncludingOpenMetrics(t *testing.T) {
200305
}
201306
}
202307

308+
func TestNegotiateFormatsIncludingOpenMetrics(t *testing.T) {
309+
acceptValuePrefix := "application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily"
310+
tests := []struct {
311+
name string
312+
acceptHeaderValues []string
313+
expectedFmts []string
314+
}{
315+
{
316+
name: "empty accept header returns FmtText fallback",
317+
acceptHeaderValues: []string{},
318+
expectedFmts: []string{"text/plain; version=0.0.4; charset=utf-8; escaping=values"},
319+
},
320+
{
321+
name: "single protobuf delimited",
322+
acceptHeaderValues: []string{acceptValuePrefix + ";encoding=delimited"},
323+
expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=values"},
324+
},
325+
{
326+
name: "single OM 1.0.0",
327+
acceptHeaderValues: []string{"application/openmetrics-text;version=1.0.0"},
328+
expectedFmts: []string{"application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=values"},
329+
},
330+
{
331+
name: "single OM 0.0.1",
332+
acceptHeaderValues: []string{"application/openmetrics-text;version=0.0.1"},
333+
expectedFmts: []string{"application/openmetrics-text; version=0.0.1; charset=utf-8; escaping=values"},
334+
},
335+
{
336+
name: "single OM no version",
337+
acceptHeaderValues: []string{"application/openmetrics-text"},
338+
expectedFmts: []string{"application/openmetrics-text; version=0.0.1; charset=utf-8; escaping=values"},
339+
},
340+
{
341+
name: "OM invalid version is not recognised",
342+
acceptHeaderValues: []string{"application/openmetrics-text;version=0.0.4"},
343+
expectedFmts: []string{"text/plain; version=0.0.4; charset=utf-8; escaping=values"},
344+
},
345+
{
346+
name: "multiple formats returned in preference order",
347+
acceptHeaderValues: []string{
348+
acceptValuePrefix + ";encoding=delimited;q=0.6",
349+
"application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5",
350+
"text/plain;version=0.0.4;q=0.2",
351+
},
352+
expectedFmts: []string{
353+
"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=values",
354+
"application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=allow-utf-8",
355+
"text/plain; version=0.0.4; charset=utf-8; escaping=values",
356+
},
357+
},
358+
{
359+
name: "server supporting only OM and text/plain can skip protobuf",
360+
acceptHeaderValues: []string{
361+
acceptValuePrefix + ";encoding=delimited;q=0.6",
362+
"application/openmetrics-text;version=1.0.0;q=0.5",
363+
"text/plain;version=0.0.4;q=0.2",
364+
},
365+
expectedFmts: []string{
366+
"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=values",
367+
"application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=values",
368+
"text/plain; version=0.0.4; charset=utf-8; escaping=values",
369+
},
370+
},
371+
{
372+
name: "escaping param is respected",
373+
acceptHeaderValues: []string{acceptValuePrefix + ";encoding=delimited;escaping=underscores"},
374+
expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores"},
375+
},
376+
{
377+
name: "unknown escaping param falls back to default",
378+
acceptHeaderValues: []string{acceptValuePrefix + ";encoding=delimited;escaping=bogus"},
379+
expectedFmts: []string{"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=values"},
380+
},
381+
{
382+
name: "single header with multiple comma-separated formats",
383+
acceptHeaderValues: []string{
384+
acceptValuePrefix + ";encoding=delimited;q=0.6," +
385+
"application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5," +
386+
"text/plain;version=0.0.4;q=0.2",
387+
},
388+
expectedFmts: []string{
389+
"application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=values",
390+
"application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=allow-utf-8",
391+
"text/plain; version=0.0.4; charset=utf-8; escaping=values",
392+
},
393+
},
394+
}
395+
396+
oldDefault := model.NameEscapingScheme
397+
model.NameEscapingScheme = model.ValueEncodingEscaping
398+
defer func() {
399+
model.NameEscapingScheme = oldDefault
400+
}()
401+
402+
for _, test := range tests {
403+
t.Run(test.name, func(t *testing.T) {
404+
h := http.Header{}
405+
for _, v := range test.acceptHeaderValues {
406+
h.Add(hdrAccept, v)
407+
}
408+
actualFmts := NegotiateFormatsIncludingOpenMetrics(h)
409+
require.Len(t, actualFmts, len(test.expectedFmts), "number of returned formats")
410+
for i, expected := range test.expectedFmts {
411+
assert.Equal(t, expected, string(actualFmts[i]), "format at index %d", i)
412+
}
413+
})
414+
}
415+
}
416+
203417
func TestEncode(t *testing.T) {
204418
metric1 := &dto.MetricFamily{
205419
Name: proto.String("foo_metric"),

0 commit comments

Comments
 (0)