Skip to content

Commit c26f585

Browse files
authored
feat: Implement utf8 label name client capability (#4442)
This change implements a client capability framework, with `allow-utf8-labelnames` as the first capability. Capabilities are flags clients set in the `Accept` header to inform the API about specific client support. This is useful for cross-API features (like utf-8 support). This change does _not_ provide full utf-8 label name support. Instead, it offers the first pass at what will be full support, with follow up changes to provide full support. This change respects the `allow-utf8-labelnames` client capability in the read path (both v1 and v2 `LabelNames` and `Series` APIs). If the capability is not set or not set to true, these APIs will filter out "invalid" label names (i.e. with chars outside of [a-zA-Z0-9_.]) from the response. This logic is currently a no-op, given it's not yet possible to write label names outside of this charset.
1 parent a077234 commit c26f585

File tree

10 files changed

+1337
-57
lines changed

10 files changed

+1337
-57
lines changed
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package featureflags
2+
3+
import (
4+
"context"
5+
"mime"
6+
"net/http"
7+
"strings"
8+
9+
"connectrpc.com/connect"
10+
"github.com/go-kit/log/level"
11+
"github.com/grafana/dskit/middleware"
12+
"github.com/grafana/pyroscope/pkg/util"
13+
"google.golang.org/grpc"
14+
"google.golang.org/grpc/metadata"
15+
)
16+
17+
const (
18+
// Capability names - update parseClientCapabilities below when new capabilities added
19+
allowUtf8LabelNamesCapabilityName string = "allow-utf8-labelnames"
20+
)
21+
22+
// Define a custom context key type to avoid collisions
23+
type contextKey struct{}
24+
25+
type ClientCapabilities struct {
26+
AllowUtf8LabelNames bool
27+
}
28+
29+
func WithClientCapabilities(ctx context.Context, clientCapabilities ClientCapabilities) context.Context {
30+
return context.WithValue(ctx, contextKey{}, clientCapabilities)
31+
}
32+
33+
func GetClientCapabilities(ctx context.Context) (ClientCapabilities, bool) {
34+
value, ok := ctx.Value(contextKey{}).(ClientCapabilities)
35+
return value, ok
36+
}
37+
38+
func ClientCapabilitiesGRPCMiddleware() grpc.UnaryServerInterceptor {
39+
return func(
40+
ctx context.Context,
41+
req interface{},
42+
info *grpc.UnaryServerInfo,
43+
handler grpc.UnaryHandler,
44+
) (interface{}, error) {
45+
// Extract metadata from context
46+
md, ok := metadata.FromIncomingContext(ctx)
47+
if !ok {
48+
return handler(ctx, req)
49+
}
50+
51+
// Convert metadata to http.Header for reuse of existing parsing logic
52+
httpHeader := make(http.Header)
53+
for key, values := range md {
54+
// gRPC metadata keys are lowercase, HTTP headers are case-insensitive
55+
httpHeader[http.CanonicalHeaderKey(key)] = values
56+
}
57+
58+
// Reuse existing HTTP header parsing
59+
clientCapabilities, err := parseClientCapabilities(httpHeader)
60+
if err != nil {
61+
return nil, connect.NewError(connect.CodeInvalidArgument, err)
62+
}
63+
64+
enhancedCtx := WithClientCapabilities(ctx, clientCapabilities)
65+
return handler(enhancedCtx, req)
66+
}
67+
}
68+
69+
// ClientCapabilitiesHttpMiddleware creates middleware that extracts and parses the
70+
// `Accept` header for capabilities the client supports
71+
func ClientCapabilitiesHttpMiddleware() middleware.Interface {
72+
return middleware.Func(func(next http.Handler) http.Handler {
73+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
74+
clientCapabilities, err := parseClientCapabilities(r.Header)
75+
if err != nil {
76+
http.Error(w, "Invalid header format: "+err.Error(), http.StatusBadRequest)
77+
return
78+
}
79+
80+
ctx := WithClientCapabilities(r.Context(), clientCapabilities)
81+
next.ServeHTTP(w, r.WithContext(ctx))
82+
})
83+
})
84+
}
85+
86+
func parseClientCapabilities(header http.Header) (ClientCapabilities, error) {
87+
acceptHeaderValues := header.Values("Accept")
88+
89+
var capabilities ClientCapabilities
90+
91+
for _, acceptHeaderValue := range acceptHeaderValues {
92+
if acceptHeaderValue != "" {
93+
accepts := strings.Split(acceptHeaderValue, ",")
94+
95+
for _, accept := range accepts {
96+
if _, params, err := mime.ParseMediaType(accept); err != nil {
97+
return capabilities, err
98+
} else {
99+
for k, v := range params {
100+
switch k {
101+
case allowUtf8LabelNamesCapabilityName:
102+
if v == "true" {
103+
capabilities.AllowUtf8LabelNames = true
104+
}
105+
default:
106+
level.Debug(util.Logger).Log(
107+
"msg", "unknown capability parsed from Accept header",
108+
"acceptHeaderKey", k,
109+
"acceptHeaderValue", v)
110+
}
111+
}
112+
}
113+
}
114+
}
115+
}
116+
return capabilities, nil
117+
}
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
package featureflags
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func Test_parseClientCapabilities(t *testing.T) {
11+
tests := []struct {
12+
Name string
13+
Header http.Header
14+
Want ClientCapabilities
15+
WantError bool
16+
ErrorMessage string
17+
}{
18+
{
19+
Name: "empty header returns default capabilities",
20+
Header: http.Header{},
21+
Want: ClientCapabilities{AllowUtf8LabelNames: false},
22+
},
23+
{
24+
Name: "no Accept header returns default capabilities",
25+
Header: http.Header{
26+
"Content-Type": []string{"application/json"},
27+
},
28+
Want: ClientCapabilities{AllowUtf8LabelNames: false},
29+
},
30+
{
31+
Name: "empty Accept header value returns default capabilities",
32+
Header: http.Header{
33+
"Accept": []string{""},
34+
},
35+
Want: ClientCapabilities{AllowUtf8LabelNames: false},
36+
},
37+
{
38+
Name: "simple Accept header without capabilities",
39+
Header: http.Header{
40+
"Accept": []string{"application/json"},
41+
},
42+
Want: ClientCapabilities{AllowUtf8LabelNames: false},
43+
},
44+
{
45+
Name: "Accept header with utf8 label names capability true",
46+
Header: http.Header{
47+
"Accept": []string{"*/*; allow-utf8-labelnames=true"},
48+
},
49+
Want: ClientCapabilities{AllowUtf8LabelNames: true},
50+
},
51+
{
52+
Name: "Accept header with utf8 label names capability false",
53+
Header: http.Header{
54+
"Accept": []string{"*/*; allow-utf8-labelnames=false"},
55+
},
56+
Want: ClientCapabilities{AllowUtf8LabelNames: false},
57+
},
58+
{
59+
Name: "Accept header with utf8 label names capability invalid value",
60+
Header: http.Header{
61+
"Accept": []string{"*/*; allow-utf8-labelnames=invalid"},
62+
},
63+
Want: ClientCapabilities{AllowUtf8LabelNames: false},
64+
},
65+
{
66+
Name: "Accept header with unknown capability",
67+
Header: http.Header{
68+
"Accept": []string{"*/*; unknown-capability=true"},
69+
},
70+
Want: ClientCapabilities{AllowUtf8LabelNames: false},
71+
},
72+
{
73+
Name: "Accept header with multiple capabilities",
74+
Header: http.Header{
75+
"Accept": []string{"*/*; allow-utf8-labelnames=true; unknown-capability=false"},
76+
},
77+
Want: ClientCapabilities{AllowUtf8LabelNames: true},
78+
},
79+
{
80+
Name: "multiple Accept header values",
81+
Header: http.Header{
82+
"Accept": []string{"application/json", "*/*; allow-utf8-labelnames=true"},
83+
},
84+
Want: ClientCapabilities{AllowUtf8LabelNames: true},
85+
},
86+
{
87+
Name: "multiple Accept header values with different capabilities",
88+
Header: http.Header{
89+
"Accept": []string{
90+
"application/json; allow-utf8-labelnames=false",
91+
"*/*; allow-utf8-labelnames=true",
92+
},
93+
},
94+
Want: ClientCapabilities{AllowUtf8LabelNames: true},
95+
},
96+
{
97+
Name: "Accept header with quality values",
98+
Header: http.Header{
99+
"Accept": []string{"text/html; q=0.9; allow-utf8-labelnames=true"},
100+
},
101+
Want: ClientCapabilities{AllowUtf8LabelNames: true},
102+
},
103+
{
104+
Name: "complex Accept header",
105+
Header: http.Header{
106+
"Accept": []string{
107+
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8;allow-utf8-labelnames=true",
108+
},
109+
},
110+
Want: ClientCapabilities{AllowUtf8LabelNames: true},
111+
},
112+
{
113+
Name: "multiple Accept header entries",
114+
Header: http.Header{
115+
"Accept": []string{
116+
"application/json",
117+
"text/plain; allow-utf8-labelnames=true",
118+
"*/*; q=0.1",
119+
},
120+
},
121+
Want: ClientCapabilities{AllowUtf8LabelNames: true},
122+
},
123+
{
124+
Name: "invalid mime type in Accept header",
125+
Header: http.Header{
126+
"Accept": []string{"invalid/mime/type/format"},
127+
},
128+
Want: ClientCapabilities{},
129+
WantError: true,
130+
ErrorMessage: "mime: unexpected content after media subtype",
131+
},
132+
{
133+
Name: "Accept header with invalid syntax",
134+
Header: http.Header{
135+
"Accept": []string{"text/html; invalid-parameter-syntax"},
136+
},
137+
Want: ClientCapabilities{},
138+
WantError: true,
139+
ErrorMessage: "mime: invalid media parameter",
140+
},
141+
{
142+
Name: "mixed valid and invalid Accept header values",
143+
Header: http.Header{
144+
"Accept": []string{
145+
"application/json",
146+
"invalid/mime/type/format",
147+
},
148+
},
149+
Want: ClientCapabilities{},
150+
WantError: true,
151+
ErrorMessage: "mime: unexpected content after media subtype",
152+
},
153+
{
154+
// Parameter names are case-insensitive in mime.ParseMediaType
155+
Name: "case sensitivity test for capability name",
156+
Header: http.Header{
157+
"Accept": []string{"*/*; Allow-Utf8-Labelnames=true"},
158+
},
159+
Want: ClientCapabilities{AllowUtf8LabelNames: true},
160+
},
161+
{
162+
Name: "whitespace handling in Accept header",
163+
Header: http.Header{
164+
"Accept": []string{" application/json ; allow-utf8-labelnames=true "},
165+
},
166+
Want: ClientCapabilities{AllowUtf8LabelNames: true},
167+
},
168+
}
169+
170+
for _, tt := range tests {
171+
t.Run(tt.Name, func(t *testing.T) {
172+
t.Parallel()
173+
174+
got, err := parseClientCapabilities(tt.Header)
175+
176+
if tt.WantError {
177+
require.Error(t, err)
178+
if tt.ErrorMessage != "" {
179+
require.Contains(t, err.Error(), tt.ErrorMessage)
180+
}
181+
return
182+
}
183+
184+
require.NoError(t, err)
185+
require.Equal(t, tt.Want, got)
186+
})
187+
}
188+
}
189+
190+
func Test_parseClientCapabilities_MultipleCapabilities(t *testing.T) {
191+
// This test specifically checks that when the same capability appears
192+
// multiple times with different values, the last "true" value wins
193+
tests := []struct {
194+
Name string
195+
Header http.Header
196+
Want ClientCapabilities
197+
}{
198+
{
199+
Name: "capability appears multiple times - last true wins",
200+
Header: http.Header{
201+
"Accept": []string{
202+
"application/json; allow-utf8-labelnames=false",
203+
"text/plain; allow-utf8-labelnames=true",
204+
},
205+
},
206+
Want: ClientCapabilities{AllowUtf8LabelNames: true},
207+
},
208+
{
209+
Name: "capability appears multiple times - last false loses to earlier true",
210+
Header: http.Header{
211+
"Accept": []string{
212+
"application/json; allow-utf8-labelnames=true",
213+
"text/plain; allow-utf8-labelnames=false",
214+
},
215+
},
216+
Want: ClientCapabilities{AllowUtf8LabelNames: true},
217+
},
218+
{
219+
Name: "capability appears multiple times - all false",
220+
Header: http.Header{
221+
"Accept": []string{
222+
"application/json; allow-utf8-labelnames=false",
223+
"text/plain; allow-utf8-labelnames=false",
224+
},
225+
},
226+
Want: ClientCapabilities{AllowUtf8LabelNames: false},
227+
},
228+
}
229+
230+
for _, tt := range tests {
231+
t.Run(tt.Name, func(t *testing.T) {
232+
t.Parallel()
233+
234+
got, err := parseClientCapabilities(tt.Header)
235+
require.NoError(t, err)
236+
require.Equal(t, tt.Want, got)
237+
})
238+
}
239+
}

0 commit comments

Comments
 (0)