← back to Cli Printing Press
feat(cli): per-endpoint header routing and auth inference from Authorization header params (#136)
fb164adc55020060e96d933a8a07e8f06eb60396 · 2026-04-05 18:14:07 -0700 · Trevin Chow
Two OpenAPI parser improvements:
1. Per-endpoint header routing: when a required header (e.g., cal-api-version) has
different values across endpoint groups, each generated command sends the correct
value. Global RequiredHeaders use the majority value as default; endpoints with
different values get HeaderOverrides. Client template supports WithHeaders variants
for all HTTP methods.
2. Auth inference tier 4: when securitySchemes, query-param inference, and description
inference all fail, scans operations for required Authorization header parameters.
If found on >30% of operations, infers Bearer auth. Catches APIs like Cal.com that
declare auth via individual header params instead of securitySchemes.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
A docs/plans/2026-04-05-002-feat-per-endpoint-headers-and-auth-param-inference-plan.mdA docs/retros/2026-04-05-cal-com-run2-retro.mdM internal/generator/templates/client.go.tmplM internal/generator/templates/command_endpoint.go.tmplM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/spec/spec.goA testdata/openapi/auth-header-param.yamlA testdata/openapi/multi-version-header.yaml
Diff
commit fb164adc55020060e96d933a8a07e8f06eb60396
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun Apr 5 18:14:07 2026 -0700
feat(cli): per-endpoint header routing and auth inference from Authorization header params (#136)
Two OpenAPI parser improvements:
1. Per-endpoint header routing: when a required header (e.g., cal-api-version) has
different values across endpoint groups, each generated command sends the correct
value. Global RequiredHeaders use the majority value as default; endpoints with
different values get HeaderOverrides. Client template supports WithHeaders variants
for all HTTP methods.
2. Auth inference tier 4: when securitySchemes, query-param inference, and description
inference all fail, scans operations for required Authorization header parameters.
If found on >30% of operations, infers Bearer auth. Catches APIs like Cal.com that
declare auth via individual header params instead of securitySchemes.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
...dpoint-headers-and-auth-param-inference-plan.md | 365 +++++++++++++++++++++
docs/retros/2026-04-05-cal-com-run2-retro.md | 192 +++++++++++
internal/generator/templates/client.go.tmpl | 42 ++-
.../generator/templates/command_endpoint.go.tmpl | 28 ++
internal/openapi/parser.go | 177 +++++++++-
internal/openapi/parser_test.go | 106 +++++-
internal/spec/spec.go | 21 +-
testdata/openapi/auth-header-param.yaml | 59 ++++
testdata/openapi/multi-version-header.yaml | 113 +++++++
9 files changed, 1067 insertions(+), 36 deletions(-)
diff --git a/docs/plans/2026-04-05-002-feat-per-endpoint-headers-and-auth-param-inference-plan.md b/docs/plans/2026-04-05-002-feat-per-endpoint-headers-and-auth-param-inference-plan.md
new file mode 100644
index 00000000..d2b9e6dc
--- /dev/null
+++ b/docs/plans/2026-04-05-002-feat-per-endpoint-headers-and-auth-param-inference-plan.md
@@ -0,0 +1,365 @@
+---
+title: "feat: Per-endpoint version header routing and auth inference from Authorization header params"
+type: feat
+status: active
+date: 2026-04-05
+origin: docs/retros/2026-04-05-cal-com-run2-retro.md (findings F1, F4)
+---
+
+# feat: Per-endpoint version header routing and auth inference from Authorization header params
+
+## Overview
+
+Two OpenAPI parser improvements that address the same root cause: the parser silently discards information from individual operations that it should preserve. For headers, it promotes one global value and drops per-endpoint variations. For auth, it never scans operation-level header parameters at all. Both cause runtime failures (404s, missing auth) on APIs like Cal.com that declare these at the operation level rather than globally.
+
+## Problem Frame
+
+**Per-endpoint headers (F1):** Cal.com uses `cal-api-version` with DIFFERENT values per endpoint group: bookings gets `2024-08-13`, event-types gets `2024-06-14`, schedules gets `2024-04-15`. The parser's `detectRequiredHeaders()` promotes the majority value globally, causing 404s on ~20% of endpoints. This affects any API with per-resource versioning (Cal.com, Twilio, enterprise APIs). ~10% of catalog.
+
+**Auth from header params (F4):** Cal.com's spec has no `securitySchemes`, no auth-like query params, and a minimal `info.description` ("Cal.com v2 API") with no auth keywords. All three existing auth inference tiers fail. But the spec declares `Authorization` as a required header parameter on individual endpoints. The parser never scans operation-level header params for auth signals. ~20% of specs without formal security sections.
+
+## Requirements Trace
+
+- R1. When a required header has multiple distinct values across endpoint groups, each generated command sends the correct value for its endpoint
+- R2. The global `RequiredHeaders` on `APISpec` continues to work for single-value headers (no regression)
+- R3. When all three auth inference tiers fail, the parser scans operations for required `Authorization` header parameters and infers Bearer auth
+- R4. Inferred auth from header params sets `Auth.Type`, `Auth.Header`, `Auth.EnvVars`, `Auth.In`, and `Auth.Inferred = true`
+- R5. Explicit auth (`securitySchemes` present) always takes precedence — inference tiers only run when prior tiers return `Type: "none"`
+- R6. The scorer recognizes auth inferred from header params (existing `Auth.Inferred` infrastructure)
+
+## Scope Boundaries
+
+- **In scope:** Per-endpoint header value overrides; auth inference from Authorization header params
+- **Not in scope:** Inferring OAuth2 flows from header params; per-endpoint headers for non-required params; changes to the 30% frequency threshold
+- **Not in scope:** Changes to `inferQueryParamAuth` or `inferDescriptionAuth` — these are stable
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/openapi/parser.go:445` — `detectRequiredHeaders()`: scans operations, promotes headers at >80% frequency. Currently stores single `defaultValue` per header name
+- `internal/openapi/parser.go:272` — `mapAuth()`: chains `selectSecurityScheme` → `inferQueryParamAuth` → `inferDescriptionAuth`. Fourth tier slots after description
+- `internal/openapi/parser.go:383` — `inferQueryParamAuth()`: the exact pattern to follow for the new tier. Same iteration, same frequency threshold, same `AuthConfig` construction
+- `internal/openapi/parser.go:553` — `inferDescriptionAuth()`: the most recent tier added. Returns `Inferred: true`
+- `internal/openapi/parser.go:1222` — `mapParameters()`: filters out header params entirely (`In != path && In != query → continue`). Auth tier 4 must scan independently, not rely on mapParameters
+- `internal/spec/spec.go:29` — `RequiredHeader{Name, Value}`: needs extension for per-endpoint overrides
+- `internal/spec/spec.go:65` — `Endpoint` struct: has `Meta map[string]string` for ad-hoc metadata. Per-endpoint header overrides are better as a typed field
+- `internal/generator/templates/client.go.tmpl:346` — global header emission in `do()` method
+- `internal/generator/templates/command_endpoint.go.tmpl` — per-endpoint command; currently cannot override headers
+
+### Institutional Learnings
+
+- Steam retro #5: `inferQueryParamAuth` precedent validates the "fallback tier" pattern and 30% threshold
+- Steam run 4 retro: "don't weaken existing thresholds for edge cases" — add new tiers instead
+- Steam run 7 retro: `Auth.EnvVars` MUST be populated for verify to pass env vars to CLI subprocess
+- Required header detection plan (004-002): explicitly scoped per-endpoint routing as future work
+- Auth inference plan (004-003): explicitly scoped operation-level param scanning as future work
+
+## Key Technical Decisions
+
+- **Per-endpoint overrides on Endpoint struct, not on RequiredHeader:** The retro WU-1 suggested not changing the spec data model, but the current `RequiredHeader{Name, Value}` struct cannot express per-endpoint variations. Adding `HeaderOverrides []RequiredHeader` to `spec.Endpoint` is the cleanest path — it's typed, per-endpoint, and doesn't break the global `APISpec.RequiredHeaders` contract.
+
+- **Client passes overrides via params map extension, not method signature change:** Rather than changing `do()` to accept a headers parameter (which touches every caller), the command template sets per-endpoint headers directly on the `http.Request` after calling `http.NewRequest` but before `client.Do`. This requires the command template to have access to the raw request, which means either: (a) add an optional `headers map[string]string` parameter to the client methods, or (b) use a `WithHeaders` option pattern. Option (a) is simpler given the existing codebase patterns.
+
+- **Fourth-tier auth uses same iteration as `inferQueryParamAuth`:** Walk `doc.Paths`, call `mergeParameters()`, filter for `In == "header"` and name matches `authorization` (case-insensitive). Count occurrences, apply >30% threshold. This is consistent with the established pattern and avoids special-casing.
+
+- **Parser detects per-endpoint values during `detectRequiredHeaders`, stores them on Endpoint during `mapResources`:** The detection and storage happen in two phases. First, `detectRequiredHeaders` is extended to also return a per-path value map when multiple values exist for the same header. Then, during `mapResources` (which builds endpoints), each endpoint gets its header overrides from this map. This keeps the parser's two-phase structure (headers detected globally, then applied per-endpoint).
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Q: Should per-endpoint header overrides be on the Endpoint struct or as a separate map on APISpec?** On Endpoint — it's per-endpoint data, so it belongs on the Endpoint struct. A top-level map keyed by path would duplicate what the Endpoint already represents.
+
+- **Q: How does the command template pass per-endpoint headers to the client?** Add an optional `headers map[string]string` parameter to `Get`, `Post`, `Put`, `Patch`, `Delete` methods on the client. When non-nil, these are set on the request alongside the global RequiredHeaders. The global headers serve as defaults; per-endpoint headers override them for matching names.
+
+- **Q: What if the Authorization header param has a description hinting at the format?** Scan the description for "Bearer" or "Basic" keywords (case-insensitive) to refine the inferred auth type. Default to `bearer_token` if no format hint is present (Bearer is the most common API auth scheme).
+
+### Deferred to Implementation
+
+- **Q: Exact header override storage format?** Whether `[]RequiredHeader` or `map[string]string` on Endpoint — implementation will determine which is cleaner with the template syntax.
+
+## High-Level Technical Design
+
+> *This illustrates the intended approach and is directional guidance for review, not implementation specification.*
+
+### Per-Endpoint Header Routing
+
+```
+detectRequiredHeaders(doc, auth)
+ │
+ ├── Existing: count per-header frequency across all operations
+ ├── NEW: also track per-path values when values differ
+ │ headerValues[headerName][path] = value
+ │
+ ├── For headers above 80% threshold:
+ │ ├── If all values identical → single RequiredHeader (existing behavior)
+ │ └── If multiple values → RequiredHeader with majority value as global
+ │ + return perEndpointHeaders map
+ │
+ └── Return ([]RequiredHeader, map[string]map[string]string)
+ ↑ headerName → path → value
+
+mapResources(doc, out, basePath)
+ │
+ ├── Calls detectRequiredHeaders (now returns per-endpoint map)
+ ├── For each endpoint being built:
+ │ └── Check if this endpoint's path has a header override
+ │ → Set endpoint.HeaderOverrides = [{Name, Value}]
+ │
+ └── Endpoints with no override use the global RequiredHeaders automatically
+```
+
+### Auth Inference Fourth Tier
+
+```
+mapAuth(doc, name)
+ │
+ ├── Tier 1: selectSecurityScheme(doc) → if found, return
+ ├── Tier 2: inferQueryParamAuth(doc, name, fallback) → if not "none", return
+ ├── Tier 3: inferDescriptionAuth(doc, name, fallback) → if not "none", return
+ └── Tier 4: inferAuthHeaderParam(doc, name, fallback) ← NEW
+ │
+ ├── Walk doc.Paths, mergeParameters(pathItem, op)
+ ├── Filter: In == "header", name matches "authorization" (case-insensitive)
+ ├── Count operations with Authorization header param
+ │
+ ├── If count / totalOps > 0.30:
+ │ ├── Check param description for "Bearer" / "Basic" hints
+ │ ├── Default: Type="bearer_token", Header="Authorization"
+ │ ├── EnvVars=[PREFIX_TOKEN], In="header", Inferred=true
+ │ └── Return AuthConfig
+ │
+ └── Else: return fallback (Type: "none")
+```
+
+## Implementation Units
+
+- [ ] **Unit 1: Extend Endpoint struct with HeaderOverrides**
+
+**Goal:** Add typed per-endpoint header override support to the spec data model
+
+**Requirements:** R1, R2
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/spec/spec.go`
+- Test: `internal/spec/spec_test.go`
+
+**Approach:**
+- Add `HeaderOverrides []RequiredHeader` field to `Endpoint` with `yaml:"header_overrides,omitempty" json:"header_overrides,omitempty"` tags
+- Existing global `RequiredHeaders` on `APISpec` is unchanged — per-endpoint overrides only exist on `Endpoint`
+
+**Patterns to follow:**
+- `RequiredHeaders` field pattern on `APISpec` (line 23)
+- `Inferred bool` field addition pattern from auth inference plan
+
+**Test scenarios:**
+- Happy path: Endpoint with HeaderOverrides round-trips through YAML/JSON
+- Happy path: Endpoint with empty HeaderOverrides omits the field (omitempty)
+
+**Verification:**
+- `go test ./internal/spec/...` passes
+
+---
+
+- [ ] **Unit 2: Extend detectRequiredHeaders to track per-endpoint values**
+
+**Goal:** When a required header has multiple distinct values across endpoints, return both the global default and a per-path value map
+
+**Requirements:** R1, R2
+
+**Dependencies:** Unit 1
+
+**Files:**
+- Modify: `internal/openapi/parser.go`
+- Test: `internal/openapi/parser_test.go`
+- Create: `testdata/openapi/multi-version-header.yaml`
+
+**Approach:**
+- Change `detectRequiredHeaders` return type from `[]RequiredHeader` to `([]RequiredHeader, map[string]map[string]string)` — the second value maps `headerName → apiPath → value`
+- During iteration, when a header name is already known with a different value, record it in the per-path map instead of ignoring it
+- The global `RequiredHeader.Value` uses the majority value (most frequent across operations)
+- When all values are identical, the per-path map is empty (existing behavior preserved)
+- Update the call site in `parse()` to capture both returns
+
+**Patterns to follow:**
+- `inferQueryParamAuth` iteration pattern: `doc.Paths.InMatchingOrder()`, pathItem.Operations(), `mergeParameters()`
+- Existing `headerInfo` struct (line 464) — extend with `values map[string]int` to count per-value frequency
+
+**Test scenarios:**
+- Happy path: Spec with uniform header value → single RequiredHeader, empty per-path map
+- Happy path: Spec with 3 endpoint groups using different values → RequiredHeader with majority value, per-path map with deviations
+- Edge case: Header with 2 values at 50/50 split → either is acceptable as global default; both are in per-path map
+- Edge case: Header with value on some endpoints and no value on others → global uses majority; endpoints without value not in per-path map
+- Integration: Existing versioned-api.yaml fixture → unchanged behavior (all same value)
+- Integration: Existing petstore.yaml → still no required headers
+
+**Verification:**
+- `go test ./internal/openapi/...` passes
+- New fixture exercises the per-endpoint path
+
+---
+
+- [ ] **Unit 3: Populate Endpoint.HeaderOverrides during mapResources**
+
+**Goal:** Wire per-path header values into each endpoint's HeaderOverrides field during resource mapping
+
+**Requirements:** R1
+
+**Dependencies:** Unit 2
+
+**Files:**
+- Modify: `internal/openapi/parser.go` (mapResources and parse functions)
+- Test: `internal/openapi/parser_test.go`
+
+**Approach:**
+- In `parse()`, capture the per-path map from `detectRequiredHeaders` and pass it to `mapResources`
+- In `mapResources`, after building each endpoint, check if the endpoint's API path has an override in the per-path map. If the override differs from the global `RequiredHeaders` value, set `endpoint.HeaderOverrides`
+- Only set overrides when the value DIFFERS from global — don't redundantly store the global value
+
+**Patterns to follow:**
+- The existing flow where `result.RequiredHeaders` is set in `parse()` before `mapResources` is called
+
+**Test scenarios:**
+- Happy path: Parse multi-version-header.yaml → bookings endpoints have no overrides (they match global), event-types endpoints have HeaderOverrides with the different value
+- Edge case: Endpoint with no matching override → HeaderOverrides is nil/empty
+- Integration: Parse petstore.yaml → no endpoints have HeaderOverrides
+
+**Verification:**
+- `go test ./internal/openapi/...` passes
+- Full parse of multi-version fixture produces correct per-endpoint overrides
+
+---
+
+- [ ] **Unit 4: Extend client methods to accept per-request header overrides**
+
+**Goal:** Client's HTTP methods accept optional headers that override the global RequiredHeaders for that request
+
+**Requirements:** R1
+
+**Dependencies:** Unit 1
+
+**Files:**
+- Modify: `internal/generator/templates/client.go.tmpl`
+- Test: `internal/generator/generator_test.go`
+
+**Approach:**
+- Add a `headers map[string]string` parameter to the `do()` method. When non-nil, set these on the request AFTER global RequiredHeaders, overriding any matching names
+- Update `Get`, `Post`, `Put`, `Patch`, `Delete` method signatures to accept and pass through optional headers. Use a nil-safe pattern so existing callers (which don't need overrides) pass nil
+- The global RequiredHeaders in client.go.tmpl remain as-is — they set defaults. Per-endpoint overrides take priority
+
+**Patterns to follow:**
+- The existing `params map[string]string` parameter pattern on client methods
+
+**Test scenarios:**
+- Happy path: Generated client with per-endpoint headers compiles and runs
+- Happy path: Override header value supersedes global RequiredHeader for that request
+- Edge case: nil headers parameter → global RequiredHeaders applied (no change from current behavior)
+
+**Verification:**
+- Generated CLI compiles (`go build ./...`)
+
+---
+
+- [ ] **Unit 5: Command templates emit per-endpoint header overrides**
+
+**Goal:** Command endpoint and promoted templates pass HeaderOverrides to client methods
+
+**Requirements:** R1
+
+**Dependencies:** Units 3, 4
+
+**Files:**
+- Modify: `internal/generator/templates/command_endpoint.go.tmpl`
+- Modify: `internal/generator/templates/command_promoted.go.tmpl`
+- Test: `internal/generator/generator_test.go`
+
+**Approach:**
+- In the command template, check if `.Endpoint.HeaderOverrides` is non-empty
+- If so, build a `map[string]string` from the overrides and pass it to the client method call
+- If empty, pass nil (existing behavior)
+- Template conditional: `{{- if .Endpoint.HeaderOverrides}} headers := map[string]string{...} {{- end}}`
+
+**Patterns to follow:**
+- Existing `{{- if .HasStore}}` conditional blocks in command templates
+
+**Test scenarios:**
+- Happy path: Generate from multi-version-header fixture → event-types command passes override headers
+- Happy path: Generate from petstore.yaml → commands pass nil headers (no overrides)
+- Integration: Full generation + build from multi-version fixture succeeds
+
+**Verification:**
+- Generated CLI compiles and the correct header value appears in dry-run output
+
+---
+
+- [ ] **Unit 6: Implement inferAuthHeaderParam as fourth auth tier**
+
+**Goal:** Detect Bearer auth from required Authorization header parameters when all three prior tiers fail
+
+**Requirements:** R3, R4, R5, R6
+
+**Dependencies:** None (independent of Units 1-5)
+
+**Files:**
+- Modify: `internal/openapi/parser.go`
+- Test: `internal/openapi/parser_test.go`
+- Create: `testdata/openapi/auth-header-param.yaml`
+
+**Approach:**
+- New function `inferAuthHeaderParam(doc *openapi3.T, name string, fallback spec.AuthConfig) spec.AuthConfig`
+- Guard for `doc == nil` or `doc.Paths == nil` → return fallback
+- Walk all operations via `doc.Paths.InMatchingOrder()`, use `mergeParameters()` to get all params
+- Count operations where a parameter has `In == "header"` and `Name` matches `authorization` (case-insensitive) and `Required == true`
+- If count/totalOps > 0.30: infer Bearer auth
+ - Check param description for "Bearer" → `Type: "bearer_token"`, "Basic" → `Type: "api_key"` with Basic format
+ - Default to `Type: "bearer_token"` if no hint
+ - Set `Header: "Authorization"`, `In: "header"`, `EnvVars: [PREFIX_TOKEN]`, `Inferred: true`
+- Wire into `mapAuth`: after `inferDescriptionAuth` returns "none", call `inferAuthHeaderParam`
+
+**Patterns to follow:**
+- `inferQueryParamAuth` for function signature, iteration, threshold, and AuthConfig construction
+- `inferDescriptionAuth` for the `Inferred: true` pattern and negation guards
+- `commonAuthQueryParams` map for keyword matching style
+
+**Test scenarios:**
+- Happy path: Spec with required Authorization header param on >30% of ops → Type="bearer_token", Header="Authorization", Inferred=true, EnvVars populated
+- Happy path: Spec with Authorization param description containing "Bearer" → Type="bearer_token"
+- Happy path: Spec with Authorization param description containing "Basic" → Type="api_key" with Basic format
+- Edge case: Spec WITH securitySchemes AND Authorization header params → explicit auth wins, tier 4 never runs
+- Edge case: Spec with Authorization header params on <30% of ops → fallback returned
+- Edge case: Spec with no header params at all → fallback returned
+- Edge case: Optional (not required) Authorization header param → not counted
+- Integration: Existing petstore.yaml → auth unchanged (has securitySchemes)
+- Integration: Existing versioned-api.yaml → auth stays "none" (no Authorization params)
+
+**Verification:**
+- `go test ./internal/openapi/...` passes
+- New fixture exercises the fourth tier
+
+## System-Wide Impact
+
+- **Interaction graph (headers):** `detectRequiredHeaders` → `RequiredHeader` → `Endpoint.HeaderOverrides` → command template → client `do()` → HTTP request. Global RequiredHeaders in client.go.tmpl are defaults; per-endpoint overrides supersede.
+- **Interaction graph (auth):** `mapAuth` → `inferAuthHeaderParam` → `AuthConfig` → config template + client template + doctor template + auth template + README template. All downstream templates use the same `Auth.*` fields — no new plumbing needed.
+- **Unchanged invariants:** `selectSecurityScheme`, `inferQueryParamAuth`, `inferDescriptionAuth` are not modified. The 30% query-param threshold is unchanged. Specs with explicit security sections produce identical output.
+- **Verify integration:** `Auth.EnvVars` must be populated for verify to pass correct env vars (Steam run 7 learning). The new tier must set this field.
+- **Scorer interaction:** The `auth_protocol` scorecard dimension already handles `Auth.Inferred` (from the description inference work). No scorer changes needed.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Client method signature change breaks generated CLI compilation | All callers updated by template; nil parameter preserves current behavior |
+| Per-endpoint header map grows large for specs with many paths | Only stored when values DIFFER from global — most endpoints use global default |
+| False positive auth inference from non-auth Authorization params | >30% threshold + required=true filter minimizes risk; Inferred flag tells user to verify |
+| Breaking existing CLIs | Per-endpoint overrides are additive; existing global headers unchanged; auth tiers only fire when prior tiers return "none" |
+
+## Sources & References
+
+- **Origin document:** [docs/retros/2026-04-05-cal-com-run2-retro.md](docs/retros/2026-04-05-cal-com-run2-retro.md) — findings F1, F4
+- **Prior art (headers):** [docs/plans/2026-04-04-002-feat-required-api-header-detection-plan.md](docs/plans/2026-04-04-002-feat-required-api-header-detection-plan.md) — completed plan that implemented the initial RequiredHeader feature
+- **Prior art (auth):** [docs/plans/2026-04-04-003-feat-auth-inference-from-description-plan.md](docs/plans/2026-04-04-003-feat-auth-inference-from-description-plan.md) — completed plan for tiers 1-3
+- Related retros: Cal.com retro finding #5 (per-endpoint versioning), Steam retro #5 (query-param auth precedent), Steam run 7 retro (EnvVars must be populated)
+- Related code: `internal/openapi/parser.go` — `detectRequiredHeaders` (line 445), `mapAuth` (line 272), `inferQueryParamAuth` (line 383), `inferDescriptionAuth` (line 553)
diff --git a/docs/retros/2026-04-05-cal-com-run2-retro.md b/docs/retros/2026-04-05-cal-com-run2-retro.md
new file mode 100644
index 00000000..777faadd
--- /dev/null
+++ b/docs/retros/2026-04-05-cal-com-run2-retro.md
@@ -0,0 +1,192 @@
+# Printing Press Retro: Cal.com (Run 2)
+
+## Session Stats
+- API: cal-com
+- Spec source: OpenAPI 3.0.0 from GitHub (285+ endpoints, 181 unique paths)
+- Scorecard: 96/100 (Grade A) — after 2 polish passes
+- Verify pass rate: 100% (26/26)
+- Dogfood: PASS (0 dead flags, 0 dead functions, 6/6 valid paths)
+- Fix loops: 2 polish passes
+- Manual code edits: 4 (auth env var, base URL, cal-api-version header, store migration NOT NULL)
+- Features built from scratch: 4 (stats, conflicts, gaps, noshow — by polish worker)
+- Machine fixes committed during session: 8
+
+## Findings
+
+### F1. Per-Endpoint Version Header Routing (Assumption mismatch)
+- **What happened:** Cal.com uses `cal-api-version` headers with DIFFERENT values per endpoint group (bookings: `2024-08-13`, event-types: `2024-06-14`, schedules: `2024-04-15`). The generator's `detectRequiredHeaders()` promotes headers appearing on >80% of endpoints to a global default, picking the majority value. Event-types and schedules endpoints get the wrong version header, returning HTTP 404.
+- **Scorer correct?** N/A — detected during live dogfood, not scored.
+- **Root cause:** `internal/openapi/parser.go` `detectRequiredHeaders()` uses a frequency threshold to promote required headers to global. When multiple values exist for the same header name, it picks the most common one and discards the rest.
+- **Cross-API check:** Any API with per-resource versioning. Cal.com, Twilio, some enterprise APIs.
+- **Frequency:** API subclass: per-endpoint-versioned APIs (~10% of catalog)
+- **Fallback if the Printing Press doesn't fix it:** Claude fixes during verify loop — caught reliably because wrong version causes 400/404. Costs a fix loop iteration.
+- **Worth a Printing Press fix?** Yes — eliminates a guaranteed fix loop.
+- **Inherent or fixable:** Fixable. When multiple values exist for the same required header, track per-endpoint values instead of promoting one global.
+- **Durable fix:** In the OpenAPI parser, when a required header has multiple distinct values, store a map of `path → header-value` alongside the global default. The command template should set the per-endpoint override when it differs from global.
+- **Test:** Positive: Cal.com event-types endpoints get `cal-api-version: 2024-06-14`. Negative: Single-version API still uses global header.
+- **Evidence:** Live dogfood test #4: event-types returned HTTP 404.
+
+### F2. Store Migration NOT NULL on Foreign Keys (Template gap)
+- **What happened:** Generated store schema uses `NOT NULL` on foreign key columns (`organizations_id`, `teams_id`) that only exist in org-level API responses. When syncing as a regular user (not org admin), the API doesn't return these fields. Migration creates the table but subsequent inserts fail because the NOT NULL constraint can't be satisfied.
+- **Scorer correct?** N/A — detected during sync, not scored.
+- **Root cause:** `internal/generator/` store template infers foreign key columns from spec path hierarchy (e.g., `/v2/organizations/{orgId}/teams` → `organizations_id` column). It marks all inferred FK columns as NOT NULL, but some are only populated in org-scoped API responses.
+- **Cross-API check:** Any API with hierarchical resources where child resources are also accessible directly (not just under the parent). GitHub (repos accessible via user or org), Stripe (resources accessible per account or per connected account).
+- **Frequency:** API subclass: APIs with hierarchical/org-scoped resources (~25% of catalog)
+- **Fallback if the Printing Press doesn't fix it:** Manual edit to make columns nullable. Easy but tedious.
+- **Worth a Printing Press fix?** Yes — simple template change.
+- **Inherent or fixable:** Fixable. Inferred FK columns should default to nullable unless the column is part of the primary key.
+- **Durable fix:** In the store template, change inferred foreign key columns from `TEXT NOT NULL` to `TEXT` (nullable). Only the primary key and entity ID should be NOT NULL.
+- **Test:** Positive: store migration succeeds when syncing as non-org user. Negative: entity's own ID column is still NOT NULL.
+- **Evidence:** `sync --full` failed with "SQL logic error: no such column: organizations_id".
+
+### F3. Promoted Command Defaults to Wrong Endpoint (Template gap)
+- **What happened:** The `schedules` promoted command's RunE uses the "get default schedule" endpoint (`GET /v2/schedules/default`) instead of "list schedules" (`GET /v2/schedules`). Running `cal-com-pp-cli schedules` hits a 404 because the default schedule endpoint requires different auth context.
+- **Scorer correct?** N/A — detected during live dogfood.
+- **Root cause:** `buildPromotedCommands()` in generator.go selects the first endpoint from the resource as the promoted endpoint. For schedules, "default" (get default) sorts before "list" alphabetically. The function should prefer "list" or "get" endpoints over others.
+- **Cross-API check:** Any API where the alphabetically-first endpoint isn't the most useful one. Common when resources have special endpoints (default, check, connect).
+- **Frequency:** API subclass: APIs with special singleton endpoints alongside list endpoints (~15%)
+- **Fallback if the Printing Press doesn't fix it:** Polish worker or skill notices wrong output during verify.
+- **Worth a Printing Press fix?** Yes — simple selection logic fix.
+- **Inherent or fixable:** Fixable. Prefer "list" > "get" > other for promoted endpoint selection.
+- **Durable fix:** In `buildPromotedCommands()`, add a preference order: endpoints named "list" first, "get" second, then alphabetical. This ensures the most common user intent (browsing resources) is the default.
+- **Test:** Positive: `schedules` promoted command lists schedules. Negative: resource with only a "create" endpoint still promotes that.
+- **Evidence:** Live dogfood test #5: schedules returned HTTP 404.
+
+### F4. Auth Not Inferred When Spec Lacks Description (Assumption mismatch)
+- **What happened:** Cal.com's spec has no `securitySchemes` AND a minimal `info.description` without auth keywords. The `inferDescriptionAuth` function (added in this session's PR) didn't trigger because the description doesn't mention "Bearer", "API key", etc. Had to manually add `CAL_COM_TOKEN` env var support to config.go.
+- **Scorer correct?** Auth scored 10/10 only after manual fix.
+- **Root cause:** `inferDescriptionAuth` only scans `info.description`. Cal.com's spec description is "Cal.com v2 API" — no auth keywords. But the API absolutely requires Bearer auth (cal_live_ prefix tokens).
+- **Cross-API check:** Any API whose spec omits both securitySchemes and auth keywords in description. Internal APIs, auto-generated specs from frameworks.
+- **Frequency:** API subclass: specs without formal auth declaration (~20%)
+- **Fallback if the Printing Press doesn't fix it:** Skill adds auth during Phase 2 post-generation. ~70% reliable.
+- **Worth a Printing Press fix?** Yes — add a fourth inference tier.
+- **Inherent or fixable:** Fixable. Scan operation-level parameters for required `Authorization` headers as a fourth-tier fallback.
+- **Durable fix:** After `inferDescriptionAuth` fails, scan all operations for a required header parameter named `Authorization`. If found consistently (>30% of operations), infer Bearer auth. Cal.com's spec uses `Authorization` as a required header on individual endpoints.
+- **Test:** Positive: Cal.com spec with Authorization header params → infers Bearer. Negative: spec with no Authorization params → stays "none".
+- **Evidence:** Doctor reported "Auth: not required" until manual fix.
+
+### F5. Base URL Placeholder When Servers Block Missing (Default gap)
+- **What happened:** Cal.com's GitHub-hosted spec has no `servers` block. The generator uses `https://api.example.com` as placeholder. Had to manually set to `https://api.cal.com`.
+- **Scorer correct?** N/A — but causes all API calls to fail until fixed.
+- **Root cause:** `internal/openapi/parser.go` falls back to placeholder when no servers defined. The placeholder is obviously wrong but the generator doesn't flag it as a blocking issue.
+- **Cross-API check:** Any spec without a servers block (GitHub-hosted specs, auto-generated specs).
+- **Frequency:** API subclass: specs without servers (~15% of specs)
+- **Fallback if the Printing Press doesn't fix it:** Skill or user notices during verify.
+- **Worth a Printing Press fix?** Yes — the parser can infer from the spec URL.
+- **Inherent or fixable:** Fixable. When `--spec-url` is provided and no servers block exists, derive the base URL from the spec URL's domain (e.g., `raw.githubusercontent.com/.../cal.com/...` → `https://api.cal.com`). Or at minimum, emit a prominent warning.
+- **Durable fix:** In the parser, when no servers block exists: (1) try to infer from spec URL domain if available, (2) check if the API name maps to a common pattern (e.g., `cal-com` → `api.cal.com`), (3) emit a WARNING requiring the user to set base_url.
+- **Test:** Positive: Cal.com spec + spec-url → base URL inferred as api.cal.com. Negative: spec with servers block → uses servers URL.
+- **Evidence:** All commands returned errors until manual base URL fix.
+
+### F6. Auth Resource Endpoint Files Generated But Never Wired (Template gap)
+- **What happened:** The generator creates endpoint files for the `auth` resource (oauth2-get-client.go, oauth2-token.go) but the root.go template excludes `auth` from the AddCommand loop (line 101: `ne $name "auth"`). These constructors are defined but never called, causing 2 "unregistered commands" in dogfood.
+- **Scorer correct?** Dogfood correctly flags them as unregistered — they genuinely aren't wired.
+- **Root cause:** The generator always creates endpoint files for every resource, but the `auth` resource is special — it's replaced by the auth template. The endpoint files for `auth` sub-resources are dead code.
+- **Cross-API check:** Every API where the spec has auth-related endpoints in a resource named "auth" or "oauth".
+- **Frequency:** API subclass: APIs with auth management endpoints (~30%)
+- **Fallback if the Printing Press doesn't fix it:** Dogfood flags them as unregistered; harmless but noisy.
+- **Worth a Printing Press fix?** Yes — skip endpoint file generation for the auth resource (same pattern as promoted resource parent skipping).
+- **Inherent or fixable:** Fixable. The generator already knows to skip auth from AddCommand — it should also skip endpoint file generation for the auth resource.
+- **Durable fix:** In the resource generation loop, skip endpoint file generation when the resource name is "auth" (matching the root.go template exclusion).
+- **Test:** Positive: auth resource endpoints not generated. Negative: auth.go template still emitted.
+- **Evidence:** Dogfood: "2 unregistered commands: oauth2-get-client, oauth2-token"
+
+## What Was Fixed During This Session
+
+These findings were discovered and fixed in the Printing Press before/during this Cal.com run:
+
+| Fix | Commit | Impact |
+|-----|--------|--------|
+| Token masking in client template | acb9bff | Auth 8→10/10 on every CLI |
+| Conditional data-layer helper emission | acb9bff | Dead Code 3→5/5 for non-data CLIs |
+| Dogfood wiring check rewrite | acb9bff | 16 false positives → 2 genuine on Cal.com |
+| OperationId normalization | acb9bff | No more controller-2024-08-13 filenames |
+| Auto-calibrated endpoint limit | dd5abb1 | No more silently skipped endpoints |
+| Skip dead promoted resource files | bfa4331 | 14 dead constructors → 2 |
+| Spec provenance (--spec-url, checksum, archive) | fabe228, c636529 | Every CLI traceable and reproducible |
+| Store=true → Sync=true invariant | 307aeb8 | Sync always generated when store exists |
+
+## Prioritized Improvements
+
+### P1 — High priority
+| # | Finding | Component | Frequency | Fallback Reliability | Complexity |
+|---|---------|-----------|-----------|---------------------|------------|
+| F1 | Per-endpoint version header routing | OpenAPI parser + command template | ~10% of APIs | ~90% via verify loop | medium |
+| F4 | Auth inference from Authorization header params | OpenAPI parser | ~20% of APIs | ~70% via skill | medium |
+
+### P2 — Medium priority
+| # | Finding | Component | Frequency | Fallback Reliability | Complexity |
+|---|---------|-----------|-----------|---------------------|------------|
+| F2 | Store migration NOT NULL on FK columns | Store template | ~25% of APIs | ~95% manual fix | small |
+| F3 | Promoted command endpoint selection | Generator (buildPromotedCommands) | ~15% of APIs | ~80% via verify | small |
+| F5 | Base URL inference when servers missing | OpenAPI parser | ~15% of specs | ~90% manual fix | small |
+| F6 | Auth resource endpoint files dead code | Generator resource loop | ~30% of APIs | Harmless noise | small |
+
+### Skip
+*None — all findings have cross-API applicability.*
+
+## Work Units
+
+### WU-1: Per-Endpoint Version Header Routing (from F1)
+- **Goal:** When an API uses different version header values per endpoint group, each command sends the correct version
+- **Target:** `internal/openapi/parser.go` (detectRequiredHeaders) and `internal/generator/templates/command_endpoint.go.tmpl`
+- **Acceptance criteria:**
+ - positive test: Cal.com event-types commands send `cal-api-version: 2024-06-14`; bookings commands send `2024-08-13`
+ - negative test: single-version API still uses global header without per-endpoint overrides
+- **Scope boundary:** Does NOT change the spec data model — uses existing RequiredHeaders with per-endpoint values
+- **Dependencies:** None
+- **Complexity:** medium
+
+### WU-2: Auth Inference from Authorization Header Params (from F4)
+- **Goal:** When spec has no securitySchemes and no auth keywords in description, scan operations for required Authorization header parameters as fourth-tier auth inference
+- **Target:** `internal/openapi/parser.go` (mapAuth chain)
+- **Acceptance criteria:**
+ - positive test: Cal.com spec (no securitySchemes, has Authorization header params) → infers Bearer
+ - negative test: spec WITH securitySchemes → uses those, skips param scan
+- **Scope boundary:** Only scans for Authorization header parameters, not arbitrary headers
+- **Dependencies:** None (builds on existing inferDescriptionAuth)
+- **Complexity:** medium
+
+### WU-3: Nullable FK Columns in Store Migrations (from F2)
+- **Goal:** Inferred foreign key columns in store migrations default to nullable
+- **Target:** Store template in `internal/generator/`
+- **Acceptance criteria:**
+ - positive test: org-scoped table has nullable organizations_id column
+ - negative test: entity's own ID column is still NOT NULL
+- **Scope boundary:** Only affects inferred FK columns, not user-defined schema
+- **Dependencies:** None
+- **Complexity:** small
+
+### WU-4: Promoted Command Endpoint Selection (from F3)
+- **Goal:** Promoted commands default to the list endpoint, not alphabetically first
+- **Target:** `internal/generator/generator.go` (buildPromotedCommands)
+- **Acceptance criteria:**
+ - positive test: schedules promoted command lists schedules (not get-default)
+ - negative test: resource with only "create" still promotes that
+- **Scope boundary:** Only changes selection logic, not the promoted command template
+- **Dependencies:** None
+- **Complexity:** small
+
+### WU-5: Skip Auth Resource Endpoint Files (from F6)
+- **Goal:** Don't generate endpoint files for the "auth" resource since they're never wired
+- **Target:** `internal/generator/generator.go` (resource generation loop)
+- **Acceptance criteria:**
+ - positive test: no oauth2-get-client.go or oauth2-token.go generated for Cal.com
+ - negative test: auth.go template still emitted; non-auth resources still generate endpoints
+- **Scope boundary:** Only skips the "auth" resource name, not other resources
+- **Dependencies:** None
+- **Complexity:** small
+
+## Anti-patterns
+- **Post-generation auth patching:** Adding auth env vars to config.go after generation is fragile — it misses doctor, client, README, and auth template integration. The parser should get auth right so every template benefits.
+- **Global header promotion for multi-value headers:** When an API has per-resource header values, promoting one value globally guarantees some endpoints break.
+
+## What the Printing Press Got Right
+- **Auto-calibrated endpoint limit:** No endpoints silently skipped (fixed during session).
+- **Spec archiving:** Every CLI now carries its spec.json + provenance manifest — fully reproducible.
+- **OperationId normalization:** `BookingsController_2024-08-13_getBooking` → `get` under bookings. Clean command names.
+- **Token masking:** `maskToken()` in client.go — auth score 10/10 on first generation.
+- **Promoted command dead code elimination:** No more dead parent/endpoint files for promoted resources.
+- **Wiring check accuracy:** Static source analysis catches real issues without false positives.
+- **Data layer generation:** Store, search, analytics, sync, FTS5, 4 insight commands — all generated and working.
+- **96/100 scorecard:** After machine fixes + 2 polish passes, Grade A with only Type Fidelity (3/5) and Terminal UX (9/10) as gaps.
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 1e0f4085..c755e18f 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -198,13 +198,17 @@ func (c *Client) RateLimit() float64 {
}
func (c *Client) Get(path string, params map[string]string) (json.RawMessage, error) {
+ return c.GetWithHeaders(path, params, nil)
+}
+
+func (c *Client) GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error) {
// Check cache for GET requests
if !c.NoCache && !c.DryRun && c.cacheDir != "" {
if cached, ok := c.readCache(path, params); ok {
return cached, nil
}
}
- result, _, err := c.do("GET", path, params, nil)
+ result, _, err := c.do("GET", path, params, nil, headers)
if err == nil && !c.NoCache && !c.DryRun && c.cacheDir != "" {
c.writeCache(path, params, result)
}
@@ -240,22 +244,40 @@ func (c *Client) writeCache(path string, params map[string]string, data json.Raw
}
func (c *Client) Post(path string, body any) (json.RawMessage, int, error) {
- return c.do("POST", path, nil, body)
+ return c.do("POST", path, nil, body, nil)
+}
+
+func (c *Client) PostWithHeaders(path string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("POST", path, nil, body, headers)
}
func (c *Client) Delete(path string) (json.RawMessage, int, error) {
- return c.do("DELETE", path, nil, nil)
+ return c.do("DELETE", path, nil, nil, nil)
+}
+
+func (c *Client) DeleteWithHeaders(path string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("DELETE", path, nil, nil, headers)
}
func (c *Client) Put(path string, body any) (json.RawMessage, int, error) {
- return c.do("PUT", path, nil, body)
+ return c.do("PUT", path, nil, body, nil)
+}
+
+func (c *Client) PutWithHeaders(path string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PUT", path, nil, body, headers)
}
func (c *Client) Patch(path string, body any) (json.RawMessage, int, error) {
- return c.do("PATCH", path, nil, body)
+ return c.do("PATCH", path, nil, body, nil)
}
-func (c *Client) do(method, path string, params map[string]string, body any) (json.RawMessage, int, error) {
+func (c *Client) PatchWithHeaders(path string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, nil, body, headers)
+}
+
+// do executes an HTTP request. headerOverrides, when non-nil, override global
+// RequiredHeaders for this specific request (used for per-endpoint API versioning).
+func (c *Client) do(method, path string, params map[string]string, body any, headerOverrides map[string]string) (json.RawMessage, int, error) {
{{- if eq .ClientPattern "proxy-envelope"}}
// Proxy-envelope: all requests are POST'd to BaseURL with a JSON envelope
targetURL := c.BaseURL
@@ -274,7 +296,7 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
// Build the request for dry-run display or actual execution
if c.DryRun {
- return c.dryRun(method, targetURL, path, params, bodyBytes)
+ return c.dryRun(method, targetURL, path, params, bodyBytes, headerOverrides)
}
const maxRetries = 3
@@ -346,6 +368,10 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
{{- range .RequiredHeaders}}
req.Header.Set("{{.Name}}", "{{.Value}}")
{{- end}}
+ // Per-endpoint header overrides (e.g., different API version per resource)
+ for k, v := range headerOverrides {
+ req.Header.Set(k, v)
+ }
req.Header.Set("User-Agent", "{{.Name}}-pp-cli/{{.Version}}")
resp, err := c.HTTPClient.Do(req)
@@ -400,7 +426,7 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
return nil, 0, lastErr
}
-func (c *Client) dryRun(method, targetURL, path string, params map[string]string, body []byte) (json.RawMessage, int, error) {
+func (c *Client) dryRun(method, targetURL, path string, params map[string]string, body []byte, headerOverrides map[string]string) (json.RawMessage, int, error) {
{{- if eq .ClientPattern "proxy-envelope"}}
// Show the proxy envelope that would be sent
envelope := proxyEnvelope{
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index ccb712ab..80999af9 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -83,6 +83,14 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- end}}
{{- end}}
+{{- if .Endpoint.HeaderOverrides}}
+ headerOverrides := map[string]string{
+{{- range .Endpoint.HeaderOverrides}}
+ "{{.Name}}": "{{.Value}}",
+{{- end}}
+ }
+{{- end}}
+
{{- if or (eq .Endpoint.Method "GET") (eq .Endpoint.Method "HEAD")}}
{{- if .Endpoint.Pagination}}
{{- if .HasStore}}
@@ -113,10 +121,14 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- end}}
{{- if .HasStore}}
data, prov, err := resolveRead(c, flags, "{{lower .ResourceName}}", {{if .Endpoint.Pagination}}true{{else}}false{{end}}, path, params)
+{{- else}}
+{{- if .Endpoint.HeaderOverrides}}
+ data, err := c.GetWithHeaders(path, params, headerOverrides)
{{- else}}
data, err := c.Get(path, params)
{{- end}}
{{- end}}
+{{- end}}
{{- else if eq .Endpoint.Method "POST"}}
var body map[string]any
if stdinBody {
@@ -137,9 +149,17 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
}
{{- end}}
}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.PostWithHeaders(path, body, headerOverrides)
+{{- else}}
data, statusCode, err := c.Post(path, body)
+{{- end}}
{{- else if eq .Endpoint.Method "DELETE"}}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.DeleteWithHeaders(path, headerOverrides)
+{{- else}}
data, statusCode, err := c.Delete(path)
+{{- end}}
{{- else if eq .Endpoint.Method "PUT"}}
var body map[string]any
if stdinBody {
@@ -160,7 +180,11 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
}
{{- end}}
}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.PutWithHeaders(path, body, headerOverrides)
+{{- else}}
data, statusCode, err := c.Put(path, body)
+{{- end}}
{{- else if eq .Endpoint.Method "PATCH"}}
var body map[string]any
if stdinBody {
@@ -181,8 +205,12 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
}
{{- end}}
}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.PatchWithHeaders(path, body, headerOverrides)
+{{- else}}
data, statusCode, err := c.Patch(path, body)
{{- end}}
+{{- end}}
{{- if eq .Endpoint.Method "DELETE"}}
if err != nil {
return classifyDeleteError(err)
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 4bcc8576..3bad51c8 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -260,7 +260,9 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
}
mapResources(doc, result, resourceBasePath)
mapTypes(doc, result)
- result.RequiredHeaders = detectRequiredHeaders(doc, result.Auth)
+ var perEndpointHeaders map[string]map[string]string
+ result.RequiredHeaders, perEndpointHeaders = detectRequiredHeaders(doc, result.Auth)
+ applyHeaderOverrides(result, perEndpointHeaders)
if err := result.Validate(); err != nil {
return nil, fmt.Errorf("validating parsed spec: %w", err)
@@ -275,7 +277,10 @@ func mapAuth(doc *openapi3.T, name string) spec.AuthConfig {
if scheme == nil {
result := inferQueryParamAuth(doc, name, auth)
if result.Type == "none" {
- return inferDescriptionAuth(doc, name, result)
+ result = inferDescriptionAuth(doc, name, result)
+ }
+ if result.Type == "none" {
+ result = inferAuthHeaderParam(doc, name, result)
}
return result
}
@@ -445,9 +450,9 @@ 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 {
+func detectRequiredHeaders(doc *openapi3.T, auth spec.AuthConfig) ([]spec.RequiredHeader, map[string]map[string]string) {
if doc == nil || doc.Paths == nil {
- return nil
+ return nil, nil
}
// Headers to exclude (case-insensitive) — handled by other mechanisms
@@ -465,6 +470,8 @@ func detectRequiredHeaders(doc *openapi3.T, auth spec.AuthConfig) []spec.Require
name string
defaultValue string
count int
+ valueCounts map[string]int // value → count (for multi-value detection)
+ pathValues map[string]string // apiPath → value (for per-endpoint overrides)
}
headers := map[string]*headerInfo{} // keyed by lowercase name
@@ -491,33 +498,115 @@ func detectRequiredHeaders(doc *openapi3.T, auth spec.AuthConfig) []spec.Require
}
h, ok := headers[lower]
if !ok {
- h = &headerInfo{name: p.Name}
- 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])
- }
+ h = &headerInfo{
+ name: p.Name,
+ valueCounts: map[string]int{},
+ pathValues: map[string]string{},
}
headers[lower] = h
}
h.count++
+
+ // Extract this operation's header value
+ val := ""
+ if p.Schema != nil && p.Schema.Value != nil {
+ if p.Schema.Value.Default != nil {
+ val = fmt.Sprintf("%v", p.Schema.Value.Default)
+ } else if len(p.Schema.Value.Enum) > 0 {
+ val = fmt.Sprintf("%v", p.Schema.Value.Enum[0])
+ }
+ }
+ if val != "" {
+ h.valueCounts[val]++
+ h.pathValues[pathKey] = val
+ }
}
}
}
if totalOps == 0 {
- return nil
+ return nil, nil
}
var result []spec.RequiredHeader
+ // perEndpointHeaders maps headerName → apiPath → value for headers with
+ // multiple distinct values. Endpoints whose value differs from the global
+ // default get an override.
+ perEndpointHeaders := map[string]map[string]string{}
+
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,
- })
+ if float64(h.count)/float64(totalOps) <= threshold {
+ continue
+ }
+
+ // Find the majority value (global default)
+ bestVal := ""
+ bestCount := 0
+ for val, cnt := range h.valueCounts {
+ if cnt > bestCount {
+ bestVal = val
+ bestCount = cnt
+ }
+ }
+ h.defaultValue = bestVal
+
+ result = append(result, spec.RequiredHeader{
+ Name: h.name,
+ Value: h.defaultValue,
+ })
+
+ // Record per-endpoint overrides for paths with non-majority values
+ if len(h.valueCounts) > 1 {
+ overrides := map[string]string{}
+ for path, val := range h.pathValues {
+ if val != bestVal {
+ overrides[path] = val
+ }
+ }
+ if len(overrides) > 0 {
+ perEndpointHeaders[h.name] = overrides
+ }
+ }
+ }
+ return result, perEndpointHeaders
+}
+
+// applyHeaderOverrides sets HeaderOverrides on each Endpoint whose API path
+// has a per-endpoint header value differing from the global default.
+func applyHeaderOverrides(s *spec.APISpec, perEndpoint map[string]map[string]string) {
+ if len(perEndpoint) == 0 || s == nil {
+ return
+ }
+ for rName, r := range s.Resources {
+ for eName, e := range r.Endpoints {
+ overrides := headerOverridesForPath(e.Path, perEndpoint)
+ if len(overrides) > 0 {
+ e.HeaderOverrides = overrides
+ r.Endpoints[eName] = e
+ }
+ }
+ for subName, sub := range r.SubResources {
+ for eName, e := range sub.Endpoints {
+ overrides := headerOverridesForPath(e.Path, perEndpoint)
+ if len(overrides) > 0 {
+ e.HeaderOverrides = overrides
+ sub.Endpoints[eName] = e
+ }
+ }
+ r.SubResources[subName] = sub
+ }
+ s.Resources[rName] = r
+ }
+}
+
+// headerOverridesForPath checks if the given endpoint path has per-endpoint header
+// overrides. Returns the matching overrides as RequiredHeader entries.
+func headerOverridesForPath(endpointPath string, perEndpoint map[string]map[string]string) []spec.RequiredHeader {
+ var result []spec.RequiredHeader
+ for headerName, pathValues := range perEndpoint {
+ if val, ok := pathValues[endpointPath]; ok {
+ result = append(result, spec.RequiredHeader{Name: headerName, Value: val})
}
}
return result
@@ -592,6 +681,60 @@ func inferDescriptionAuth(doc *openapi3.T, name string, fallback spec.AuthConfig
return fallback
}
+// inferAuthHeaderParam scans all operations for required Authorization header
+// parameters. This is the fourth-tier auth fallback — it fires only when
+// securitySchemes, query-param inference, and description inference all fail.
+// APIs like Cal.com declare auth via individual header parameters instead of
+// securitySchemes or description text.
+func inferAuthHeaderParam(doc *openapi3.T, name string, fallback spec.AuthConfig) spec.AuthConfig {
+ if doc == nil || doc.Paths == nil {
+ return fallback
+ }
+
+ authParamCount := 0
+ 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++
+ for _, params := range []openapi3.Parameters{pathItem.Parameters, op.Parameters} {
+ for _, pRef := range params {
+ if pRef == nil || pRef.Value == nil {
+ continue
+ }
+ p := pRef.Value
+ if p.In == openapi3.ParameterInHeader &&
+ strings.EqualFold(p.Name, "Authorization") &&
+ p.Required {
+ authParamCount++
+ break // count once per operation
+ }
+ }
+ }
+ }
+ }
+
+ if totalOps == 0 || float64(authParamCount)/float64(totalOps) <= 0.3 {
+ return fallback
+ }
+
+ envPrefix := strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
+ return spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ In: "header",
+ EnvVars: []string{envPrefix + "_TOKEN"},
+ Inferred: true,
+ }
+}
+
// commonCustomHeaders are header names that APIs use instead of Authorization.
// Checked case-insensitively against the description text.
var commonCustomHeaders = []string{
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 3e5d4796..cc1274c9 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -482,9 +482,39 @@ func TestDetectRequiredHeaders(t *testing.T) {
assert.Empty(t, parsed.RequiredHeaders)
})
+ t.Run("multi-version header tracks per-endpoint overrides", func(t *testing.T) {
+ data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "multi-version-header.yaml"))
+ require.NoError(t, err)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+
+ // Global header should be the majority value (2024-08-13 appears on 3 of 6 ops)
+ require.Len(t, parsed.RequiredHeaders, 1)
+ assert.Equal(t, "cal-api-version", parsed.RequiredHeaders[0].Name)
+ assert.Equal(t, "2024-08-13", parsed.RequiredHeaders[0].Value)
+
+ // Event-types endpoints should have header overrides with 2024-06-14
+ eventTypes := parsed.Resources["event-types"]
+ require.NotNil(t, eventTypes)
+ for eName, ep := range eventTypes.Endpoints {
+ require.NotEmpty(t, ep.HeaderOverrides, "event-types endpoint %q should have header overrides", eName)
+ assert.Equal(t, "cal-api-version", ep.HeaderOverrides[0].Name)
+ assert.Equal(t, "2024-06-14", ep.HeaderOverrides[0].Value)
+ }
+
+ // Bookings endpoints should NOT have overrides (they match the global default)
+ bookings := parsed.Resources["bookings"]
+ require.NotNil(t, bookings)
+ for eName, ep := range bookings.Endpoints {
+ assert.Empty(t, ep.HeaderOverrides, "bookings endpoint %q should not have overrides (matches global)", eName)
+ }
+ })
+
t.Run("authorization header excluded even if required on all ops", func(t *testing.T) {
- headers := detectRequiredHeaders(nil, spec.AuthConfig{})
+ headers, perEndpoint := detectRequiredHeaders(nil, spec.AuthConfig{})
assert.Empty(t, headers)
+ assert.Empty(t, perEndpoint)
})
}
@@ -604,6 +634,80 @@ func TestInferDescriptionAuth(t *testing.T) {
})
}
+func TestInferAuthHeaderParam(t *testing.T) {
+ t.Parallel()
+
+ t.Run("detects auth from required Authorization header params", func(t *testing.T) {
+ data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "auth-header-param.yaml"))
+ require.NoError(t, err)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+
+ assert.Equal(t, "bearer_token", parsed.Auth.Type)
+ assert.Equal(t, "Authorization", parsed.Auth.Header)
+ assert.Equal(t, "header", parsed.Auth.In)
+ assert.True(t, parsed.Auth.Inferred)
+ assert.NotEmpty(t, parsed.Auth.EnvVars, "EnvVars must be populated for verify")
+ })
+
+ t.Run("does not trigger when Authorization params below threshold", func(t *testing.T) {
+ // 1 out of 5 operations = 20% < 30% threshold
+ doc := &openapi3.T{
+ Info: &openapi3.Info{Title: "test", Description: "no auth keywords"},
+ Paths: &openapi3.Paths{},
+ }
+ for i, path := range []string{"/a", "/b", "/c", "/d", "/e"} {
+ pathItem := &openapi3.PathItem{
+ Get: &openapi3.Operation{Responses: openapi3.NewResponses()},
+ }
+ if i == 0 { // only first has Authorization param
+ pathItem.Get.Parameters = openapi3.Parameters{
+ &openapi3.ParameterRef{Value: &openapi3.Parameter{
+ Name: "Authorization", In: "header", Required: true,
+ }},
+ }
+ }
+ doc.Paths.Set(path, pathItem)
+ }
+ result := mapAuth(doc, "test-api")
+ assert.Equal(t, "none", result.Type)
+ })
+
+ t.Run("optional Authorization param not counted", func(t *testing.T) {
+ doc := &openapi3.T{
+ Info: &openapi3.Info{Title: "test", Description: "no auth keywords"},
+ Paths: &openapi3.Paths{},
+ }
+ for _, path := range []string{"/a", "/b", "/c"} {
+ pathItem := &openapi3.PathItem{
+ Get: &openapi3.Operation{
+ Responses: openapi3.NewResponses(),
+ Parameters: openapi3.Parameters{
+ &openapi3.ParameterRef{Value: &openapi3.Parameter{
+ Name: "Authorization", In: "header", Required: false,
+ }},
+ },
+ },
+ }
+ doc.Paths.Set(path, pathItem)
+ }
+ result := mapAuth(doc, "test-api")
+ assert.Equal(t, "none", result.Type)
+ })
+
+ t.Run("explicit securitySchemes still wins over header param", func(t *testing.T) {
+ data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "gmail.yaml"))
+ require.NoError(t, err)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+
+ assert.Equal(t, "bearer_token", parsed.Auth.Type)
+ assert.False(t, parsed.Auth.Inferred, "explicit auth should not be marked as inferred")
+ })
+}
+
func TestAuthTierPrecedence(t *testing.T) {
t.Parallel()
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 5f5390e8..45f93943 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -63,16 +63,17 @@ type Resource struct {
}
type Endpoint struct {
- Method string `yaml:"method" json:"method"`
- Path string `yaml:"path" json:"path"`
- Description string `yaml:"description" json:"description"`
- Params []Param `yaml:"params" json:"params"`
- Body []Param `yaml:"body" json:"body"`
- Response ResponseDef `yaml:"response" json:"response"`
- Pagination *Pagination `yaml:"pagination" json:"pagination"`
- ResponsePath string `yaml:"response_path,omitempty" json:"response_path,omitempty"` // path to extract data array from response (e.g., "data", "results.items")
- Meta map[string]string `yaml:"meta,omitempty" json:"meta,omitempty"` // per-endpoint metadata (e.g., source_tier, source_count from crowd-sniff)
- Alias string `yaml:"-" json:"-"` // computed, not from YAML
+ Method string `yaml:"method" json:"method"`
+ Path string `yaml:"path" json:"path"`
+ Description string `yaml:"description" json:"description"`
+ Params []Param `yaml:"params" json:"params"`
+ Body []Param `yaml:"body" json:"body"`
+ Response ResponseDef `yaml:"response" json:"response"`
+ Pagination *Pagination `yaml:"pagination" json:"pagination"`
+ ResponsePath string `yaml:"response_path,omitempty" json:"response_path,omitempty"` // path to extract data array from response (e.g., "data", "results.items")
+ Meta map[string]string `yaml:"meta,omitempty" json:"meta,omitempty"` // per-endpoint metadata (e.g., source_tier, source_count from crowd-sniff)
+ HeaderOverrides []RequiredHeader `yaml:"header_overrides,omitempty" json:"header_overrides,omitempty"` // per-endpoint header overrides (e.g., different api-version)
+ Alias string `yaml:"-" json:"-"` // computed, not from YAML
}
type Param struct {
diff --git a/testdata/openapi/auth-header-param.yaml b/testdata/openapi/auth-header-param.yaml
new file mode 100644
index 00000000..a9512b54
--- /dev/null
+++ b/testdata/openapi/auth-header-param.yaml
@@ -0,0 +1,59 @@
+openapi: "3.0.0"
+info:
+ title: Auth Header Param API
+ version: "1.0.0"
+ description: "Scheduling infrastructure API"
+servers:
+ - url: https://api.example.com
+paths:
+ /v2/bookings:
+ get:
+ operationId: getBookings
+ summary: List bookings
+ parameters:
+ - name: Authorization
+ in: header
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: OK
+ /v2/bookings/{id}:
+ get:
+ operationId: getBooking
+ summary: Get a booking
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: Authorization
+ in: header
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: OK
+ /v2/event-types:
+ get:
+ operationId: getEventTypes
+ summary: List event types
+ parameters:
+ - name: Authorization
+ in: header
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: OK
+ /v2/health:
+ get:
+ operationId: healthCheck
+ summary: Health check (no auth)
+ responses:
+ "200":
+ description: OK
diff --git a/testdata/openapi/multi-version-header.yaml b/testdata/openapi/multi-version-header.yaml
new file mode 100644
index 00000000..c8a5194f
--- /dev/null
+++ b/testdata/openapi/multi-version-header.yaml
@@ -0,0 +1,113 @@
+openapi: "3.0.0"
+info:
+ title: Multi Version Header API
+ version: "1.0.0"
+ description: "API with per-endpoint version headers"
+servers:
+ - url: https://api.example.com
+security:
+ - BearerAuth: []
+components:
+ securitySchemes:
+ BearerAuth:
+ type: http
+ scheme: bearer
+paths:
+ /v2/bookings:
+ get:
+ operationId: listBookings
+ summary: List bookings
+ parameters:
+ - name: cal-api-version
+ in: header
+ required: true
+ schema:
+ type: string
+ default: "2024-08-13"
+ responses:
+ "200":
+ description: OK
+ /v2/bookings/{id}:
+ get:
+ operationId: getBooking
+ summary: Get a booking
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: cal-api-version
+ in: header
+ required: true
+ schema:
+ type: string
+ default: "2024-08-13"
+ responses:
+ "200":
+ description: OK
+ /v2/event-types:
+ get:
+ operationId: listEventTypes
+ summary: List event types
+ parameters:
+ - name: cal-api-version
+ in: header
+ required: true
+ schema:
+ type: string
+ default: "2024-06-14"
+ responses:
+ "200":
+ description: OK
+ /v2/event-types/{id}:
+ get:
+ operationId: getEventType
+ summary: Get an event type
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: cal-api-version
+ in: header
+ required: true
+ schema:
+ type: string
+ default: "2024-06-14"
+ responses:
+ "200":
+ description: OK
+ patch:
+ operationId: updateEventType
+ summary: Update event type
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: cal-api-version
+ in: header
+ required: true
+ schema:
+ type: string
+ default: "2024-06-14"
+ responses:
+ "200":
+ description: OK
+ /v2/schedules:
+ get:
+ operationId: listSchedules
+ summary: List schedules
+ parameters:
+ - name: cal-api-version
+ in: header
+ required: true
+ schema:
+ type: string
+ default: "2024-08-13"
+ responses:
+ "200":
+ description: OK
← b5fddac0 fix(cli): ensure Sync is always enabled when Store is true
·
back to Cli Printing Press
·
fix(skills): add Phase 3 Completion Gate to prevent skipping 3ea8601d →