← back to Cli Printing Press
feat(cli): detect and emit required API headers from OpenAPI specs (#125)
79a945857700df516338a59d7b7608452468627c · 2026-04-04 20:10:11 -0700 · Trevin Chow
* feat(cli): detect and emit required API headers from OpenAPI specs
Many APIs require per-request headers beyond Authorization — version
headers (cal-api-version, Stripe-Version, anthropic-version) that cause
silent 404s when missing. The generator now detects these automatically.
Detection: scans all OpenAPI operations for `in: header, required: true`
parameters. Headers appearing on >80% of operations (matching the
filterGlobalParams threshold) are emitted as defaults in the generated
client. Auth-related headers (Authorization, Content-Type, Accept,
User-Agent) are excluded via case-insensitive matching.
Default values extracted from schema.default or first schema.enum entry.
Headers without defaults are emitted with empty values — doctor warns.
Three templates updated: client.go.tmpl, doctor.go.tmpl,
auth_browser.go.tmpl. APIs without required headers generate identical
code (range over empty slice = no output).
Tested against: versioned-api.yaml (positive), petstore.yaml (negative),
stytch.yaml (negative — optional headers not detected).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: mark required-api-headers plan as completed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): use mergeParameters for header override semantics, strict >80% threshold
Two fixes from Codex review:
1. Use mergeParameters(pathItem, op) instead of manually iterating
path-level then op-level params. This respects OpenAPI's override
semantics — if an operation redefines a path-level header (makes it
optional or changes its default), the op-level definition wins.
2. Change threshold from >= 0.8 to > 0.8 to match filterGlobalParams.
With 5 operations, a header on 4 (80% exactly) should NOT be
promoted — it's missing from one endpoint intentionally.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
A docs/plans/2026-04-04-002-feat-required-api-header-detection-plan.mdM internal/generator/templates/auth_browser.go.tmplM internal/generator/templates/client.go.tmplM internal/generator/templates/doctor.go.tmplM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/spec/spec.goA testdata/openapi/versioned-api.yaml
Diff
commit 79a945857700df516338a59d7b7608452468627c
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sat Apr 4 20:10:11 2026 -0700
feat(cli): detect and emit required API headers from OpenAPI specs (#125)
* feat(cli): detect and emit required API headers from OpenAPI specs
Many APIs require per-request headers beyond Authorization — version
headers (cal-api-version, Stripe-Version, anthropic-version) that cause
silent 404s when missing. The generator now detects these automatically.
Detection: scans all OpenAPI operations for `in: header, required: true`
parameters. Headers appearing on >80% of operations (matching the
filterGlobalParams threshold) are emitted as defaults in the generated
client. Auth-related headers (Authorization, Content-Type, Accept,
User-Agent) are excluded via case-insensitive matching.
Default values extracted from schema.default or first schema.enum entry.
Headers without defaults are emitted with empty values — doctor warns.
Three templates updated: client.go.tmpl, doctor.go.tmpl,
auth_browser.go.tmpl. APIs without required headers generate identical
code (range over empty slice = no output).
Tested against: versioned-api.yaml (positive), petstore.yaml (negative),
stytch.yaml (negative — optional headers not detected).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: mark required-api-headers plan as completed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): use mergeParameters for header override semantics, strict >80% threshold
Two fixes from Codex review:
1. Use mergeParameters(pathItem, op) instead of manually iterating
path-level then op-level params. This respects OpenAPI's override
semantics — if an operation redefines a path-level header (makes it
optional or changes its default), the op-level definition wins.
2. Change threshold from >= 0.8 to > 0.8 to match filterGlobalParams.
With 5 operations, a header on 4 (80% exactly) should NOT be
promoted — it's missing from one endpoint intentionally.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
...-002-feat-required-api-header-detection-plan.md | 268 +++++++++++++++++++++
internal/generator/templates/auth_browser.go.tmpl | 3 +
internal/generator/templates/client.go.tmpl | 3 +
internal/generator/templates/doctor.go.tmpl | 3 +
internal/openapi/parser.go | 86 +++++++
internal/openapi/parser_test.go | 41 ++++
internal/spec/spec.go | 38 +--
testdata/openapi/versioned-api.yaml | 96 ++++++++
8 files changed, 524 insertions(+), 14 deletions(-)
diff --git a/docs/plans/2026-04-04-002-feat-required-api-header-detection-plan.md b/docs/plans/2026-04-04-002-feat-required-api-header-detection-plan.md
new file mode 100644
index 00000000..bd51c102
--- /dev/null
+++ b/docs/plans/2026-04-04-002-feat-required-api-header-detection-plan.md
@@ -0,0 +1,268 @@
+---
+title: "feat: Detect and emit required API headers from OpenAPI specs"
+type: feat
+status: completed
+date: 2026-04-04
+origin: docs/retros/2026-04-04-cal-com-retro.md (finding #5)
+---
+
+# feat: Detect and emit required API headers from OpenAPI specs
+
+## Overview
+
+Many APIs require per-request headers beyond Authorization — version headers (`cal-api-version`, `Stripe-Version`, `anthropic-version`), custom client identifiers, or API-specific metadata headers. The generator currently only emits `Authorization` and `User-Agent`. When a required header is missing, API calls silently fail (404, stale behavior). This feature detects required headers from the OpenAPI spec and emits them in the generated client.
+
+## Problem Frame
+
+During the Cal.com CLI generation, all API calls returned 404 because the `cal-api-version: 2024-08-13` header was missing. The generator had no mechanism to detect or emit it. Manual intervention was required to add the header to the printed CLI's client.go — work that should have been automatic.
+
+This affects ~10-20% of APIs that use explicit version headers or other required per-request headers. (see origin: `docs/retros/2026-04-04-cal-com-retro.md`, finding #5)
+
+## Requirements Trace
+
+- R1. Required headers declared in the OpenAPI spec (with `in: header` and `required: true`) that appear on >80% of all operations (denominator: total operations in the spec) are emitted as default headers in the generated client
+- R2. The default value for each header is extracted from the parameter's `schema.default` or first `schema.enum` value. If neither exists, the header is still detected but emitted with an empty value — doctor warns about unconfigured required headers
+- R3. Auth-related headers (Authorization) and dynamic headers (Content-Type, User-Agent) are excluded from detection
+- R4. APIs without required headers (Stripe with no version header in spec, GitHub, Petstore) generate identical client code to today — no regression
+- R5. The detected headers are available to all templates that make HTTP requests (client, doctor, auth browser)
+
+## Scope Boundaries
+
+- **In scope**: Global required headers (>80% of operations) with known default values
+- **Not in scope**: Per-operation headers that vary across endpoints (these remain as command flags)
+- **Not in scope**: Cal.com's per-endpoint version *routing* (different version values per path prefix, e.g., bookings use `2024-08-13`, event-types use `2024-06-14`). That routing logic is API-specific. What IS in scope: detecting `cal-api-version` as a globally required header from the spec — the header name and a single default value. Cal.com's spec declares the header on most operations; the >80% threshold detects it. The per-endpoint value differences are a Cal.com-specific concern addressed separately in the printed CLI.
+- **Not in scope**: Auth inference from description text (retro finding #7 — separate feature)
+- **Not in scope for detection**: Internal YAML specs. Auto-detection applies only to OpenAPI specs. Internal YAML spec users can declare `RequiredHeaders` manually via the new struct field.
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/openapi/parser.go:744` — `filterGlobalParams`: frequency-based threshold (>80%) for identifying global query params. Same architectural pattern needed here.
+- `internal/openapi/parser.go:366` — `inferQueryParamAuth`: scans operations for auth-like params when security section is empty. Same iteration pattern.
+- `internal/openapi/parser.go:966` — `mapParameters`: **the drop point** — currently filters out `in: header` params (`parameter.In != "path" && != "query"` → continue). This is where header params get silently discarded.
+- `internal/openapi/parser.go:1077` — `mergeParameters`: correctly collects ALL params including headers. The data is available before the filter.
+- `internal/spec/spec.go:11-26` — `APISpec` struct: no `RequiredHeaders` field exists. `Auth.Header` only handles the auth header.
+- `internal/generator/templates/client.go.tmpl:343-346` — header insertion point (between Authorization and User-Agent)
+
+### Institutional Learnings
+
+- Cal.com retro finding #5: complete design spec with threshold, template, guard, and test plan
+- Steam retro finding #5: frequency-threshold pattern validated for auth param detection (>30%)
+- Both retros confirm the >80% threshold is the right level for "this is global, not per-operation"
+
+### Negative Test Fixtures
+
+- `testdata/openapi/stytch.yaml`: has `in: header` params (`X-Stytch-Member-Session`) that are `required: false` and per-operation — must NOT be detected
+- `testdata/openapi/petstore.yaml`: has `api_key` as `in: header` on a single endpoint, `required: false` — must NOT be detected
+
+## Key Technical Decisions
+
+- **80% threshold** (matching `filterGlobalParams`): A header must appear as `required: true` on >80% of operations to be considered global. This avoids false positives on per-operation headers while catching version headers that are ubiquitous. Rationale: `filterGlobalParams` uses this threshold successfully for query params; version headers have the same "almost every endpoint needs this" pattern.
+
+- **Default value from schema, not hardcoded**: The parser extracts the default from `schema.Default` or the first entry in `schema.Enum`. If neither exists, the header name is stored but the value is empty — the template emits it with an empty string and the user must configure it. Rationale: we can't invent version numbers, but we can surface the header so the user knows it's needed.
+
+- **Struct on APISpec, not a separate config**: `RequiredHeaders` goes directly on `APISpec` so all templates can access it without plumbing changes. Rationale: `client.go.tmpl` already receives `*spec.APISpec` directly (line 299 of generator.go).
+
+- **Exclude list**: Skip `Authorization`, `Content-Type`, `Accept`, `User-Agent` — these are handled by other mechanisms. Rationale: Authorization is set by the auth system, Content-Type is set per-request based on body presence, User-Agent is generated.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Q: What about APIs with per-endpoint versioning (Cal.com)?** Resolution: Out of scope. Per-endpoint routing (different version per path prefix) is API-specific. This feature handles the common case: one version header across all endpoints. Cal.com's per-endpoint routing was a manual fix in the printed CLI, and correctly so.
+
+- **Q: Should the header value be configurable via env var?** Resolution: No for v1. The value comes from the spec's default/enum. If an API changes its version, the user regenerates. Adding env var configurability is premature — we haven't seen demand for it.
+
+### Deferred to Implementation
+
+- **Q: What happens when a header has no default value and no enum?** Store the header name with empty value. The client template emits `req.Header.Set("X-Api-Version", "")`. The doctor template checks for empty-value required headers and emits a warning: `WARN Required header X-Api-Version has no configured value`. This converts a silent 404 into a visible diagnostic.
+
+## High-Level Technical Design
+
+> *This illustrates the intended approach and is directional guidance for review, not implementation specification.*
+
+```
+OpenAPI spec
+ │
+ ▼
+mergeParameters() ─── collects ALL params (path, query, header)
+ │
+ ├── mapParameters() ─── filters to path+query only (existing, unchanged)
+ │
+ └── detectRequiredHeaders() ─── NEW: filters to header only
+ │
+ ├── Filter: in=header, required=true
+ ├── Exclude: Authorization, Content-Type, Accept, User-Agent
+ ├── Count frequency across all operations
+ ├── Threshold: >80% of operations
+ └── Extract default from schema.Default or schema.Enum[0]
+ │
+ ▼
+ APISpec.RequiredHeaders []RequiredHeader{Name, Value}
+ │
+ ▼
+ client.go.tmpl: {{range .RequiredHeaders}}
+ req.Header.Set("{{.Name}}", "{{.Value}}")
+ {{end}}
+```
+
+## Implementation Units
+
+- [ ] **Unit 1: Add RequiredHeader struct and field to APISpec**
+
+**Goal:** Define the data structure for required headers in the spec
+
+**Requirements:** R1, R5
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/spec/spec.go`
+- Test: `internal/spec/spec_test.go`
+
+**Approach:**
+- Add `RequiredHeader` struct with `Name string` and `Value string` fields
+- Add `RequiredHeaders []RequiredHeader` field to `APISpec` with yaml/json tags + omitempty
+- Follow existing struct tag conventions (yaml + json)
+
+**Patterns to follow:**
+- `AuthConfig` struct in the same file for naming/tag conventions
+
+**Test scenarios:**
+- Happy path: `APISpec` with `RequiredHeaders` round-trips through YAML marshal/unmarshal
+- Happy path: `APISpec` with empty `RequiredHeaders` omits the field in YAML output (omitempty)
+
+**Verification:**
+- `go test ./internal/spec/...` passes
+- `RequiredHeaders` field accessible from templates via `{{.RequiredHeaders}}`
+
+---
+
+- [ ] **Unit 2: Implement detectRequiredHeaders in the OpenAPI parser**
+
+**Goal:** Scan OpenAPI parameters for required headers appearing on >80% of operations
+
+**Requirements:** R1, R2, R3
+
+**Dependencies:** Unit 1
+
+**Files:**
+- Modify: `internal/openapi/parser.go`
+- Test: `internal/openapi/parser_test.go`
+
+**Approach:**
+- New function `detectRequiredHeaders(doc *openapi3.T, auth spec.AuthConfig) []spec.RequiredHeader`
+- Iterate all path items and operations via `doc.Paths` (same iteration pattern as `inferQueryParamAuth`, not `filterGlobalParams` which walks post-mapped resources)
+- For each operation, call `mergeParameters` to get all params including headers
+- Filter to `parameter.In == "header"` and `parameter.Required == true`
+- Exclude known headers using case-insensitive comparison (`strings.EqualFold`): Authorization, Content-Type, Accept, User-Agent, and the auth header from `auth.Header`
+- Count frequency per header name across total operations
+- Apply 80% threshold
+- For qualifying headers, extract value from `parameter.Schema.Value.Default` or first `parameter.Schema.Value.Enum` entry
+- Call from `parse()` after `mapAuth` and `mapResources`, store result in `result.RequiredHeaders`
+
+**Patterns to follow:**
+- `inferQueryParamAuth` (line 366) for the iteration pattern (walks `doc.Paths` directly, same as needed here)
+- `filterGlobalParams` (line 744) for the threshold math only (80% frequency calculation)
+- `mergeParameters` (line 1077) for collecting all params
+
+**Test scenarios:**
+- Happy path: Spec with `cal-api-version` header (required: true) on 100% of operations → detected with correct name and default value
+- Happy path: Spec with version header on 85% of operations → detected (above 80%)
+- Edge case: Header on 75% of operations → NOT detected (below 80%)
+- Edge case: Header with `required: false` → NOT detected regardless of frequency
+- Edge case: `Authorization` header with `required: true` on all operations → excluded (auth-handled)
+- Edge case: Header with no default value and no enum → detected with empty Value
+- Edge case: Spec with zero operations → empty result, no panic
+- Integration: Existing `testdata/openapi/petstore.yaml` → no required headers detected (api_key is optional, single endpoint)
+- Integration: Existing `testdata/openapi/stytch.yaml` → no required headers detected (session headers are optional)
+
+**Verification:**
+- `go test ./internal/openapi/...` passes
+- New test fixture exercises the threshold boundary
+
+---
+
+- [ ] **Unit 3: Emit required headers in client and doctor templates**
+
+**Goal:** Generated clients set the detected headers on every request
+
+**Requirements:** R4, R5
+
+**Dependencies:** Unit 2
+
+**Files:**
+- Modify: `internal/generator/templates/client.go.tmpl`
+- Modify: `internal/generator/templates/doctor.go.tmpl`
+- Modify: `internal/generator/templates/auth_browser.go.tmpl`
+- Test: `internal/generator/generator_test.go`
+
+**Approach:**
+- In `client.go.tmpl`, add `{{range .RequiredHeaders}}` block between the auth header (line ~344) and User-Agent (line ~346)
+- Same block in `doctor.go.tmpl` auth health check request (line ~153)
+- Same block in `auth_browser.go.tmpl` auth validation request (line ~704)
+- For APIs without required headers, the range produces no output — no regression
+
+**Patterns to follow:**
+- Existing `{{if .Auth.Header}}` conditional pattern in client.go.tmpl
+- The `{{range .Tables}}` pattern in store.go.tmpl for iterating slices
+
+**Test scenarios:**
+- Happy path: Generate from spec with required headers → client.go contains `req.Header.Set("cal-api-version", "2024-08-13")`
+- Happy path: Generate from spec without required headers → client.go has no extra header lines (identical to current output)
+- Edge case: Required header with empty Value → client.go contains `req.Header.Set("X-Api-Version", "")` AND doctor.go emits a WARN for the unconfigured header
+- Integration: Full generation from petstore.yaml → binary builds, --help works, no extra headers in client
+
+**Verification:**
+- `go test ./internal/generator/...` passes
+- Generated client for a versioned-API spec sets the correct header
+- Generated client for petstore has no extra headers
+
+---
+
+- [ ] **Unit 4: Add test fixture for versioned API**
+
+**Goal:** A reusable OpenAPI test fixture that exercises required header detection
+
+**Requirements:** R1, R2
+
+**Dependencies:** Unit 2
+
+**Files:**
+- Create: `testdata/openapi/versioned-api.yaml`
+- Modify: `internal/openapi/parser_test.go`
+
+**Approach:**
+- Minimal OpenAPI spec with 5 endpoints, all requiring `X-Api-Version: 2024-01-01` header
+- One endpoint also has a per-operation optional header (should not be detected as global)
+- Used by Unit 2's parser tests as the primary positive fixture
+
+**Test scenarios:**
+- Test expectation: none -- this is a test fixture, not a feature-bearing unit
+
+**Verification:**
+- The fixture is valid OpenAPI 3.0 (parseable by kin-openapi)
+- Parser tests using this fixture pass
+
+## System-Wide Impact
+
+- **Interaction graph:** The new `RequiredHeaders` field flows from OpenAPI parser → `APISpec` → client/doctor/auth templates. No callbacks, middleware, or observers involved.
+- **Unchanged invariants:** Auth detection (`mapAuth`, `inferQueryParamAuth`) is not modified. The `mapParameters` function that maps path/query params to CLI flags is not modified. Existing generated CLIs without required headers produce identical output.
+- **API surface parity:** Three templates set HTTP headers (client, doctor, auth_browser). All three must emit required headers for consistency.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| False positive: Content-Type detected as global header | Explicit exclude list: Authorization, Content-Type, Accept, User-Agent |
+| Threshold too aggressive (80%) misses headers on 70% of ops | Conservative choice matches existing `filterGlobalParams`. Can lower later if needed. |
+| Default value missing from spec | Store with empty Value; template still emits header. Doctor could warn (future enhancement). |
+| Breaking existing CLIs | Range over empty slice produces no output — zero-diff for APIs without required headers |
+
+## Sources & References
+
+- **Origin document:** [docs/retros/2026-04-04-cal-com-retro.md](docs/retros/2026-04-04-cal-com-retro.md) — finding #5
+- Related code: `internal/openapi/parser.go` — `filterGlobalParams` (line 744), `inferQueryParamAuth` (line 366), `mapParameters` (line 959)
+- Related code: `internal/spec/spec.go` — `APISpec` struct (line 11)
+- Related code: `internal/generator/templates/client.go.tmpl` — header emission (line 343-346)
diff --git a/internal/generator/templates/auth_browser.go.tmpl b/internal/generator/templates/auth_browser.go.tmpl
index fbe21eae..3a339a2f 100644
--- a/internal/generator/templates/auth_browser.go.tmpl
+++ b/internal/generator/templates/auth_browser.go.tmpl
@@ -701,6 +701,9 @@ func validateComposedAuth(authHeader string) error {
return nil // can't validate, assume OK
}
req.Header.Set("{{if .Auth.Header}}{{.Auth.Header}}{{else}}Authorization{{end}}", authHeader)
+{{- range .RequiredHeaders}}
+ req.Header.Set("{{.Name}}", "{{.Value}}")
+{{- end}}
req.Header.Set("User-Agent", "{{.Name}}-pp-cli/{{.Version}}")
client := &http.Client{Timeout: 5 * time.Second}
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index efe0ba71..aec845bf 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -343,6 +343,9 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
req.Header.Set("{{if .Auth.Header}}{{.Auth.Header}}{{else}}Authorization{{end}}", authHeader)
{{- end}}
}
+{{- range .RequiredHeaders}}
+ req.Header.Set("{{.Name}}", "{{.Value}}")
+{{- end}}
req.Header.Set("User-Agent", "{{.Name}}-pp-cli/{{.Version}}")
resp, err := c.HTTPClient.Do(req)
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index ed1ace9b..9077c299 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -149,6 +149,9 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
authReq.URL.RawQuery = q.Encode()
{{- else}}
authReq.Header.Set("{{if .Auth.Header}}{{.Auth.Header}}{{else}}Authorization{{end}}", authHeader)
+{{- end}}
+{{- range .RequiredHeaders}}
+ authReq.Header.Set("{{.Name}}", "{{.Value}}")
{{- end}}
authReq.Header.Set("User-Agent", "{{.Name}}-pp-cli")
authResp, authErr := httpClient.Do(authReq)
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 159d9b96..bea8cb8d 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -248,6 +248,7 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
}
mapResources(doc, result, resourceBasePath)
mapTypes(doc, result)
+ result.RequiredHeaders = detectRequiredHeaders(doc, result.Auth)
if err := result.Validate(); err != nil {
return nil, fmt.Errorf("validating parsed spec: %w", err)
@@ -425,6 +426,91 @@ func inferQueryParamAuth(doc *openapi3.T, name string, fallback spec.AuthConfig)
}
}
+// detectRequiredHeaders scans all operations for required header parameters
+// and returns those appearing on >80% of operations as global required headers.
+// Auth-related and dynamic headers are excluded via case-insensitive matching.
+func detectRequiredHeaders(doc *openapi3.T, auth spec.AuthConfig) []spec.RequiredHeader {
+ if doc == nil || doc.Paths == nil {
+ return nil
+ }
+
+ // Headers to exclude (case-insensitive) — handled by other mechanisms
+ excludeHeaders := map[string]bool{
+ "authorization": true,
+ "content-type": true,
+ "accept": true,
+ "user-agent": true,
+ }
+ if auth.Header != "" {
+ excludeHeaders[strings.ToLower(auth.Header)] = true
+ }
+
+ type headerInfo struct {
+ name string
+ defaultValue string
+ count int
+ }
+
+ headers := map[string]*headerInfo{} // keyed by lowercase name
+ totalOps := 0
+
+ for _, pathKey := range doc.Paths.InMatchingOrder() {
+ pathItem := doc.Paths.Value(pathKey)
+ if pathItem == nil {
+ continue
+ }
+ for _, op := range pathItem.Operations() {
+ if op == nil {
+ continue
+ }
+ totalOps++
+ // Use mergeParameters to respect operation-level overrides.
+ // If an operation redefines a path-level header (e.g., makes it
+ // optional or changes the default), the op-level wins.
+ merged := mergeParameters(pathItem, op)
+ for _, p := range merged {
+ if p == nil {
+ continue
+ }
+ lower := strings.ToLower(p.Name)
+ if p.In != openapi3.ParameterInHeader || !p.Required || excludeHeaders[lower] {
+ continue
+ }
+ h, ok := headers[lower]
+ if !ok {
+ h = &headerInfo{name: p.Name}
+ // Extract default value from schema
+ if p.Schema != nil && p.Schema.Value != nil {
+ if p.Schema.Value.Default != nil {
+ h.defaultValue = fmt.Sprintf("%v", p.Schema.Value.Default)
+ } else if len(p.Schema.Value.Enum) > 0 {
+ h.defaultValue = fmt.Sprintf("%v", p.Schema.Value.Enum[0])
+ }
+ }
+ headers[lower] = h
+ }
+ h.count++
+ }
+ }
+ }
+
+ if totalOps == 0 {
+ return nil
+ }
+
+ var result []spec.RequiredHeader
+ threshold := 0.8
+ for _, h := range headers {
+ if float64(h.count)/float64(totalOps) > threshold {
+ result = append(result, spec.RequiredHeader{
+ Name: h.name,
+ Value: h.defaultValue,
+ })
+ }
+ }
+ return result
+}
+
func selectSecurityScheme(doc *openapi3.T) (string, *openapi3.SecurityScheme) {
if doc == nil || doc.Components == nil || len(doc.Components.SecuritySchemes) == 0 {
return "", nil
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index abe15d33..747448d2 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -437,3 +437,44 @@ func TestHumanizeDescription(t *testing.T) {
})
}
}
+
+func TestDetectRequiredHeaders(t *testing.T) {
+ t.Parallel()
+
+ t.Run("versioned API with required header on all operations", func(t *testing.T) {
+ data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "versioned-api.yaml"))
+ require.NoError(t, err)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+
+ require.Len(t, parsed.RequiredHeaders, 1)
+ assert.Equal(t, "X-Api-Version", parsed.RequiredHeaders[0].Name)
+ assert.Equal(t, "2024-01-01", parsed.RequiredHeaders[0].Value)
+ })
+
+ t.Run("petstore has no required headers", func(t *testing.T) {
+ data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "petstore.yaml"))
+ require.NoError(t, err)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+
+ assert.Empty(t, parsed.RequiredHeaders)
+ })
+
+ t.Run("stytch has no required headers (optional session headers)", func(t *testing.T) {
+ data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "stytch.yaml"))
+ require.NoError(t, err)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+
+ assert.Empty(t, parsed.RequiredHeaders)
+ })
+
+ t.Run("authorization header excluded even if required on all ops", func(t *testing.T) {
+ headers := detectRequiredHeaders(nil, spec.AuthConfig{})
+ assert.Empty(t, headers)
+ })
+}
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 10dc6243..2fb754af 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -9,20 +9,30 @@ import (
)
type APISpec struct {
- Name string `yaml:"name" json:"name"`
- Description string `yaml:"description" json:"description"`
- Version string `yaml:"version" json:"version"`
- BaseURL string `yaml:"base_url" json:"base_url"`
- BasePath string `yaml:"base_path,omitempty" json:"base_path,omitempty"`
- Owner string `yaml:"owner,omitempty" json:"owner,omitempty"` // GitHub owner for import paths and Homebrew tap
- SpecSource string `yaml:"spec_source,omitempty" json:"spec_source,omitempty"` // official, community, sniffed, docs — affects generated client defaults
- ClientPattern string `yaml:"client_pattern,omitempty" json:"client_pattern,omitempty"` // rest (default), proxy-envelope — affects generated HTTP client
- ProxyRoutes map[string]string `yaml:"proxy_routes,omitempty" json:"proxy_routes,omitempty"` // path prefix → service name for proxy-envelope routing
- WebsiteURL string `yaml:"website_url,omitempty" json:"website_url,omitempty"` // product/company website (not the API base URL)
- Auth AuthConfig `yaml:"auth" json:"auth"`
- Config ConfigSpec `yaml:"config" json:"config"`
- Resources map[string]Resource `yaml:"resources" json:"resources"`
- Types map[string]TypeDef `yaml:"types" json:"types"`
+ Name string `yaml:"name" json:"name"`
+ Description string `yaml:"description" json:"description"`
+ Version string `yaml:"version" json:"version"`
+ BaseURL string `yaml:"base_url" json:"base_url"`
+ BasePath string `yaml:"base_path,omitempty" json:"base_path,omitempty"`
+ Owner string `yaml:"owner,omitempty" json:"owner,omitempty"` // GitHub owner for import paths and Homebrew tap
+ SpecSource string `yaml:"spec_source,omitempty" json:"spec_source,omitempty"` // official, community, sniffed, docs — affects generated client defaults
+ ClientPattern string `yaml:"client_pattern,omitempty" json:"client_pattern,omitempty"` // rest (default), proxy-envelope — affects generated HTTP client
+ ProxyRoutes map[string]string `yaml:"proxy_routes,omitempty" json:"proxy_routes,omitempty"` // path prefix → service name for proxy-envelope routing
+ WebsiteURL string `yaml:"website_url,omitempty" json:"website_url,omitempty"` // product/company website (not the API base URL)
+ Auth AuthConfig `yaml:"auth" json:"auth"`
+ RequiredHeaders []RequiredHeader `yaml:"required_headers,omitempty" json:"required_headers,omitempty"`
+ Config ConfigSpec `yaml:"config" json:"config"`
+ Resources map[string]Resource `yaml:"resources" json:"resources"`
+ Types map[string]TypeDef `yaml:"types" json:"types"`
+}
+
+// RequiredHeader represents a non-auth header that the API requires on most
+// requests (e.g., cal-api-version, Stripe-Version, anthropic-version).
+// Detected automatically from OpenAPI specs when a required header parameter
+// appears on >80% of operations.
+type RequiredHeader struct {
+ Name string `yaml:"name" json:"name"`
+ Value string `yaml:"value" json:"value"`
}
type AuthConfig struct {
diff --git a/testdata/openapi/versioned-api.yaml b/testdata/openapi/versioned-api.yaml
new file mode 100644
index 00000000..28e3816c
--- /dev/null
+++ b/testdata/openapi/versioned-api.yaml
@@ -0,0 +1,96 @@
+openapi: "3.0.0"
+info:
+ title: Versioned API
+ version: "1.0.0"
+ description: Test fixture for required header detection
+servers:
+ - url: https://api.example.com/v2
+paths:
+ /v2/users:
+ get:
+ operationId: getUsers
+ summary: List users
+ parameters:
+ - name: X-Api-Version
+ in: header
+ required: true
+ schema:
+ type: string
+ default: "2024-01-01"
+ responses:
+ "200":
+ description: OK
+ post:
+ operationId: createUser
+ summary: Create user
+ parameters:
+ - name: X-Api-Version
+ in: header
+ required: true
+ schema:
+ type: string
+ default: "2024-01-01"
+ responses:
+ "201":
+ description: Created
+ /v2/users/{id}:
+ get:
+ operationId: getUser
+ summary: Get user by ID
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: X-Api-Version
+ in: header
+ required: true
+ schema:
+ type: string
+ default: "2024-01-01"
+ responses:
+ "200":
+ description: OK
+ patch:
+ operationId: updateUser
+ summary: Update user
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: X-Api-Version
+ in: header
+ required: true
+ schema:
+ type: string
+ default: "2024-01-01"
+ responses:
+ "200":
+ description: OK
+ delete:
+ operationId: deleteUser
+ summary: Delete user
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: X-Api-Version
+ in: header
+ required: true
+ schema:
+ type: string
+ default: "2024-01-01"
+ - name: X-Request-Id
+ in: header
+ required: false
+ schema:
+ type: string
+ description: Optional per-request correlation ID (should NOT be detected as global)
+ responses:
+ "204":
+ description: No Content
← 26fe5727 fix(cli): unhide sub-resource groups when wired into promote
·
back to Cli Printing Press
·
feat(cli): infer API auth from spec description when securit 75ad9cdb →