-
Notifications
You must be signed in to change notification settings - Fork 250
Support ${headers.NAME} syntax to forward upstream API headers to toolsets #1725
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,7 @@ import ( | |
| "github.com/getkin/kin-openapi/openapi3" | ||
|
|
||
| "github.com/docker/cagent/pkg/tools" | ||
| "github.com/docker/cagent/pkg/upstream" | ||
| "github.com/docker/cagent/pkg/useragent" | ||
| ) | ||
|
|
||
|
|
@@ -349,9 +350,11 @@ func sanitizeToolName(name string) string { | |
| } | ||
|
|
||
| // setHeaders sets the User-Agent and custom headers on an HTTP request. | ||
| // Header values may contain ${headers.NAME} placeholders that are resolved | ||
| // from upstream headers stored in the request context. | ||
| func setHeaders(req *http.Request, headers map[string]string) { | ||
| req.Header.Set("User-Agent", useragent.Header) | ||
| for k, v := range headers { | ||
| for k, v := range upstream.ResolveHeaders(req.Context(), headers) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Context may not contain upstream headers for placeholder resolution Similar to If the caller didn't propagate the context from an HTTP request that went through the Evidence: The test Suggestion:
|
||
| req.Header.Set(k, v) | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| // Package upstream provides utilities for propagating HTTP headers | ||
| // from incoming API requests to outbound toolset HTTP calls. | ||
| package upstream | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "net/http" | ||
| "regexp" | ||
| "strings" | ||
|
|
||
| "github.com/dop251/goja" | ||
| ) | ||
|
|
||
| type contextKey struct{} | ||
|
|
||
| // WithHeaders returns a new context carrying the given HTTP headers. | ||
| func WithHeaders(ctx context.Context, h http.Header) context.Context { | ||
| return context.WithValue(ctx, contextKey{}, h) | ||
| } | ||
|
|
||
| // HeadersFromContext retrieves upstream HTTP headers from the context. | ||
| // Returns nil if no headers are present. | ||
| func HeadersFromContext(ctx context.Context) http.Header { | ||
| h, _ := ctx.Value(contextKey{}).(http.Header) | ||
| return h | ||
| } | ||
|
|
||
| // Handler wraps an http.Handler to store the incoming HTTP request | ||
| // headers in the request context for downstream toolset forwarding. | ||
| func Handler(next http.Handler) http.Handler { | ||
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| ctx := WithHeaders(r.Context(), r.Header.Clone()) | ||
| next.ServeHTTP(w, r.WithContext(ctx)) | ||
| }) | ||
| } | ||
|
|
||
| // NewHeaderTransport wraps an http.RoundTripper to set custom headers on | ||
| // every outbound request. Header values may contain ${headers.NAME} | ||
| // placeholders that are resolved at request time from upstream headers | ||
| // stored in the request context. | ||
| func NewHeaderTransport(base http.RoundTripper, headers map[string]string) http.RoundTripper { | ||
| return &headerTransport{base: base, headers: headers} | ||
| } | ||
|
|
||
| type headerTransport struct { | ||
| base http.RoundTripper | ||
| headers map[string]string | ||
| } | ||
|
|
||
| func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { | ||
| req = req.Clone(req.Context()) | ||
| for key, value := range ResolveHeaders(req.Context(), t.headers) { | ||
| req.Header.Set(key, value) | ||
| } | ||
| return t.base.RoundTrip(req) | ||
| } | ||
|
|
||
| // ResolveHeaders resolves ${headers.NAME} placeholders in header values | ||
| // using upstream headers from the context. Header names in the placeholder | ||
| // are case-insensitive, matching HTTP header convention. | ||
| // | ||
| // For example, given the config header: | ||
| // | ||
| // Authorization: ${headers.Authorization} | ||
| // | ||
| // and an upstream request with "Authorization: Bearer token", the resolved | ||
| // value will be "Bearer token". | ||
| func ResolveHeaders(ctx context.Context, headers map[string]string) map[string]string { | ||
| if len(headers) == 0 { | ||
| return headers | ||
| } | ||
|
|
||
| upstream := HeadersFromContext(ctx) | ||
| if upstream == nil { | ||
| return headers | ||
| } | ||
|
|
||
| vm := goja.New() | ||
| _ = vm.Set("headers", vm.NewDynamicObject(headerAccessor(func(name string) goja.Value { | ||
| return vm.ToValue(upstream.Get(name)) | ||
| }))) | ||
|
|
||
| resolved := make(map[string]string, len(headers)) | ||
| for k, v := range headers { | ||
| resolved[k] = expandTemplate(vm, v) | ||
| } | ||
| return resolved | ||
| } | ||
|
|
||
| // headerAccessor implements [goja.DynamicObject] for case-insensitive | ||
| // HTTP header lookups. | ||
| type headerAccessor func(string) goja.Value | ||
|
|
||
| func (h headerAccessor) Get(k string) goja.Value { return h(k) } | ||
| func (headerAccessor) Set(string, goja.Value) bool { return false } | ||
| func (headerAccessor) Has(string) bool { return true } | ||
| func (headerAccessor) Delete(string) bool { return false } | ||
| func (headerAccessor) Keys() []string { return nil } | ||
|
|
||
| // headerPlaceholderRe matches ${headers.NAME} and captures the header | ||
| // name so we can rewrite it to bracket notation for the JS runtime. | ||
| var headerPlaceholderRe = regexp.MustCompile(`\$\{\s*headers\.([^}]+)\}`) | ||
|
|
||
| // expandTemplate evaluates a string as a JavaScript template literal, | ||
| // resolving any ${...} expressions via the goja runtime. | ||
| // Before evaluation it rewrites ${headers.NAME} to ${headers["NAME"]} | ||
| // so that header names containing hyphens (e.g. X-Request-Id) are | ||
| // accessed correctly. | ||
| func expandTemplate(vm *goja.Runtime, text string) string { | ||
| if !strings.Contains(text, "${") { | ||
| return text | ||
| } | ||
|
|
||
| // Rewrite dotted header access to bracket notation so names with | ||
| // hyphens work: ${headers.X-Req-Id} → ${headers["X-Req-Id"]} | ||
| text = headerPlaceholderRe.ReplaceAllStringFunc(text, func(m string) string { | ||
| parts := headerPlaceholderRe.FindStringSubmatch(m) | ||
| name := strings.TrimSpace(parts[1]) | ||
| return `${headers["` + name + `"]}` | ||
| }) | ||
|
|
||
| escaped := strings.ReplaceAll(text, "\\", "\\\\") | ||
| escaped = strings.ReplaceAll(escaped, "`", "\\`") | ||
| script := "`" + escaped + "`" | ||
|
|
||
| v, err := vm.RunString(script) | ||
| if err != nil { | ||
| return text | ||
| } | ||
| if v == nil || v.Export() == nil { | ||
| return "" | ||
| } | ||
| return fmt.Sprintf("%v", v.Export()) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Context may not contain upstream headers for placeholder resolution
The
setHeadersfunction is called to resolve${headers.NAME}placeholders fromreq.Context(). However, the context comes from thectxparameter passed tocallTool. If this tool is invoked outside the normal HTTP handler middleware chain (or if theupstream.Handlermiddleware wasn't applied), the context won't contain upstream headers and placeholders won't resolve.Current flow:
upstream.Handlermiddleware → stores headers in contextcallTool(ctx, ...)setHeaderstries to resolve placeholders fromctxThe implementation assumes the
ctxparameter contains upstream headers, but this isn't validated or guaranteed.Evidence: The test cases in
api_test.goall uset.Context()(a plain testing context with no upstream headers) and don't test placeholder resolution.Suggestion: Either:
upstream.Handler