feat(client): support header and cookie parameters

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 19:50:41 +00:00
parent 7c3e8e7ba6
commit ebad4c397d
2 changed files with 266 additions and 5 deletions

174
client.go
View file

@ -36,10 +36,16 @@ type openAPIOperation struct {
method string
pathTemplate string
hasRequestBody bool
parameters []openAPIParameter
requestSchema map[string]any
responseSchema map[string]any
}
type openAPIParameter struct {
name string
in string
}
// OpenAPIClientOption configures a runtime OpenAPI client.
type OpenAPIClientOption func(*OpenAPIClient)
@ -88,9 +94,9 @@ func NewOpenAPIClient(opts ...OpenAPIClientOption) *OpenAPIClient {
// Call invokes the operation with the given operationId.
//
// The params argument may be a map, struct, or nil. For convenience, a map may
// include "path", "query", and "body" keys to explicitly control where the
// values are sent. When no explicit body is provided, requests with a declared
// requestBody send the remaining parameters as JSON.
// include "path", "query", "header", "cookie", and "body" keys to explicitly
// control where the values are sent. When no explicit body is provided,
// requests with a declared requestBody send the remaining parameters as JSON.
func (c *OpenAPIClient) Call(operationID string, params any) (any, error) {
if err := c.load(); err != nil {
return nil, err
@ -140,6 +146,7 @@ func (c *OpenAPIClient) Call(operationID string, params any) (any, error) {
if c.bearerToken != "" {
req.Header.Set("Authorization", "Bearer "+c.bearerToken)
}
applyRequestParameters(req, op, merged)
resp, err := c.httpClient.Do(req)
if err != nil {
@ -228,10 +235,12 @@ func (c *OpenAPIClient) loadSpec() error {
if operationID == "" {
continue
}
params := parseOperationParameters(operation)
operations[operationID] = openAPIOperation{
method: strings.ToUpper(method),
pathTemplate: pathTemplate,
hasRequestBody: operation["requestBody"] != nil,
parameters: params,
requestSchema: requestBodySchema(operation),
responseSchema: firstSuccessResponseSchema(operation),
}
@ -303,12 +312,15 @@ func (c *OpenAPIClient) buildURL(op openAPIOperation, params map[string]any) (st
}
if op.method == http.MethodGet || (op.method == http.MethodHead && !op.hasRequestBody) {
for key, value := range params {
if key == "path" || key == "body" || key == "query" {
if key == "path" || key == "body" || key == "query" || key == "header" || key == "cookie" {
continue
}
if containsString(pathKeys, key) {
continue
}
if operationParameterLocation(op, key) == "header" || operationParameterLocation(op, key) == "cookie" {
continue
}
if _, exists := query[key]; exists {
continue
}
@ -346,12 +358,16 @@ func (c *OpenAPIClient) buildBody(op openAPIOperation, params map[string]any) ([
payload := make(map[string]any, len(params))
for key, value := range params {
if key == "path" || key == "query" || key == "body" {
if key == "path" || key == "query" || key == "body" || key == "header" || key == "cookie" {
continue
}
if containsString(pathKeys, key) {
continue
}
switch operationParameterLocation(op, key) {
case "header", "cookie", "query":
continue
}
if _, exists := queryKeys[key]; exists {
continue
}
@ -363,6 +379,154 @@ func (c *OpenAPIClient) buildBody(op openAPIOperation, params map[string]any) ([
return encodeJSONBody(payload)
}
func applyRequestParameters(req *http.Request, op openAPIOperation, params map[string]any) {
explicitHeaders, hasExplicitHeaders := nestedMap(params, "header")
explicitCookies, hasExplicitCookies := nestedMap(params, "cookie")
if hasExplicitHeaders {
applyHeaderValues(req.Header, explicitHeaders)
}
applyTopLevelHeaderParameters(req.Header, op, params, explicitHeaders, hasExplicitHeaders)
if hasExplicitCookies {
applyCookieValues(req, explicitCookies)
}
applyTopLevelCookieParameters(req, op, params, explicitCookies, hasExplicitCookies)
}
func applyTopLevelHeaderParameters(headers http.Header, op openAPIOperation, params, explicit map[string]any, hasExplicit bool) {
for key, value := range params {
if key == "path" || key == "query" || key == "body" || key == "header" || key == "cookie" {
continue
}
if operationParameterLocation(op, key) != "header" {
continue
}
if hasExplicit {
if _, ok := explicit[key]; ok {
continue
}
}
applyHeaderValue(headers, key, value)
}
}
func applyTopLevelCookieParameters(req *http.Request, op openAPIOperation, params, explicit map[string]any, hasExplicit bool) {
for key, value := range params {
if key == "path" || key == "query" || key == "body" || key == "header" || key == "cookie" {
continue
}
if operationParameterLocation(op, key) != "cookie" {
continue
}
if hasExplicit {
if _, ok := explicit[key]; ok {
continue
}
}
applyCookieValue(req, key, value)
}
}
func applyHeaderValues(headers http.Header, values map[string]any) {
for key, value := range values {
applyHeaderValue(headers, key, value)
}
}
func applyHeaderValue(headers http.Header, key string, value any) {
switch v := value.(type) {
case nil:
return
case []string:
for _, item := range v {
headers.Add(key, item)
}
return
case []any:
for _, item := range v {
headers.Add(key, fmt.Sprint(item))
}
return
}
rv := reflect.ValueOf(value)
if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array) && !(rv.Type().Elem().Kind() == reflect.Uint8) {
for i := 0; i < rv.Len(); i++ {
headers.Add(key, fmt.Sprint(rv.Index(i).Interface()))
}
return
}
headers.Set(key, fmt.Sprint(value))
}
func applyCookieValues(req *http.Request, values map[string]any) {
for key, value := range values {
applyCookieValue(req, key, value)
}
}
func applyCookieValue(req *http.Request, key string, value any) {
switch v := value.(type) {
case nil:
return
case []string:
for _, item := range v {
req.AddCookie(&http.Cookie{Name: key, Value: item})
}
return
case []any:
for _, item := range v {
req.AddCookie(&http.Cookie{Name: key, Value: fmt.Sprint(item)})
}
return
}
rv := reflect.ValueOf(value)
if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array) && !(rv.Type().Elem().Kind() == reflect.Uint8) {
for i := 0; i < rv.Len(); i++ {
req.AddCookie(&http.Cookie{Name: key, Value: fmt.Sprint(rv.Index(i).Interface())})
}
return
}
req.AddCookie(&http.Cookie{Name: key, Value: fmt.Sprint(value)})
}
func parseOperationParameters(operation map[string]any) []openAPIParameter {
rawParams, ok := operation["parameters"].([]any)
if !ok {
return nil
}
params := make([]openAPIParameter, 0, len(rawParams))
for _, rawParam := range rawParams {
param, ok := rawParam.(map[string]any)
if !ok {
continue
}
name, _ := param["name"].(string)
in, _ := param["in"].(string)
if name == "" || in == "" {
continue
}
params = append(params, openAPIParameter{name: name, in: in})
}
return params
}
func operationParameterLocation(op openAPIOperation, name string) string {
for _, param := range op.parameters {
if param.name == name {
return param.in
}
}
return ""
}
func encodeJSONBody(v any) ([]byte, error) {
data, err := json.Marshal(v)
if err != nil {

View file

@ -257,6 +257,103 @@ paths:
}
}
func TestOpenAPIClient_Good_UsesHeaderAndCookieParameters(t *testing.T) {
errCh := make(chan error, 1)
mux := http.NewServeMux()
mux.HandleFunc("/inspect", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
errCh <- fmt.Errorf("expected GET, got %s", r.Method)
w.WriteHeader(http.StatusInternalServerError)
return
}
if got := r.Header.Get("X-Trace-ID"); got != "trace-123" {
errCh <- fmt.Errorf("expected X-Trace-ID=trace-123, got %q", got)
w.WriteHeader(http.StatusInternalServerError)
return
}
if got := r.Header.Get("X-Custom-Header"); got != "custom-value" {
errCh <- fmt.Errorf("expected X-Custom-Header=custom-value, got %q", got)
w.WriteHeader(http.StatusInternalServerError)
return
}
session, err := r.Cookie("session_id")
if err != nil {
errCh <- fmt.Errorf("expected session_id cookie: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if session.Value != "cookie-123" {
errCh <- fmt.Errorf("expected session_id=cookie-123, got %q", session.Value)
w.WriteHeader(http.StatusInternalServerError)
return
}
pref, err := r.Cookie("pref")
if err != nil {
errCh <- fmt.Errorf("expected pref cookie: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if pref.Value != "dark" {
errCh <- fmt.Errorf("expected pref=dark, got %q", pref.Value)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
specPath := writeTempSpec(t, `openapi: 3.1.0
info:
title: Test API
version: 1.0.0
paths:
/inspect:
get:
operationId: inspect_request
parameters:
- name: X-Trace-ID
in: header
- name: session_id
in: cookie
`)
client := api.NewOpenAPIClient(
api.WithSpec(specPath),
api.WithBaseURL(srv.URL),
)
result, err := client.Call("inspect_request", map[string]any{
"X-Trace-ID": "trace-123",
"session_id": "cookie-123",
"header": map[string]any{
"X-Custom-Header": "custom-value",
},
"cookie": map[string]any{
"pref": "dark",
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
select {
case err := <-errCh:
t.Fatal(err)
default:
}
decoded, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map result, got %T", result)
}
if okValue, ok := decoded["ok"].(bool); !ok || !okValue {
t.Fatalf("expected ok=true, got %#v", decoded["ok"])
}
}
func TestOpenAPIClient_Good_UsesFirstAbsoluteServer(t *testing.T) {
errCh := make(chan error, 1)
mux := http.NewServeMux()