← back to Cli Printing Press
feat(cli): search body construction, README website links, and profiler param detection (#120)
90cf58440dee96e7dacb0da383b5a3bd7501f43d · 2026-04-03 22:05:12 -0700 · Trevin Chow
* feat(cli): hyperlink product name in generated README to official website
Extracts website URL from OpenAPI spec contact.url, externalDocs.url, or
x-website extension. For sniffed APIs, derives it from the base URL domain.
The README title becomes [Product Name](url) CLI when a URL is available.
Adds WebsiteURL field to APISpec, extracted during OpenAPI parsing and
passed through to readme.md.tmpl.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): profiler recognizes queryText and text as search query params
The search param detection only checked q, query, search, keyword, term.
APIs like Postman Explore use queryText which wasn't matched, causing the
generated search command to send {"q": query} instead of {"queryText": query}
and getting HTTP 400 from the API.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): construct full POST body for complex search endpoints
Three fixes for live search on APIs with complex POST search bodies:
1. Parser: mapSchemaType now returns "array" for array schemas (was
defaulting to "string"), and isComplexBodyFieldSchema allows string
arrays through (they're not truly complex — just lists of known values).
Array items with enums are propagated via Fields[0].Enum.
2. Profiler: SearchBodyFields captures all non-query body fields with
their spec defaults, enum values, or synthesized defaults. The search
template constructs the full POST body at generation time using a new
goLiteral template function.
3. Search template: isNilOrEmpty now checks nested "document" objects
and preserves items with "score" fields — fixes search result wrappers
like {score, document: {name, ...}} being incorrectly filtered out.
Tested: postman-explore search "weather" returns 10 live results.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
A docs/plans/2026-04-03-005-feat-verify-live-smoke-tests-plan.mdM internal/generator/generator.goM internal/generator/templates/readme.md.tmplM internal/generator/templates/search.go.tmplM internal/openapi/parser.goM internal/profiler/profiler.goM internal/spec/spec.go
Diff
commit 90cf58440dee96e7dacb0da383b5a3bd7501f43d
Author: Trevin Chow <trevin@trevinchow.com>
Date: Fri Apr 3 22:05:12 2026 -0700
feat(cli): search body construction, README website links, and profiler param detection (#120)
* feat(cli): hyperlink product name in generated README to official website
Extracts website URL from OpenAPI spec contact.url, externalDocs.url, or
x-website extension. For sniffed APIs, derives it from the base URL domain.
The README title becomes [Product Name](url) CLI when a URL is available.
Adds WebsiteURL field to APISpec, extracted during OpenAPI parsing and
passed through to readme.md.tmpl.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): profiler recognizes queryText and text as search query params
The search param detection only checked q, query, search, keyword, term.
APIs like Postman Explore use queryText which wasn't matched, causing the
generated search command to send {"q": query} instead of {"queryText": query}
and getting HTTP 400 from the API.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): construct full POST body for complex search endpoints
Three fixes for live search on APIs with complex POST search bodies:
1. Parser: mapSchemaType now returns "array" for array schemas (was
defaulting to "string"), and isComplexBodyFieldSchema allows string
arrays through (they're not truly complex — just lists of known values).
Array items with enums are propagated via Fields[0].Enum.
2. Profiler: SearchBodyFields captures all non-query body fields with
their spec defaults, enum values, or synthesized defaults. The search
template constructs the full POST body at generation time using a new
goLiteral template function.
3. Search template: isNilOrEmpty now checks nested "document" objects
and preserves items with "score" fields — fixes search result wrappers
like {score, document: {name, ...}} being incorrectly filtered out.
Tested: postman-explore search "weather" returns 10 live results.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
...-04-03-005-feat-verify-live-smoke-tests-plan.md | 227 +++++++++++++++++++++
internal/generator/generator.go | 43 ++++
internal/generator/templates/readme.md.tmpl | 4 +-
internal/generator/templates/search.go.tmpl | 24 +++
internal/openapi/parser.go | 48 ++++-
internal/profiler/profiler.go | 80 +++++++-
internal/spec/spec.go | 1 +
7 files changed, 416 insertions(+), 11 deletions(-)
diff --git a/docs/plans/2026-04-03-005-feat-verify-live-smoke-tests-plan.md b/docs/plans/2026-04-03-005-feat-verify-live-smoke-tests-plan.md
new file mode 100644
index 00000000..7949386e
--- /dev/null
+++ b/docs/plans/2026-04-03-005-feat-verify-live-smoke-tests-plan.md
@@ -0,0 +1,227 @@
+---
+title: "feat: Add live smoke tests to verify pipeline"
+type: feat
+status: active
+date: 2026-04-03
+---
+
+# Add Live Smoke Tests to Verify Pipeline
+
+## Overview
+
+Add a `--smoke` flag to the `verify` command that runs a small set of live read-only API calls to catch request-shape bugs (wrong param names, broken auth headers, incorrect paths) before the CLI ships. Today, verify tests `--help`, `--dry-run`, and mock execution — none of which send a real HTTP request. Bugs like sending `{"q": "steam"}` instead of `{"queryText": "steam"}` pass all existing checks and only surface when the user manually runs the command.
+
+## Problem Frame
+
+The verify pipeline has two modes: mock (default) and live (`--api-key`). Mock mode starts an `httptest.Server` that returns synthetic JSON — it validates CLI plumbing (flags parsed, output formatted) but cannot detect wrong API paths, wrong parameter names, or broken auth headers. Live mode (`--api-key`) runs ALL commands against the real API, which is heavy, requires auth for every API, and conflates "does the request shape work?" with "does every command work end-to-end?"
+
+The gap is a lightweight middle ground: send 2-4 real HTTP requests to verify the request shape is correct, without running the full command suite against the live API. This catches the class of bugs where the CLI compiles, help works, dry-run shows the right shape, but the actual API rejects the request.
+
+## Requirements Trace
+
+- R1. `verify --smoke` runs 2-4 live read-only API requests against representative commands
+- R2. Smoke tests only run read-only operations (GET, search). No mutations.
+- R3. Smoke tests check for HTTP 2xx/3xx success — any 4xx/5xx fails the smoke test with the response body in the report
+- R4. Smoke test results appear in the `VerifyReport` alongside existing help/dry-run/exec results
+- R5. `--smoke` requires either `--api-key` or a reachable unauthenticated API — error clearly if neither
+- R6. Existing `--api-key` full-live mode is unchanged. `--smoke` is additive.
+- R7. The printing-press skill's shipcheck phase can pass `--smoke` when an API key is available
+
+## Scope Boundaries
+
+- **Not changing mock mode.** Default `verify` (no flags) continues to use the mock server.
+- **Not replacing `--api-key` mode.** Full live verification continues to work as before.
+- **Not testing write commands.** Smoke tests are read-only.
+- **Not adding retry logic to smoke tests.** Single attempt per command. Transient failures are acceptable — smoke tests catch shape bugs, not availability issues.
+- **Not auto-discovering which commands to smoke test.** The selection is deterministic based on command classification.
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/pipeline/runtime.go` — `RunVerify()`, `VerifyConfig`, `VerifyReport`, `CommandResult`, command discovery, classification, mock server setup
+- `internal/pipeline/runtime.go:473` — command classification: hardcoded data-layer (sync, search), local (doctor, auth), read (tail), spec-based method inference
+- `internal/pipeline/runtime.go:441` — synthetic argument values for positional args (maps placeholder names to test values)
+- `internal/cli/verify.go` — CLI flag definitions: `--dir`, `--spec`, `--api-key`, `--env-var`, `--threshold`, `--fix`, `--json`, `--cleanup`
+- `internal/pipeline/fixloop.go` — fix loop pattern (iterative test → fix → re-test)
+- `internal/pipeline/workflow_verify.go` — workflow verification with `mode: live` and 3-retry logic for transient errors
+
+### Institutional Learnings
+
+- **Postman Explore search bug** (this session): `search "steam"` sent `{"q": "steam"}` instead of `{"queryText": "steam"}` because the profiler didn't recognize `queryText`. Passed all verify checks. Would have been caught by a single live search request.
+- **Verify gap for local-data commands** (Postman Explore Run 2 retro): Commands needing local SQLite DB can't be tested during verify without a prior sync. Smoke tests should skip data-layer commands that require local state.
+
+## Key Technical Decisions
+
+- **`--smoke` is a separate flag, not a mode of `--api-key`**: `--api-key` runs ALL commands live (existing behavior). `--smoke` runs only 2-4 representative commands. They can be combined (`--smoke --api-key KEY`) or `--smoke` can work without a key for unauthenticated APIs.
+
+- **Command selection is deterministic**: Pick one command from each category that exists: (1) the first `read` GET command, (2) the `search` command if it exists, (3) the `sync` command if it exists (with `--resources <first-resource>` to limit scope). No randomness — reproducible across runs.
+
+- **Smoke results are a new field on `VerifyReport`**: Add `SmokeResults []SmokeResult` to the report. The existing `Verdict` computation considers smoke failures as warnings (not hard failures) so they don't block shipping CLIs that work in mock mode but have a flaky API.
+
+- **Smoke tests use the same environment setup as live mode**: Auth env vars, base URL override, timeout. The infrastructure already exists in `RunVerify()`.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Should smoke failures affect the verdict?** Yes, but as warnings only. A smoke failure means the request shape may be wrong, but could also be a transient API issue. The verdict should note "smoke: WARN" but not downgrade a PASS to FAIL solely on smoke results.
+
+- **How does `--smoke` interact with `--fix`?** Smoke tests run after the fix loop, not during it. The fix loop repairs plumbing issues (dry-run, help). Smoke tests validate request shape, which the fix loop can't repair automatically.
+
+### Deferred to Implementation
+
+- **Exact timeout for smoke requests.** The existing live mode uses 15 seconds. Smoke tests may want a shorter timeout (5-10 seconds) since they're meant to be quick. Determine during implementation.
+
+## Implementation Units
+
+- [ ] **Unit 1: Add `--smoke` flag and `SmokeResult` type**
+
+**Goal:** Add the `--smoke` CLI flag and the data types for smoke test results.
+
+**Requirements:** R1, R4, R6
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/cli/verify.go`
+- Modify: `internal/pipeline/runtime.go`
+- Test: `internal/pipeline/runtime_test.go`
+
+**Approach:**
+- Add `Smoke bool` field to `VerifyConfig`
+- Add `--smoke` flag to the verify CLI command
+- Add `SmokeResult` struct: `Command string`, `Path string`, `Method string`, `StatusCode int`, `Success bool`, `Error string`, `ResponseSnippet string`
+- Add `SmokeResults []SmokeResult` and `SmokePass bool` fields to `VerifyReport`
+- Pass `Smoke` through to `RunVerify()`
+
+**Patterns to follow:**
+- Existing `APIKey` field on `VerifyConfig` and `--api-key` flag registration in `internal/cli/verify.go`
+- Existing `CommandResult` struct pattern for per-command results
+
+**Test scenarios:**
+- Happy path: `VerifyConfig{Smoke: true}` is accepted, `SmokeResults` field is present on report
+- Happy path: `VerifyConfig{Smoke: false}` (default) produces report with nil/empty `SmokeResults`
+- Edge case: `--smoke` without `--api-key` on an API that requires auth → clear error message
+
+**Verification:**
+- `printing-press verify --help` shows `--smoke` flag
+- Generated report JSON includes `smoke_results` field when `--smoke` is set
+
+---
+
+- [ ] **Unit 2: Implement smoke test execution**
+
+**Goal:** Run 2-4 live read-only requests against representative commands and record results.
+
+**Requirements:** R1, R2, R3, R5
+
+**Dependencies:** Unit 1
+
+**Files:**
+- Modify: `internal/pipeline/runtime.go`
+- Test: `internal/pipeline/runtime_test.go`
+
+**Approach:**
+- Add `runSmokeTests()` function called from `RunVerify()` when `cfg.Smoke` is true
+- Command selection: from the discovered commands, pick deterministically:
+ 1. First command classified as `read` with no required positional args (simplest GET)
+ 2. `search` command if it exists (the exact bug class this feature catches)
+ 3. `sync` command if it exists (with `--resources <first-resource> --full` to limit scope, 10-second timeout)
+- For each selected command, run the CLI binary with real API credentials (same env setup as live mode) and capture the exit code plus stderr/stdout
+- Parse the HTTP status from the output — if the command exits 0, it's a pass. If it exits non-zero, capture the error message (which includes the HTTP status from `classifyAPIError`)
+- For search: run with a generic query like `"test"` — the goal is to verify the request shape, not find results
+- Skip smoke tests entirely if no API key is provided AND the API requires auth (determined from spec or VerifyConfig)
+
+**Patterns to follow:**
+- Existing `runCommandTests()` function for CLI invocation with environment and timeout
+- Existing `runDataPipelineTest()` for sync invocation pattern
+- Existing synthetic argument values map for generating test inputs
+
+**Test scenarios:**
+- Happy path: API reachable, all smoke commands return 2xx → `SmokePass: true`, each `SmokeResult.Success: true`
+- Happy path: unauthenticated API, no `--api-key` needed → smoke tests run against live API without auth
+- Error path: search command returns 400 (wrong param name) → `SmokeResult{Success: false, StatusCode: 400, Error: "..."}` with response body snippet
+- Error path: API unreachable (DNS/timeout) → `SmokeResult{Success: false, Error: "connection refused"}` — not confused with a request-shape bug
+- Edge case: CLI has no `search` command → only GET and sync are tested (or just GET if no sync)
+- Edge case: CLI has no `read` commands at all → smoke returns empty results with a note, not an error
+
+**Verification:**
+- Run `verify --smoke --api-key <key>` on a generated CLI with a working API → smoke results show in report
+- Run `verify --smoke` on a CLI for an unauthenticated API → smoke tests run without a key
+
+---
+
+- [ ] **Unit 3: Integrate smoke results into verdict and reporting**
+
+**Goal:** Surface smoke results in the human-readable and JSON reports, and factor them into the verdict as warnings.
+
+**Requirements:** R3, R4
+
+**Dependencies:** Unit 1, Unit 2
+
+**Files:**
+- Modify: `internal/pipeline/runtime.go` (verdict computation)
+- Modify: `internal/cli/verify.go` (report rendering)
+- Test: `internal/pipeline/runtime_test.go`
+
+**Approach:**
+- In the verdict computation section of `RunVerify()`, if smoke tests ran and any failed, append "smoke failures" to the report but do NOT downgrade PASS to FAIL. If the mock/live verdict is PASS but smoke failed, set verdict to "PASS (smoke: WARN)".
+- In the human-readable report output, add a "Smoke Tests" section after the per-command table showing each smoke result with command name, HTTP status, and pass/fail
+- In the JSON report, include `smoke_results` array and `smoke_pass` boolean
+
+**Patterns to follow:**
+- Existing verdict derivation logic in `RunVerify()` (PassRate, DataPipeline, Critical thresholds)
+- Existing human report rendering in `internal/cli/verify.go` (tabular command results)
+
+**Test scenarios:**
+- Happy path: all smoke tests pass → verdict unchanged, smoke section shows all green
+- Happy path: smoke tests fail but mock tests pass → verdict "PASS (smoke: WARN)", report shows which smoke commands failed with HTTP status
+- Edge case: `--smoke` not set → no smoke section in report, verdict unaffected
+- Happy path: JSON output includes `smoke_results` array with `command`, `status_code`, `success`, `error` fields
+
+**Verification:**
+- Human report shows smoke test table when `--smoke` is used
+- JSON report includes `smoke_results` and `smoke_pass`
+- Verdict computation correctly warns on smoke failure without blocking
+
+---
+
+- [ ] **Unit 4: Wire `--smoke` into printing-press skill shipcheck**
+
+**Goal:** The printing-press skill passes `--smoke` to verify during shipcheck when an API key is available.
+
+**Requirements:** R7
+
+**Dependencies:** Unit 3
+
+**Files:**
+- Modify: `skills/printing-press/SKILL.md` (Phase 4 shipcheck verify invocation)
+
+**Approach:**
+- In the Phase 4 shipcheck section, update the `printing-press verify` invocation to include `--smoke` when an API key is available (same condition that currently gates Phase 5 live smoke testing)
+- The skill already tracks whether an API key was provided — use that flag
+- Update the shipcheck interpretation to note smoke warnings in the shipcheck report
+
+**Test expectation:** none — skill file edit, no behavioral code change. Verified by running a full printing-press session with an API key and confirming the shipcheck report includes smoke results.
+
+## System-Wide Impact
+
+- **Interaction graph:** `--smoke` integrates into the existing `RunVerify()` flow. It runs after command discovery and classification (which it depends on) and before verdict computation (which it feeds into). The fix loop is unaffected — smoke runs after fixes, not during.
+- **Error propagation:** Smoke failures are warnings, not errors. They appear in the report but don't block the verdict. This is intentional — transient API issues shouldn't block shipping.
+- **Unchanged invariants:** Mock mode, `--api-key` full-live mode, `--fix` loop, workflow-verify, dogfood, and scorecard are all unchanged. `--smoke` is purely additive.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Smoke tests flaky due to API rate limits | Single attempt, no retry. Smoke failures are warnings, not blockers. The report includes the HTTP status so the user can distinguish rate limits (429) from shape bugs (400). |
+| Smoke tests slow down verify | At most 3 commands with 10-15 second timeouts = 45 seconds worst case. Acceptable for an opt-in flag. |
+| Search smoke test returns 0 results (not a bug) | The test checks for HTTP 2xx, not result count. Zero results with 200 is a pass — the request shape was correct. |
+
+## Sources & References
+
+- Related code: `internal/pipeline/runtime.go` (RunVerify, VerifyConfig, VerifyReport, command discovery)
+- Related code: `internal/cli/verify.go` (CLI flag definitions, report rendering)
+- Related code: `internal/pipeline/workflow_verify.go` (live mode retry pattern — reference but not used for smoke)
+- Retro finding: Postman Explore `queryText` bug — the exact bug class this feature prevents
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 5c367d76..8aa839e8 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -3,6 +3,7 @@ package generator
import (
"embed"
"fmt"
+ "net/url"
"os"
"os/exec"
"path/filepath"
@@ -111,6 +112,40 @@ func New(s *spec.APISpec, outputDir string) *Generator {
return cases.Title(language.English).String(strings.ReplaceAll(s, "-", " "))
},
"envName": func(s string) string { return strings.ToUpper(strings.ReplaceAll(s, "-", "_")) },
+ "goLiteral": func(v any) string {
+ switch val := v.(type) {
+ case string:
+ return fmt.Sprintf("%q", val)
+ case int:
+ return strconv.Itoa(val)
+ case float64:
+ if val == float64(int(val)) {
+ return strconv.Itoa(int(val))
+ }
+ return fmt.Sprintf("%g", val)
+ case bool:
+ if val {
+ return "true"
+ }
+ return "false"
+ case []string:
+ parts := make([]string, len(val))
+ for i, s := range val {
+ parts[i] = fmt.Sprintf("%q", s)
+ }
+ return "[]any{" + strings.Join(parts, ", ") + "}"
+ case []any:
+ parts := make([]string, len(val))
+ for i, item := range val {
+ parts[i] = fmt.Sprintf("%q", fmt.Sprint(item))
+ }
+ return "[]any{" + strings.Join(parts, ", ") + "}"
+ case map[string]any:
+ return "map[string]any{}"
+ default:
+ return fmt.Sprintf("%v", v)
+ }
+ },
"firstResource": func(resources map[string]spec.Resource) string {
var names []string
for name := range resources {
@@ -187,6 +222,12 @@ type readmeTemplateData struct {
}
func (g *Generator) readmeData() *readmeTemplateData {
+ // For sniffed APIs without a website URL, derive it from the base URL domain
+ if g.Spec.WebsiteURL == "" && g.Spec.SpecSource == "sniffed" && g.Spec.BaseURL != "" {
+ if u, err := url.Parse(g.Spec.BaseURL); err == nil && u.Host != "" {
+ g.Spec.WebsiteURL = u.Scheme + "://" + u.Host
+ }
+ }
return &readmeTemplateData{
APISpec: g.Spec,
Sources: g.Sources,
@@ -425,6 +466,7 @@ func (g *Generator) Generate() error {
SearchEndpointPath string
SearchQueryParam string
SearchEndpointMethod string
+ SearchBodyFields []profiler.SearchBodyField
}{
APISpec: g.Spec,
SyncableResources: g.profile.SyncableResources,
@@ -434,6 +476,7 @@ func (g *Generator) Generate() error {
SearchEndpointPath: g.profile.SearchEndpointPath,
SearchQueryParam: g.profile.SearchQueryParam,
SearchEndpointMethod: g.profile.SearchEndpointMethod,
+ SearchBodyFields: g.profile.SearchBodyFields,
}
for _, tmplName := range g.VisionSet.TemplateNames() {
diff --git a/internal/generator/templates/readme.md.tmpl b/internal/generator/templates/readme.md.tmpl
index 52b5e4dd..d80841c6 100644
--- a/internal/generator/templates/readme.md.tmpl
+++ b/internal/generator/templates/readme.md.tmpl
@@ -1,6 +1,8 @@
# {{humanName .Name}} CLI
-{{.Description}}
+{{.Description}}{{if .WebsiteURL}}
+
+Learn more at [{{humanName .Name}}]({{.WebsiteURL}}).{{end}}
## Install
diff --git a/internal/generator/templates/search.go.tmpl b/internal/generator/templates/search.go.tmpl
index a28ac71a..83188e61 100644
--- a/internal/generator/templates/search.go.tmpl
+++ b/internal/generator/templates/search.go.tmpl
@@ -14,11 +14,13 @@ import (
// isNilOrEmpty checks whether a JSON object has nil or empty values for
// common identifier fields (title, name, identifier, id).
+// Also checks nested "document" objects for search result wrappers.
func isNilOrEmpty(raw json.RawMessage) bool {
var obj map[string]interface{}
if err := json.Unmarshal(raw, &obj); err != nil {
return true
}
+ // Check top-level fields
for _, key := range []string{"title", "name", "identifier", "id"} {
if v, ok := obj[key]; ok {
if v == nil {
@@ -33,6 +35,25 @@ func isNilOrEmpty(raw json.RawMessage) bool {
}
}
}
+ // Check nested "document" for search result wrappers like {score, document: {name, ...}}
+ if doc, ok := obj["document"]; ok {
+ if docMap, ok := doc.(map[string]interface{}); ok {
+ for _, key := range []string{"title", "name", "identifier", "id", "slug"} {
+ if v, ok := docMap[key]; ok && v != nil {
+ if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
+ return false
+ }
+ if _, ok := v.(string); !ok {
+ return false
+ }
+ }
+ }
+ }
+ }
+ // If the object has a "score" field, it's likely a search result — keep it
+ if _, ok := obj["score"]; ok {
+ return false
+ }
return true
}
@@ -102,6 +123,9 @@ In local mode: searches locally synced data only.`,
{{- if eq .SearchEndpointMethod "POST"}}
data, _, getErr := c.Post("{{.SearchEndpointPath}}", map[string]any{
"{{.SearchQueryParam}}": query,
+{{- range .SearchBodyFields}}
+ "{{.Name}}": {{goLiteral .Default}},
+{{- end}}
})
{{- else}}
data, getErr := c.Get("{{.SearchEndpointPath}}", map[string]string{
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 87a372e8..159d9b96 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -150,6 +150,24 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
version = strings.TrimSpace(doc.Info.Version)
}
+ // Extract website URL from spec metadata (contact URL, externalDocs, or x-website)
+ var websiteURL string
+ if doc.Info != nil {
+ if doc.Info.Contact != nil && doc.Info.Contact.URL != "" {
+ websiteURL = doc.Info.Contact.URL
+ }
+ if websiteURL == "" && doc.Info.Extensions != nil {
+ if raw, ok := doc.Info.Extensions["x-website"]; ok {
+ if s, ok := raw.(string); ok {
+ websiteURL = s
+ }
+ }
+ }
+ }
+ if websiteURL == "" && doc.ExternalDocs != nil && doc.ExternalDocs.URL != "" {
+ websiteURL = doc.ExternalDocs.URL
+ }
+
// Extract x-proxy-routes extension for proxy-envelope client pattern
var proxyRoutes map[string]string
if doc.Info != nil && doc.Info.Extensions != nil {
@@ -213,6 +231,7 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
Version: version,
BaseURL: baseURL,
BasePath: basePath,
+ WebsiteURL: websiteURL,
ProxyRoutes: proxyRoutes,
Auth: mapAuth(doc, name),
Config: spec.ConfigSpec{
@@ -1146,6 +1165,16 @@ func mapRequestBody(requestBodyRef *openapi3.RequestBodyRef, method, path string
if schema != nil && schema.Default != nil {
param.Default = schema.Default
}
+ // For array types, propagate item-level enum as a Fields entry
+ // so downstream consumers (profiler) can access it.
+ if schema != nil && schema.Type != nil && schema.Type.Is(openapi3.TypeArray) &&
+ schema.Items != nil && schema.Items.Value != nil && len(schema.Items.Value.Enum) > 0 {
+ param.Fields = []spec.Param{{
+ Name: "items",
+ Type: "string",
+ Enum: schemaEnum(schema.Items.Value),
+ }}
+ }
body = append(body, param)
}
@@ -1405,6 +1434,8 @@ func mapSchemaType(schema *openapi3.Schema) string {
return "int"
case schema.Type.Includes(openapi3.TypeNumber):
return "float"
+ case schema.Type.Includes(openapi3.TypeArray):
+ return "array"
case schema.Type.Includes(openapi3.TypeString):
return "string"
default:
@@ -1463,7 +1494,22 @@ func isObjectSchema(schema *openapi3.Schema) bool {
}
func isComplexBodyFieldSchema(schema *openapi3.Schema) bool {
- return isObjectSchema(schema) || isArraySchema(schema)
+ if isObjectSchema(schema) {
+ return true
+ }
+ if isArraySchema(schema) {
+ // Arrays with simple string/enum items are not truly complex —
+ // they can be represented as comma-separated flags and are needed
+ // by the profiler for search body construction.
+ if schema.Items != nil && schema.Items.Value != nil {
+ itemSchema := schema.Items.Value
+ if itemSchema.Type != nil && itemSchema.Type.Is(openapi3.TypeString) {
+ return false // simple string array, keep it
+ }
+ }
+ return true
+ }
+ return false
}
func schemaTypeName(schemaRef *openapi3.SchemaRef, fallback string) string {
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index 0d05e4bc..47ce2bf2 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -44,6 +44,14 @@ type PaginationProfile struct {
DefaultPageSize int `json:"default_page_size"` // detected or default 100
}
+// SearchBodyField describes an additional body field needed for POST search endpoints.
+type SearchBodyField struct {
+ Name string `json:"name"`
+ Type string `json:"type"` // string, integer, boolean, array
+ Default any `json:"default"` // default value from spec, or synthesized from enum
+ Required bool `json:"required"`
+}
+
// SyncableResource describes a resource that supports paginated list sync.
type SyncableResource struct {
Name string
@@ -77,6 +85,10 @@ type APIProfile struct {
SearchQueryParam string
// SearchEndpointMethod is the HTTP method for the search endpoint (GET or POST).
SearchEndpointMethod string
+ // SearchBodyFields holds additional body fields (beyond the query param) needed for POST
+ // search endpoints. Each entry has name, default value, and type. The search template
+ // uses these to construct the full POST body at generation time.
+ SearchBodyFields []SearchBodyField
Domain DomainSignals
Pagination PaginationProfile
@@ -142,25 +154,75 @@ func Profile(s *spec.APISpec) *APIProfile {
hasSearchEndpoint = true
// Prefer shorter/more general search paths (e.g., /search over /users/search)
if p.SearchEndpointPath == "" || len(endpoint.Path) < len(p.SearchEndpointPath) {
+ method := strings.ToUpper(endpoint.Method)
p.SearchEndpointPath = endpoint.Path
- p.SearchEndpointMethod = strings.ToUpper(endpoint.Method)
- // Find the query parameter for the search endpoint
+ p.SearchEndpointMethod = method
p.SearchQueryParam = "q" // default
+ p.SearchBodyFields = nil
+
+ // Find the query parameter
+ searchParamNames := []string{"q", "query", "search", "keyword", "term", "querytext", "searchterm", "searchtext", "text"}
+ isSearchParam := func(name string) bool {
+ lower := strings.ToLower(name)
+ for _, sp := range searchParamNames {
+ if lower == sp {
+ return true
+ }
+ }
+ return false
+ }
+
for _, param := range endpoint.Params {
- pname := strings.ToLower(param.Name)
- if pname == "q" || pname == "query" || pname == "search" || pname == "keyword" || pname == "term" {
+ if isSearchParam(param.Name) {
p.SearchQueryParam = param.Name
break
}
}
- // For POST search endpoints, check body params too
- if p.SearchEndpointMethod == "POST" {
+
+ // For POST endpoints, check body params for query param and
+ // capture additional required fields with their defaults
+ if method == "POST" {
for _, param := range endpoint.Body {
- pname := strings.ToLower(param.Name)
- if pname == "q" || pname == "query" || pname == "search" || pname == "keyword" || pname == "term" {
+ if isSearchParam(param.Name) {
p.SearchQueryParam = param.Name
- break
+ continue
+ }
+ // Capture non-query body fields so the template can
+ // construct the full POST body at generation time
+ field := SearchBodyField{
+ Name: param.Name,
+ Type: param.Type,
+ Required: param.Required,
+ }
+ // Use spec default if available
+ if param.Default != nil {
+ field.Default = param.Default
+ } else if len(param.Enum) > 0 {
+ // For arrays with enum values, use all enum values as default
+ if param.Type == "array" {
+ field.Default = param.Enum
+ } else {
+ field.Default = param.Enum[0]
+ }
+ } else if param.Type == "array" && len(param.Fields) > 0 && len(param.Fields[0].Enum) > 0 {
+ // Array items have enum — use all enum values (e.g., search all entity types)
+ field.Default = param.Fields[0].Enum
+ } else {
+ // Synthesize reasonable defaults by type
+ switch param.Type {
+ case "integer", "number":
+ field.Default = 10
+ case "boolean":
+ field.Default = true
+ case "string":
+ field.Default = ""
+ case "object":
+ field.Default = map[string]any{}
+ case "array":
+ field.Default = []any{}
+ }
}
+ p.SearchBodyFields = append(p.SearchBodyFields, field)
}
}
}
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 7b103b9c..10dc6243 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -18,6 +18,7 @@ type APISpec struct {
SpecSource string `yaml:"spec_source,omitempty" json:"spec_source,omitempty"` // official, community, sniffed, docs — affects generated client defaults
ClientPattern string `yaml:"client_pattern,omitempty" json:"client_pattern,omitempty"` // rest (default), proxy-envelope — affects generated HTTP client
ProxyRoutes map[string]string `yaml:"proxy_routes,omitempty" json:"proxy_routes,omitempty"` // path prefix → service name for proxy-envelope routing
+ WebsiteURL string `yaml:"website_url,omitempty" json:"website_url,omitempty"` // product/company website (not the API base URL)
Auth AuthConfig `yaml:"auth" json:"auth"`
Config ConfigSpec `yaml:"config" json:"config"`
Resources map[string]Resource `yaml:"resources" json:"resources"`
← b88aa97b feat(cli): add --data-source flag for live/local/auto read r
·
back to Cli Printing Press
·
fix(cli): use catalog Homepage for README website link, remo 729ad07d →