← back to Cli Printing Press
feat(cli): add --auth-preference flag for catalog-driven scheme selection (#865)
34ca81497f6e46ee4fe5f92a30f6f3f15d498e27 · 2026-05-16 13:49:10 -0400 · Michael Schreiber
* feat(cli): add --auth-preference flag for catalog-driven scheme selection
When an OpenAPI spec advertises multiple security schemes — most commonly
OAuth2 (with full authorizationCode flow) plus HTTP Basic — the parser
defaulted to OAuth2. That selection is correct for hosted multi-tenant
integrations but unusable for personal-token CLIs (Atlassian Jira /
Confluence / Bitbucket, GitLab, and others all expose this exact pair).
Add a generation-time hint that lets a catalog entry — or a one-off
generate caller — pin a specific securityScheme name from the spec:
printing-press generate --spec jira.json --auth-preference basicAuth
The hint is a soft preference: an unknown name silently falls back to
the existing default selector so a typo in catalog YAML degrades
gracefully rather than failing the whole generate. Matching is
case-insensitive against the spec's scheme name.
Plumbing:
* internal/openapi/parser.go: introduce ParseOptions, route the six
Parse*/ParseFile* helpers through a single ParseWithOptions, thread
AuthPreference into selectSecurityScheme.
* internal/cli/root.go: --auth-preference flag on `generate`.
* internal/catalog/catalog.go: optional auth_preference field on Entry.
* .github/workflows/validate-catalog.yml: forward auth_preference to
generate so the CI matches what users get from the catalog.
* catalog/jira.yaml: new official entry, pinned to basicAuth.
Tests cover: preference selects the named scheme, case-insensitive
matching preserves the spec's casing in Auth.Scheme, and an unknown
preference falls through to the default OAuth2-wins behavior.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(ci): use yq to read auth_preference in validate-catalog
grep+sed silently mangled quoted values, trailing comments, or
indented keys. Use `yq e '.auth_preference // ""'` so the workflow
forwards the same value the generator would parse.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): forward catalog auth_preference into OpenAPI parse for generate
* test(cli): migrate auth-preference test fixtures from bare example.com to api.example.com
The spec validator added in main (fix #984) rejects bare example.com as a
reserved RFC 2606 placeholder host; subdomains like api.example.com remain
allowed. Update the two auth-preference parser tests so they pass the merged
validator.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): flatten nested-prefix body fields that collide with sibling identifiers
Two body properties whose camelCased prefix-paths converge on the same
Go identifier — e.g. Atlassian ProjectComponent's top-level
`leadAccountId` alongside the sibling `lead` object's nested
`accountId` — emit two `var bodyLeadAccountId string` declarations
after #957's nested-object expansion, and the generated CLI fails to
compile with "redeclared in this block".
The existing parser-side seenCamelNames pass only dedupes among
top-level body params; it doesn't predict the identifiers that the
generator's nested-prefix recursion will produce.
Add flattenCollidingBodyFields, a generator-side pre-pass that walks
endpoint.Body using the same identifier rule as renderBodyMap
(`toCamel(paramIdent(p))` joined to the parent prefix). Where a leaf's
predicted identifier collides with another leaf elsewhere in the tree,
clear the offending parent's Fields so it falls through to the
JSON-string fallback. The user can still reach the nested data via
`--lead '{"accountId":"..."}'`.
Wired into all four body-emission template helpers (bodyMap,
bodyVarDecls, bodyFlagRegs, bodyRequiredChecks) so detection and
emission cannot drift. No effect on the common case: 17/17 golden
fixtures byte-identical.
Discovered while greening up the validate-catalog CI job for the Jira
catalog entry added in this PR — the Atlassian Jira spec was the first
real shape to trip the collision.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): preserve Bearer default over OAuth2 authorization-code in scheme selection
The OAuth2+AC early-return loop in selectSecurityScheme bypassed the
scoring system for any spec advertising both http/bearer and OAuth2
authorizationCode. Since schemePriorityBearer = 0 < schemePriorityOAuth2AuthCode
= 200, the scoring system intentionally picks Bearer as the simpler
scheme for CLI use; the early-return inverted that for every Bearer +
OAuth2+AC spec.
The loop also added nothing for the Jira goal: AuthPreference already
pins basicAuth when the catalog asks, and scoring already prefers
OAuth2+AC (200) over basicAuth (500) by default.
Drop the loop. Add a parser test that locks both halves of the contract:
default selection keeps Bearer; AuthPreference: "OAuth2" still lets
callers opt into the 3LO dance when they want it.
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Co-authored-by: Trevin Chow <trevin@trevinchow.com>
Files touched
M .github/workflows/validate-catalog.ymlA catalog/jira.yamlM internal/catalog/catalog.goM internal/catalog/catalog_test.goM internal/cli/generate_test.goM internal/cli/public_param_audit.goM internal/cli/root.goA internal/generator/body_collision.goM internal/generator/body_collision_test.goM internal/generator/generator.goM internal/openapi/parser.goM internal/openapi/parser_test.goM testdata/golden/expected/catalog-list/stdout.txt
Diff
commit 34ca81497f6e46ee4fe5f92a30f6f3f15d498e27
Author: Michael Schreiber <michael.a.schreiber@gmail.com>
Date: Sat May 16 13:49:10 2026 -0400
feat(cli): add --auth-preference flag for catalog-driven scheme selection (#865)
* feat(cli): add --auth-preference flag for catalog-driven scheme selection
When an OpenAPI spec advertises multiple security schemes — most commonly
OAuth2 (with full authorizationCode flow) plus HTTP Basic — the parser
defaulted to OAuth2. That selection is correct for hosted multi-tenant
integrations but unusable for personal-token CLIs (Atlassian Jira /
Confluence / Bitbucket, GitLab, and others all expose this exact pair).
Add a generation-time hint that lets a catalog entry — or a one-off
generate caller — pin a specific securityScheme name from the spec:
printing-press generate --spec jira.json --auth-preference basicAuth
The hint is a soft preference: an unknown name silently falls back to
the existing default selector so a typo in catalog YAML degrades
gracefully rather than failing the whole generate. Matching is
case-insensitive against the spec's scheme name.
Plumbing:
* internal/openapi/parser.go: introduce ParseOptions, route the six
Parse*/ParseFile* helpers through a single ParseWithOptions, thread
AuthPreference into selectSecurityScheme.
* internal/cli/root.go: --auth-preference flag on `generate`.
* internal/catalog/catalog.go: optional auth_preference field on Entry.
* .github/workflows/validate-catalog.yml: forward auth_preference to
generate so the CI matches what users get from the catalog.
* catalog/jira.yaml: new official entry, pinned to basicAuth.
Tests cover: preference selects the named scheme, case-insensitive
matching preserves the spec's casing in Auth.Scheme, and an unknown
preference falls through to the default OAuth2-wins behavior.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(ci): use yq to read auth_preference in validate-catalog
grep+sed silently mangled quoted values, trailing comments, or
indented keys. Use `yq e '.auth_preference // ""'` so the workflow
forwards the same value the generator would parse.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): forward catalog auth_preference into OpenAPI parse for generate
* test(cli): migrate auth-preference test fixtures from bare example.com to api.example.com
The spec validator added in main (fix #984) rejects bare example.com as a
reserved RFC 2606 placeholder host; subdomains like api.example.com remain
allowed. Update the two auth-preference parser tests so they pass the merged
validator.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): flatten nested-prefix body fields that collide with sibling identifiers
Two body properties whose camelCased prefix-paths converge on the same
Go identifier — e.g. Atlassian ProjectComponent's top-level
`leadAccountId` alongside the sibling `lead` object's nested
`accountId` — emit two `var bodyLeadAccountId string` declarations
after #957's nested-object expansion, and the generated CLI fails to
compile with "redeclared in this block".
The existing parser-side seenCamelNames pass only dedupes among
top-level body params; it doesn't predict the identifiers that the
generator's nested-prefix recursion will produce.
Add flattenCollidingBodyFields, a generator-side pre-pass that walks
endpoint.Body using the same identifier rule as renderBodyMap
(`toCamel(paramIdent(p))` joined to the parent prefix). Where a leaf's
predicted identifier collides with another leaf elsewhere in the tree,
clear the offending parent's Fields so it falls through to the
JSON-string fallback. The user can still reach the nested data via
`--lead '{"accountId":"..."}'`.
Wired into all four body-emission template helpers (bodyMap,
bodyVarDecls, bodyFlagRegs, bodyRequiredChecks) so detection and
emission cannot drift. No effect on the common case: 17/17 golden
fixtures byte-identical.
Discovered while greening up the validate-catalog CI job for the Jira
catalog entry added in this PR — the Atlassian Jira spec was the first
real shape to trip the collision.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli): preserve Bearer default over OAuth2 authorization-code in scheme selection
The OAuth2+AC early-return loop in selectSecurityScheme bypassed the
scoring system for any spec advertising both http/bearer and OAuth2
authorizationCode. Since schemePriorityBearer = 0 < schemePriorityOAuth2AuthCode
= 200, the scoring system intentionally picks Bearer as the simpler
scheme for CLI use; the early-return inverted that for every Bearer +
OAuth2+AC spec.
The loop also added nothing for the Jira goal: AuthPreference already
pins basicAuth when the catalog asks, and scoring already prefers
OAuth2+AC (200) over basicAuth (500) by default.
Drop the loop. Add a parser test that locks both halves of the contract:
default selection keeps Bearer; AuthPreference: "OAuth2" still lets
callers opt into the 3LO dance when they want it.
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Co-authored-by: Trevin Chow <trevin@trevinchow.com>
---
.github/workflows/validate-catalog.yml | 13 +-
catalog/jira.yaml | 21 +++
internal/catalog/catalog.go | 7 +
internal/catalog/catalog_test.go | 22 +++
internal/cli/generate_test.go | 68 +++++++++
internal/cli/public_param_audit.go | 2 +-
internal/cli/root.go | 39 +++--
internal/generator/body_collision.go | 88 +++++++++++
internal/generator/body_collision_test.go | 116 +++++++++++++++
internal/generator/generator.go | 8 +-
internal/openapi/parser.go | 85 +++++++----
internal/openapi/parser_test.go | 178 +++++++++++++++++++++++
testdata/golden/expected/catalog-list/stdout.txt | 1 +
13 files changed, 604 insertions(+), 44 deletions(-)
diff --git a/.github/workflows/validate-catalog.yml b/.github/workflows/validate-catalog.yml
index 8373f39d..ce4007c4 100644
--- a/.github/workflows/validate-catalog.yml
+++ b/.github/workflows/validate-catalog.yml
@@ -98,12 +98,23 @@ jobs:
NAME=$(grep '^name:' "$file" | head -1 | sed 's/name: *//')
OUT_DIR="${RUNNER_TEMP:-/tmp}/${NAME}-cli"
+ # Forward auth_preference when the entry sets one so the generated
+ # CLI matches what `printing-press generate <name>` would produce.
+ # Use yq so quoted values, trailing comments, and indentation are
+ # handled correctly (grep+sed silently mangled those).
+ AUTH_PREFERENCE_ARG=()
+ AUTH_PREF=$(yq e '.auth_preference // ""' "$file")
+ if [ -n "$AUTH_PREF" ]; then
+ AUTH_PREFERENCE_ARG=(--auth-preference "$AUTH_PREF")
+ fi
+
./printing-press generate \
--spec "$SPEC_FILE" \
--spec-url "$SPEC_URL" \
--name "$NAME" \
--output "$OUT_DIR" \
- --validate
+ --validate \
+ "${AUTH_PREFERENCE_ARG[@]}"
echo "PASS: Generated CLI compiles"
rm -rf "$OUT_DIR" "$SPEC_FILE"
diff --git a/catalog/jira.yaml b/catalog/jira.yaml
new file mode 100644
index 00000000..3203ba96
--- /dev/null
+++ b/catalog/jira.yaml
@@ -0,0 +1,21 @@
+name: jira
+display_name: Jira Cloud
+description: Issue tracking and project management API for Jira Cloud
+category: project-management
+spec_url: https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json
+spec_format: json
+openapi_version: "3.0"
+tier: official
+verified_date: "2026-05-09"
+auth_preference: basicAuth
+homepage: https://developer.atlassian.com/cloud/jira/platform/rest/v3/
+notes: |
+ HTTP Basic auth: email + API token (https://id.atlassian.com/manage-profile/security/api-tokens).
+ Site URL is per-tenant (https://<your-site>.atlassian.net); supply via JIRA_BASE_URL env var.
+ Spec is large (~420 paths); generator truncation applies.
+
+ Common JQL patterns (use with the issue-search command or --jql flag):
+ Issues assigned to you: assignee = currentUser()
+ Open issues in a project: project = MYPROJ AND statusCategory != Done
+ Recently updated: updated >= -7d ORDER BY updated DESC
+ Run `<slug>-pp-cli --help` or the `context` MCP tool for exact command names after generation.
diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go
index f7cfc8b5..f2e4d82d 100644
--- a/internal/catalog/catalog.go
+++ b/internal/catalog/catalog.go
@@ -178,6 +178,13 @@ type Entry struct {
// can use as implementation backing when no official spec exists. When this
// list is non-empty, spec_url and spec_format are optional.
WrapperLibraries []WrapperLibrary `yaml:"wrapper_libraries,omitempty"`
+ // AuthPreference is the securityScheme name from the upstream spec the
+ // generator should pick when multiple schemes are advertised. Without it
+ // the parser's default selection wins (which favors OAuth2 with a full
+ // authorization-code flow). Set this to a simpler scheme (e.g. "basicAuth")
+ // when the API supports both OAuth2 and HTTP Basic and the printed CLI is
+ // meant for personal API-token use rather than a 3LO web flow.
+ AuthPreference string `yaml:"auth_preference,omitempty"`
}
// IsWrapperOnly reports whether this entry represents an API reached through
diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go
index 227c2bfc..5ff4c121 100644
--- a/internal/catalog/catalog_test.go
+++ b/internal/catalog/catalog_test.go
@@ -53,6 +53,28 @@ notes: Example fixture.
assert.Equal(t, "Example fixture.", entry.Notes)
}
+func TestParseEntryAuthPreference(t *testing.T) {
+ // auth_preference is optional; when set it carries the upstream
+ // securityScheme name verbatim through the parser → generator pipeline.
+ data := []byte(`
+name: jira-like
+display_name: Jira-like
+description: Multi-scheme spec
+category: project-management
+spec_url: https://example.com/openapi.yaml
+spec_format: json
+openapi_version: "3.0"
+tier: official
+verified_date: "2026-05-09"
+homepage: https://example.com
+auth_preference: basicAuth
+`)
+
+ entry, err := ParseEntry(data)
+ require.NoError(t, err)
+ assert.Equal(t, "basicAuth", entry.AuthPreference)
+}
+
func TestValidateEntry(t *testing.T) {
base := Entry{
Name: "test-api",
diff --git a/internal/cli/generate_test.go b/internal/cli/generate_test.go
index 2bc057a4..680e9bea 100644
--- a/internal/cli/generate_test.go
+++ b/internal/cli/generate_test.go
@@ -10,6 +10,7 @@ import (
"path/filepath"
"testing"
+ catalogfs "github.com/mvanhorn/cli-printing-press/v4/catalog"
"github.com/mvanhorn/cli-printing-press/v4/internal/catalog"
"github.com/mvanhorn/cli-printing-press/v4/internal/catalogmeta"
"github.com/mvanhorn/cli-printing-press/v4/internal/pipeline"
@@ -1815,6 +1816,73 @@ resources:
"the spec-derived directory must not be created when --output is explicit")
}
+func TestOpenAPIAuthPreferenceForGenerateFromJiraCatalogEntry(t *testing.T) {
+ t.Parallel()
+
+ jira, err := catalog.LookupFS(catalogfs.FS, "jira")
+ require.NoError(t, err)
+ require.Equal(t, "basicAuth", jira.AuthPreference)
+
+ assert.Equal(t, "basicAuth", openAPIAuthPreferenceForGenerate("", "", []string{jira.SpecURL}, ""),
+ "catalog spec_url match should forward auth_preference without --auth-preference")
+ assert.Equal(t, "OAuth2", openAPIAuthPreferenceForGenerate("OAuth2", "", []string{jira.SpecURL}, ""),
+ "explicit --auth-preference must override catalog")
+
+ localSpec := filepath.Join(t.TempDir(), "swagger.json")
+ assert.Equal(t, "basicAuth", openAPIAuthPreferenceForGenerate("", "jira", []string{localSpec}, ""),
+ "catalog slug via --name should resolve auth_preference without https spec refs")
+}
+
+func TestOpenAPIAuthPreferenceForGenerateParsesJiraLikeSpecWithCatalogDefault(t *testing.T) {
+ t.Parallel()
+
+ // Mirrors real Jira-style specs: OAuth2 authorizationCode + HTTP Basic; default
+ // parser choice is OAuth2 unless AuthPreference pins basicAuth (see openapi parser tests).
+ specBytes := []byte(`openapi: "3.0.3"
+info:
+ title: Atlassian-like
+ version: "1.0"
+servers:
+ - url: https://example.atlassian.net
+components:
+ securitySchemes:
+ OAuth2:
+ type: oauth2
+ flows:
+ authorizationCode:
+ authorizationUrl: https://auth.example.com/authorize
+ tokenUrl: https://auth.example.com/token
+ scopes:
+ read: read access
+ basicAuth:
+ type: http
+ scheme: basic
+paths:
+ /v1/things:
+ get:
+ operationId: list things
+ security:
+ - basicAuth: []
+ - OAuth2: [read]
+ responses: {"200": {description: ok}}
+`)
+
+ jira, err := catalog.LookupFS(catalogfs.FS, "jira")
+ require.NoError(t, err)
+
+ pref := openAPIAuthPreferenceForGenerate("", "", []string{jira.SpecURL}, "")
+ require.Equal(t, "basicAuth", pref)
+
+ parsed, err := parseOpenAPISpec(filepath.Join(t.TempDir(), "spec.yaml"), specBytes, false, pref)
+ require.NoError(t, err)
+ assert.Equal(t, "basicAuth", parsed.Auth.Scheme)
+ assert.Equal(t, "api_key", parsed.Auth.Type)
+
+ defaultParsed, err := parseOpenAPISpec(filepath.Join(t.TempDir(), "spec2.yaml"), specBytes, false, "")
+ require.NoError(t, err)
+ assert.Equal(t, "OAuth2", defaultParsed.Auth.Scheme, "without catalog-driven preference, OAuth2 wins")
+}
+
func TestEnrichSpecFromCatalogCopiesGenerationMetadata(t *testing.T) {
apiSpec := &spec.APISpec{Name: "test-api", BaseURL: spec.PlaceholderBaseURL, BaseURLIsPlaceholder: true}
diff --git a/internal/cli/public_param_audit.go b/internal/cli/public_param_audit.go
index 23db035f..8742020b 100644
--- a/internal/cli/public_param_audit.go
+++ b/internal/cli/public_param_audit.go
@@ -93,7 +93,7 @@ func parsePublicParamAuditSpec(specFiles []string, cliName string, lenient bool)
var apiSpec *spec.APISpec
if openapi.IsOpenAPI(data) {
- apiSpec, err = parseOpenAPISpec(specFile, data, lenient)
+ apiSpec, err = parseOpenAPISpec(specFile, data, lenient, "")
} else if graphql.IsGraphQLSDL(data) {
apiSpec, err = graphql.ParseSDLBytes(specFile, data)
} else {
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 257c0e94..00c93ce4 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -100,6 +100,7 @@ func newGenerateCmd() *cobra.Command {
var specURL string
var planFile string
var trafficAnalysisPath string
+ var authPreference string
cmd := &cobra.Command{
Use: "generate",
@@ -274,6 +275,8 @@ func newGenerateCmd() *cobra.Command {
openapi.SetMaxEndpointsPerResource(maxEndpointsPerResource)
}
+ openAPIParseAuthPref := openAPIAuthPreferenceForGenerate(authPreference, cliName, specFiles, specURL)
+
var specs []*spec.APISpec
var specRawBytes [][]byte // raw spec data for archiving
for _, specFile := range specFiles {
@@ -285,7 +288,7 @@ func newGenerateCmd() *cobra.Command {
var apiSpec *spec.APISpec
if openapi.IsOpenAPI(data) {
- apiSpec, err = parseOpenAPISpec(specFile, data, lenient)
+ apiSpec, err = parseOpenAPISpec(specFile, data, lenient, openAPIParseAuthPref)
} else if graphql.IsGraphQLSDL(data) {
apiSpec, err = graphql.ParseSDLBytes(specFile, data)
} else {
@@ -441,6 +444,7 @@ func newGenerateCmd() *cobra.Command {
cmd.Flags().StringVar(&specURL, "spec-url", "", "Original spec URL for provenance (use when --spec is a local file downloaded from a URL)")
cmd.Flags().StringVar(&planFile, "plan", "", "Path to a markdown plan document for plan-driven generation (instead of --spec)")
cmd.Flags().StringVar(&trafficAnalysisPath, "traffic-analysis", "", "Path to browser-sniff traffic-analysis.json for advisory generation context")
+ cmd.Flags().StringVar(&authPreference, "auth-preference", "", "Preferred securityScheme name from the spec (overrides default selection and any catalog auth_preference; useful when a spec advertises multiple schemes such as OAuth2 + HTTP Basic and you want the simpler one). When omitted, a matching embedded catalog entry's auth_preference applies for OpenAPI parsing.")
return cmd
}
@@ -703,17 +707,26 @@ func readSpec(specFile string, refresh bool, skipCache bool) ([]byte, error) {
return data, nil
}
-func parseOpenAPISpec(specFile string, data []byte, lenient bool) (*spec.APISpec, error) {
- if openapi.IsRemoteSpecSource(specFile) {
- if lenient {
- return openapi.ParseLenient(data)
- }
- return openapi.Parse(data)
+func parseOpenAPISpec(specFile string, data []byte, lenient bool, authPreference string) (*spec.APISpec, error) {
+ opts := openapi.ParseOptions{Lenient: lenient, AuthPreference: authPreference}
+ if !openapi.IsRemoteSpecSource(specFile) {
+ opts.Path = specFile
+ }
+ return openapi.ParseWithOptions(data, opts)
+}
+
+// openAPIAuthPreferenceForGenerate resolves AuthPreference for openapi.ParseWithOptions.
+// Explicit --auth-preference wins; otherwise a matching catalog entry's auth_preference
+// is used so catalog-driven generates pick the intended scheme before spec enrichment.
+func openAPIAuthPreferenceForGenerate(cliAuthPref, cliName string, specFiles []string, specURL string) string {
+ if s := strings.TrimSpace(cliAuthPref); s != "" {
+ return s
}
- if lenient {
- return openapi.ParseWithPathLenient(data, specFile)
+ entry := lookupCatalogEntryForGenerateSpec(strings.TrimSpace(cliName), catalogSpecLookupRefs(specFiles, specURL))
+ if entry == nil {
+ return ""
}
- return openapi.ParseWithPath(data, specFile)
+ return strings.TrimSpace(entry.AuthPreference)
}
// archiveSpecBytes picks the bytes and filename for the spec snapshot that
@@ -1532,8 +1545,10 @@ func catalogSpecLookupRefs(specFiles []string, specURL string) []string {
}
func lookupCatalogEntryForGenerateSpec(apiName string, specRefs []string) *catalog.Entry {
- if entry, err := catalog.LookupFS(catalogfs.FS, apiName); err == nil {
- return entry
+ if name := strings.TrimSpace(apiName); name != "" {
+ if entry, err := catalog.LookupFS(catalogfs.FS, name); err == nil {
+ return entry
+ }
}
specURLs := make(map[string]struct{}, len(specRefs))
for _, ref := range specRefs {
diff --git a/internal/generator/body_collision.go b/internal/generator/body_collision.go
new file mode 100644
index 00000000..891bf9e4
--- /dev/null
+++ b/internal/generator/body_collision.go
@@ -0,0 +1,88 @@
+package generator
+
+import (
+ "github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+)
+
+// flattenCollidingBodyFields returns body with Fields cleared on any
+// object param whose nested expansion would produce a Go identifier
+// already claimed by another leaf in the same body tree. The colliding
+// parent then falls through to the JSON-string fallback in
+// renderBodyMap, so the user can still reach the field via the parent
+// flag as a JSON blob.
+//
+// Without this pass, two body properties whose camelCased prefix-paths
+// converge on the same identifier — e.g. top-level `leadAccountId`
+// alongside nested `lead.accountId` (Atlassian's ProjectComponent
+// exposes exactly this pair) — emit two `var bodyLeadAccountId ...`
+// declarations and the generated CLI fails to compile with
+// "redeclared in this block".
+//
+// The check uses the same identifier-prediction rule as renderBodyMap
+// and renderBodyVarDecls (`toCamel(paramIdent(p))` joined to the
+// parent prefix) so detection and emission cannot drift.
+func flattenCollidingBodyFields(body []spec.Param) []spec.Param {
+ counts := countBodyLeaves(body, "")
+ collision := false
+ for _, n := range counts {
+ if n > 1 {
+ collision = true
+ break
+ }
+ }
+ if !collision {
+ return body
+ }
+ return clearCollidingParents(body, "", counts)
+}
+
+func countBodyLeaves(params []spec.Param, prefix string) map[string]int {
+ counts := map[string]int{}
+ var walk func([]spec.Param, string)
+ walk = func(ps []spec.Param, pfx string) {
+ for _, p := range ps {
+ ident := pfx + toCamel(paramIdent(p))
+ if p.Type == "object" && len(p.Fields) > 0 {
+ walk(p.Fields, ident)
+ continue
+ }
+ counts[ident]++
+ }
+ }
+ walk(params, prefix)
+ return counts
+}
+
+func clearCollidingParents(params []spec.Param, prefix string, counts map[string]int) []spec.Param {
+ out := make([]spec.Param, len(params))
+ copy(out, params)
+ for i := range out {
+ p := &out[i]
+ if p.Type != "object" || len(p.Fields) == 0 {
+ continue
+ }
+ ident := prefix + toCamel(paramIdent(*p))
+ if subtreeHasCollidingLeaf(p.Fields, ident, counts) {
+ p.Fields = nil
+ continue
+ }
+ p.Fields = clearCollidingParents(p.Fields, ident, counts)
+ }
+ return out
+}
+
+func subtreeHasCollidingLeaf(params []spec.Param, prefix string, counts map[string]int) bool {
+ for _, p := range params {
+ ident := prefix + toCamel(paramIdent(p))
+ if p.Type == "object" && len(p.Fields) > 0 {
+ if subtreeHasCollidingLeaf(p.Fields, ident, counts) {
+ return true
+ }
+ continue
+ }
+ if counts[ident] > 1 {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/generator/body_collision_test.go b/internal/generator/body_collision_test.go
index 59ff0eed..404bf80d 100644
--- a/internal/generator/body_collision_test.go
+++ b/internal/generator/body_collision_test.go
@@ -433,6 +433,122 @@ components:
"the postalAddress.customer cycle-cut leaf must emit a unique identifier under its full path")
}
+// TestFlattenCollidingBodyFields_NestedPrefixShape covers the Atlassian
+// ProjectComponent shape: a top-level scalar `leadAccountId` plus a
+// sibling `lead` object whose nested `accountId` would expand to the
+// same Go identifier `bodyLeadAccountId`. The parser-side seenCamelNames
+// dedup only checks top-level names, so the collision surfaces in the
+// generator. flattenCollidingBodyFields must clear the offending
+// parent's Fields so it falls through to the JSON-blob branch.
+func TestFlattenCollidingBodyFields_NestedPrefixShape(t *testing.T) {
+ t.Parallel()
+
+ body := []spec.Param{
+ {Name: "leadAccountId", Type: "string"},
+ {
+ Name: "lead",
+ Type: "object",
+ Fields: []spec.Param{
+ {Name: "accountId", Type: "string"},
+ {Name: "displayName", Type: "string"},
+ },
+ },
+ }
+
+ got := flattenCollidingBodyFields(body)
+
+ require.Len(t, got, 2)
+ assert.Equal(t, "leadAccountId", got[0].Name)
+ assert.Empty(t, got[0].Fields, "top-level scalar is untouched")
+ assert.Equal(t, "lead", got[1].Name)
+ assert.Empty(t, got[1].Fields,
+ "colliding parent must have Fields cleared so it falls through to JSON-blob")
+}
+
+// TestFlattenCollidingBodyFields_NoCollisionPassesThrough guards the
+// common case: when nested expansion is collision-free the helper must
+// not strip Fields. Two unrelated objects with non-colliding leaf names
+// (the canonical start/end DateTimeTimeZone example from #957) must
+// round-trip with Fields intact.
+func TestFlattenCollidingBodyFields_NoCollisionPassesThrough(t *testing.T) {
+ t.Parallel()
+
+ body := []spec.Param{
+ {
+ Name: "start",
+ Type: "object",
+ Fields: []spec.Param{
+ {Name: "dateTime", Type: "string"},
+ {Name: "timeZone", Type: "string"},
+ },
+ },
+ {
+ Name: "end",
+ Type: "object",
+ Fields: []spec.Param{
+ {Name: "dateTime", Type: "string"},
+ {Name: "timeZone", Type: "string"},
+ },
+ },
+ }
+
+ got := flattenCollidingBodyFields(body)
+
+ require.Len(t, got, 2)
+ for _, p := range got {
+ assert.Len(t, p.Fields, 2, "nested object %q keeps its 2 Fields", p.Name)
+ }
+}
+
+// TestGenerateProjectComponentShapeCompiles is the end-to-end regression
+// for the Atlassian Jira validate-catalog failure: a POST endpoint whose
+// body contains both `leadAccountId` (scalar) and `lead` (object with
+// nested `accountId`) must produce a generated CLI that compiles. Before
+// the flattenCollidingBodyFields pass, this shape emitted two
+// `var bodyLeadAccountId string` declarations and failed govulncheck's
+// load step with "redeclared in this block".
+func TestGenerateProjectComponentShapeCompiles(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("collide-nested")
+ apiSpec.Resources["components"] = spec.Resource{
+ Description: "Components",
+ Endpoints: map[string]spec.Endpoint{
+ "create": {
+ Method: "POST",
+ Path: "/components",
+ Description: "Create a component (Jira ProjectComponent shape)",
+ Body: []spec.Param{
+ {Name: "leadAccountId", Type: "string", Description: "Lead user account ID (top-level)"},
+ {
+ Name: "lead",
+ Type: "object",
+ Description: "Lead user details (nested object)",
+ Fields: []spec.Param{
+ {Name: "accountId", Type: "string", Description: "Account ID inside the lead object"},
+ {Name: "displayName", Type: "string", Description: "Display name"},
+ },
+ },
+ },
+ },
+ "get": {
+ Method: "GET",
+ Path: "/components/{id}",
+ Description: "Get one component",
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "collide-nested-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ bodyVars, _ := parseBodyDeclarations(t,
+ filepath.Join(outputDir, "internal", "cli", "components_create.go"))
+
+ assertNoDuplicates(t, bodyVars,
+ "nested-prefix collision must not produce duplicate `var body<X>` declarations")
+}
+
// parseBodyDeclarations returns the names of all `var bodyXxx` declarations
// and the literal cobra flag names registered. Cobra registrations may come
// from either flag<X> or body<X> Go identifiers, so the flag-binding return
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index e86c6f65..7db4349d 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -3398,7 +3398,7 @@ func endpointNeedsClientLimit(endpoint spec.Endpoint) bool {
// map[string]any rather than a single JSON-string flag.
func bodyMap(body []spec.Param, indent string) string {
var b strings.Builder
- renderBodyMap(&b, body, indent, "body", "", "")
+ renderBodyMap(&b, flattenCollidingBodyFields(body), indent, "body", "", "")
return b.String()
}
@@ -3518,7 +3518,7 @@ func bodyVarDecls(endpoint spec.Endpoint) string {
}
return b.String()
}
- renderBodyVarDecls(&b, endpoint.Body, "")
+ renderBodyVarDecls(&b, flattenCollidingBodyFields(endpoint.Body), "")
return b.String()
}
@@ -3560,7 +3560,7 @@ func bodyFlagRegs(endpoint spec.Endpoint) string {
}
return b.String()
}
- renderBodyFlagRegs(&b, endpoint.Body, "", "", true)
+ renderBodyFlagRegs(&b, flattenCollidingBodyFields(endpoint.Body), "", "", true)
return b.String()
}
@@ -3622,7 +3622,7 @@ func bodyRequiredChecks(endpoint spec.Endpoint, indent string) string {
}
return b.String()
}
- renderBodyRequiredChecks(&b, endpoint.Body, indent, "", true)
+ renderBodyRequiredChecks(&b, flattenCollidingBodyFields(endpoint.Body), indent, "", true)
return b.String()
}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index cbe55d86..5bacd948 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -163,62 +163,83 @@ func stripBrokenRefs(data []byte, errMsg string) []byte {
// Parse parses an OpenAPI spec strictly. Use ParseLenient for specs with broken $refs.
func Parse(data []byte) (*spec.APISpec, error) {
- return parse(data, false)
+ return ParseWithOptions(data, ParseOptions{})
}
// ParseFile parses an OpenAPI spec from a file and resolves local external
// refs relative to that file.
func ParseFile(path string) (*spec.APISpec, error) {
- return parseFile(path, false)
+ return parseFileWithOptions(path, ParseOptions{})
}
// ParseWithPath parses OpenAPI spec bytes and resolves local external refs
// relative to the given file path.
func ParseWithPath(data []byte, path string) (*spec.APISpec, error) {
- return parseWithPath(data, path, false)
+ return ParseWithOptions(data, ParseOptions{Path: path})
}
// ParseLenient parses an OpenAPI spec, skipping validation errors from broken $refs.
// It logs warnings to stderr for any issues found but continues parsing.
func ParseLenient(data []byte) (*spec.APISpec, error) {
- return parse(data, true)
+ return ParseWithOptions(data, ParseOptions{Lenient: true})
}
// ParseFileLenient parses an OpenAPI spec from a file and skips validation
// errors from broken $refs after resolving local external refs relative to
// that file.
func ParseFileLenient(path string) (*spec.APISpec, error) {
- return parseFile(path, true)
+ return parseFileWithOptions(path, ParseOptions{Lenient: true})
}
// ParseWithPathLenient parses OpenAPI spec bytes, resolving local external refs
// relative to the given file path and skipping validation errors from broken
// refs.
func ParseWithPathLenient(data []byte, path string) (*spec.APISpec, error) {
- return parseWithPath(data, path, true)
-}
-
-func parseFile(path string, lenient bool) (*spec.APISpec, error) {
- data, err := os.ReadFile(path)
+ return ParseWithOptions(data, ParseOptions{Path: path, Lenient: true})
+}
+
+// ParseOptions carries optional hints that influence parsing without changing
+// the existing single-purpose Parse* signatures. New behavioral knobs (e.g.
+// auth-scheme preference) should be added here rather than as new positional
+// parameters across every entry point.
+type ParseOptions struct {
+ // Path resolves local external refs relative to a file location. Empty
+ // means the spec is treated as remote/in-memory only.
+ Path string
+ // Lenient skips validation errors from broken $refs.
+ Lenient bool
+ // AuthPreference names a security scheme from components.securitySchemes
+ // that should win over the parser's default selection priority. Used when
+ // a spec exposes multiple valid schemes (e.g. OAuth2 + basic) and the
+ // caller knows which one fits the printed CLI's intended auth model.
+ // Unknown names are ignored (default selection runs).
+ AuthPreference string
+}
+
+// ParseWithOptions is the canonical parser entry point; the older Parse* and
+// ParseFile* helpers delegate to it with default ParseOptions plus their own
+// path/lenient settings.
+func ParseWithOptions(data []byte, opts ParseOptions) (*spec.APISpec, error) {
+ if opts.Path == "" {
+ return parseWithLocation(data, opts.Lenient, nil, opts.AuthPreference)
+ }
+ location, err := fileLocation(opts.Path)
if err != nil {
- return nil, fmt.Errorf("reading OpenAPI spec: %w", err)
+ return nil, err
}
- return parseWithPath(data, path, lenient)
+ return parseWithLocation(data, opts.Lenient, location, opts.AuthPreference)
}
-func parseWithPath(data []byte, path string, lenient bool) (*spec.APISpec, error) {
- location, err := fileLocation(path)
+func parseFileWithOptions(path string, opts ParseOptions) (*spec.APISpec, error) {
+ data, err := os.ReadFile(path)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("reading OpenAPI spec: %w", err)
}
- return parseWithLocation(data, lenient, location)
+ opts.Path = path
+ return ParseWithOptions(data, opts)
}
-func parse(data []byte, lenient bool) (*spec.APISpec, error) {
- return parseWithLocation(data, lenient, nil)
-}
-
-func parseWithLocation(data []byte, lenient bool, location *url.URL) (*spec.APISpec, error) {
+func parseWithLocation(data []byte, lenient bool, location *url.URL, authPreference string) (*spec.APISpec, error) {
var metadata specDataMetadata
if normalized, meta, err := normalizeSpecDataWithMetadata(data); err == nil {
data = normalized
@@ -388,7 +409,7 @@ func parseWithLocation(data []byte, lenient bool, location *url.URL) (*spec.APIS
baseURLIsPlaceholder = true
}
- auth := mapAuthWithDescriptionInference(doc, name, !metadata.explicitEmptySecuritySchemes)
+ auth := mapAuthWithDescriptionInference(doc, name, !metadata.explicitEmptySecuritySchemes, authPreference)
if auth.Type != "none" && allOperationsAllowAnonymous(doc) {
auth = spec.AuthConfig{Type: "none"}
}
@@ -549,12 +570,12 @@ func lookupOpenAPIInfoExtension(doc *openapi3.T, key string) (any, bool) {
}
func mapAuth(doc *openapi3.T, name string) spec.AuthConfig {
- return mapAuthWithDescriptionInference(doc, name, true)
+ return mapAuthWithDescriptionInference(doc, name, true, "")
}
-func mapAuthWithDescriptionInference(doc *openapi3.T, name string, allowDescriptionInference bool) spec.AuthConfig {
+func mapAuthWithDescriptionInference(doc *openapi3.T, name string, allowDescriptionInference bool, authPreference string) spec.AuthConfig {
auth := spec.AuthConfig{Type: "none"}
- schemeName, scheme := selectSecurityScheme(doc)
+ schemeName, scheme := selectSecurityScheme(doc, authPreference)
if scheme == nil {
result := inferQueryParamAuth(doc, name, auth)
if result.Type == "none" {
@@ -1758,13 +1779,25 @@ func isNegated(text string, keywordIdx int) bool {
return false
}
-func selectSecurityScheme(doc *openapi3.T) (string, *openapi3.SecurityScheme) {
+func selectSecurityScheme(doc *openapi3.T, authPreference string) (string, *openapi3.SecurityScheme) {
if doc == nil || doc.Components == nil || len(doc.Components.SecuritySchemes) == 0 {
return "", nil
}
candidates := candidateSecuritySchemeNames(doc)
+ if pref := strings.TrimSpace(authPreference); pref != "" {
+ for _, name := range candidates {
+ if !strings.EqualFold(name, pref) {
+ continue
+ }
+ scheme := securitySchemeValue(doc.Components.SecuritySchemes[name])
+ if scheme != nil {
+ return name, scheme
+ }
+ }
+ }
+
bestScore := math.MaxInt
var bestName string
var bestScheme *openapi3.SecurityScheme
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index c4d4a964..a6adacaf 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -989,6 +989,184 @@ paths:
assert.Equal(t, "https://example.com/auth", parsed.Auth.AuthorizationURL)
}
+func TestParseAuthPreferenceSelectsNamedScheme(t *testing.T) {
+ t.Parallel()
+
+ // Many real-world specs (Atlassian Jira, Confluence, Bitbucket, GitLab)
+ // advertise both OAuth2 (with full authorizationCode flow) and HTTP Basic.
+ // The default selector picks OAuth2 — correct for hosted multi-tenant
+ // integrations but wrong for personal-token CLIs. AuthPreference lets the
+ // catalog (or a generate caller) pin the simpler scheme.
+ specBytes := []byte(`openapi: "3.0.3"
+info:
+ title: Atlassian-like
+ version: "1.0"
+servers:
+ - url: https://example.atlassian.net
+components:
+ securitySchemes:
+ OAuth2:
+ type: oauth2
+ flows:
+ authorizationCode:
+ authorizationUrl: https://auth.example.com/authorize
+ tokenUrl: https://auth.example.com/token
+ scopes:
+ read: read access
+ basicAuth:
+ type: http
+ scheme: basic
+paths:
+ /v1/things:
+ get:
+ operationId: list things
+ security:
+ - basicAuth: []
+ - OAuth2: [read]
+ responses: {"200": {description: ok}}
+`)
+
+ defaultParsed, err := Parse(specBytes)
+ require.NoError(t, err)
+ assert.Equal(t, "OAuth2", defaultParsed.Auth.Scheme, "without preference, OAuth2+AC wins by design")
+ assert.Equal(t, "bearer_token", defaultParsed.Auth.Type)
+
+ preferred, err := ParseWithOptions(specBytes, ParseOptions{AuthPreference: "basicAuth"})
+ require.NoError(t, err)
+ assert.Equal(t, "api_key", preferred.Auth.Type, "preference pins HTTP Basic")
+ assert.Equal(t, "basicAuth", preferred.Auth.Scheme)
+ assert.Equal(t, "Basic {username}:{password}", preferred.Auth.Format)
+}
+
+func TestParseAuthPreferenceCaseInsensitive(t *testing.T) {
+ t.Parallel()
+
+ specBytes := []byte(`openapi: "3.0.3"
+info:
+ title: Mixed
+ version: "1.0"
+servers:
+ - url: https://api.example.com
+components:
+ securitySchemes:
+ OAuth2:
+ type: oauth2
+ flows:
+ authorizationCode:
+ authorizationUrl: https://api.example.com/authorize
+ tokenUrl: https://api.example.com/token
+ scopes:
+ read: read
+ BasicAuth:
+ type: http
+ scheme: basic
+paths:
+ /v1/things:
+ get:
+ operationId: list things
+ security:
+ - BasicAuth: []
+ - OAuth2: [read]
+ responses: {"200": {description: ok}}
+`)
+
+ preferred, err := ParseWithOptions(specBytes, ParseOptions{AuthPreference: "basicauth"})
+ require.NoError(t, err)
+ assert.Equal(t, "api_key", preferred.Auth.Type)
+ assert.Equal(t, "BasicAuth", preferred.Auth.Scheme, "match is case-insensitive but result preserves spec casing")
+}
+
+func TestParseAuthPreferenceUnknownNameFallsBackToDefault(t *testing.T) {
+ t.Parallel()
+
+ // An unknown preference name must not fail parse; it falls through to the
+ // default selector so a typo in catalog yaml degrades gracefully.
+ specBytes := []byte(`openapi: "3.0.3"
+info:
+ title: Mixed
+ version: "1.0"
+servers:
+ - url: https://api.example.com
+components:
+ securitySchemes:
+ OAuth2:
+ type: oauth2
+ flows:
+ authorizationCode:
+ authorizationUrl: https://api.example.com/authorize
+ tokenUrl: https://api.example.com/token
+ scopes:
+ read: read
+ basicAuth:
+ type: http
+ scheme: basic
+paths:
+ /v1/things:
+ get:
+ operationId: list things
+ security:
+ - basicAuth: []
+ - OAuth2: [read]
+ responses: {"200": {description: ok}}
+`)
+
+ parsed, err := ParseWithOptions(specBytes, ParseOptions{AuthPreference: "doesNotExist"})
+ require.NoError(t, err)
+ assert.Equal(t, "OAuth2", parsed.Auth.Scheme, "unknown preference falls back to default selector")
+ assert.Equal(t, "bearer_token", parsed.Auth.Type)
+}
+
+func TestParseBearerPreservedOverOAuth2AuthCode(t *testing.T) {
+ t.Parallel()
+
+ // GitHub-style shape: an http/bearer scheme alongside a full OAuth2
+ // authorizationCode flow. The scoring system in schemePriorityScore
+ // pins schemePriorityBearer = 0 < schemePriorityOAuth2AuthCode = 200
+ // precisely because Bearer is the simplest scheme for a CLI to use.
+ // Default selection must keep Bearer; AuthPreference must still let
+ // callers opt into OAuth2 when they want the 3LO dance.
+ specBytes := []byte(`openapi: "3.0.3"
+info:
+ title: GitHub-like
+ version: "1.0"
+servers:
+ - url: https://api.example.com
+components:
+ securitySchemes:
+ BearerAuth:
+ type: http
+ scheme: bearer
+ OAuth2:
+ type: oauth2
+ flows:
+ authorizationCode:
+ authorizationUrl: https://auth.example.com/authorize
+ tokenUrl: https://auth.example.com/token
+ scopes:
+ read: read access
+paths:
+ /v1/things:
+ get:
+ operationId: list things
+ security:
+ - BearerAuth: []
+ - OAuth2: [read]
+ responses: {"200": {description: ok}}
+`)
+
+ defaultParsed, err := Parse(specBytes)
+ require.NoError(t, err)
+ assert.Equal(t, "BearerAuth", defaultParsed.Auth.Scheme, "default selection must keep Bearer over OAuth2+AC")
+ assert.Equal(t, "bearer_token", defaultParsed.Auth.Type)
+
+ preferred, err := ParseWithOptions(specBytes, ParseOptions{AuthPreference: "OAuth2"})
+ require.NoError(t, err)
+ assert.Equal(t, "OAuth2", preferred.Auth.Scheme, "AuthPreference must still let callers opt into OAuth2")
+ assert.Equal(t, "bearer_token", preferred.Auth.Type)
+ assert.Equal(t, "https://auth.example.com/authorize", preferred.Auth.AuthorizationURL)
+ assert.Equal(t, "https://auth.example.com/token", preferred.Auth.TokenURL)
+}
+
func TestBearerSchemeNameCanSpecializeEnvVar(t *testing.T) {
t.Parallel()
diff --git a/testdata/golden/expected/catalog-list/stdout.txt b/testdata/golden/expected/catalog-list/stdout.txt
index 27baa4da..cff87db9 100644
--- a/testdata/golden/expected/catalog-list/stdout.txt
+++ b/testdata/golden/expected/catalog-list/stdout.txt
@@ -35,6 +35,7 @@ payments:
project-management:
asana Work management and project tracking API
+ jira Issue tracking and project management API for Jira Cloud
sales-and-crm:
hubspot CRM contacts API
← 62838d04 fix(cli): validate-narrative splits piped recipes and strips
·
back to Cli Printing Press
·
fix(cli): filter auth login --chrome cookies by spec auth.co 95417401 →