diff --git a/docs/design-compose.md b/docs/design-compose.md index 191aba3..697a214 100644 --- a/docs/design-compose.md +++ b/docs/design-compose.md @@ -85,7 +85,9 @@ Both schemas are preserved in the merged document without conflict. - Only OpenAPI 3.x specs are supported (same constraint as `climate generate`). - `allOf` / `oneOf` / `anyOf` schema combiners are not rewritten in the current implementation; plain `$ref` strings are rewritten. -- The generated CLI routes requests to the servers declared in each individual - spec. If you want a true API gateway (single ingress), deploy a reverse - proxy in front of the services and point the generated CLI's `--server` flag - at it. +- The generated CLI uses the primary merged server URL and can override it via + `--base-url` (or `_BASE_URL`). If the server URL contains template + variables (`{region}`), the generated CLI also supports + `--server-var-` / `_SERVER_VAR_` overrides. +- If you want a true API gateway (single ingress), deploy a reverse proxy in + front of the services and point `--base-url` at it. diff --git a/docs/openapi-3-support-matrix.md b/docs/openapi-3-support-matrix.md index 6b1d1dd..448a72f 100644 --- a/docs/openapi-3-support-matrix.md +++ b/docs/openapi-3-support-matrix.md @@ -23,8 +23,8 @@ what is partially supported, and what should be designed/implemented next. | Local mock simulator (`mock`) | ✅ Implemented | Auto responses from spec schema + latency | Add optional examples-first mode | | `enum` | ✅ Implemented (mock) / ⚠️ partial (CLI) | Mock prefers first enum value | Add flag-level enum validation/help text | | `allOf`, `oneOf`, `anyOf`, `not` | ⚠️ Partial | Core flow works for simple schemas; advanced combiners not fully synthesized | Add schema normalizer for combiners | -| `servers` and server variables | ⚠️ Partial | Primary server is used; limited variable ergonomics | Add variable interpolation flags in generated root command | -| `callbacks` | ⚠️ Partial | Not mapped to generated CLI surface; mock can emit synthetic event payloads to target endpoints via flags | Add event command model (`events subscribe`/`events trigger`) | +| `servers` and server variables | ✅ Implemented | Generated CLIs use primary server URL and support server-template interpolation via `--server-var-` and `_SERVER_VAR_` env vars | Keep stable | +| `callbacks` | ⚠️ Partial | Not mapped to generated CLI surface; `climate mock` can generate and emit synthetic event payloads to target endpoints via flags | Add event command model (`events subscribe`/`events trigger`) | | `webhooks` (3.1) | ⚠️ Partial | Top-level webhook declarations are not yet parsed as first-class objects; mock has event emission mode for local webhook testing | Add webhook simulation and event ingestion model | | Links | ❌ Planned | Ignored | Add optional “follow-up command hint” output | | Examples (`example` / `examples`) | ⚠️ Partial | Not consistently preferred in generation | Use examples as first-class sample payload/response source | diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 0de67cf..60e572f 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -224,6 +224,9 @@ func rootGoContent(openAPI *spec.OpenAPI, cliName string, schemes []auth.Scheme) var authFlagInits strings.Builder var authHeadersBody strings.Builder var authQueryBody strings.Builder + var serverVarDecls strings.Builder + var serverVarFlagInits strings.Builder + var serverVarResolveBody strings.Builder seenVars := map[string]bool{} @@ -231,6 +234,7 @@ func rootGoContent(openAPI *spec.OpenAPI, cliName string, schemes []auth.Scheme) needsBase64 := false needsNetHTTP := false needsIOUtil := false + needsStrings := false for _, scheme := range schemes { switch scheme.Type { @@ -380,6 +384,49 @@ func rootGoContent(openAPI *spec.OpenAPI, cliName string, schemes []auth.Scheme) } } + baseURL := "" + serverVariables := map[string]spec.ServerVariable{} + if len(openAPI.Servers) > 0 { + baseURL = openAPI.Servers[0].URL + serverVariables = openAPI.Servers[0].Variables + } + + serverVarKeys := make([]string, 0, len(serverVariables)) + for name := range serverVariables { + serverVarKeys = append(serverVarKeys, name) + } + sort.Strings(serverVarKeys) + for _, name := range serverVarKeys { + sv := serverVariables[name] + varName := safeIdent("serverVar" + toPascal(name)) + flagName := "server-var-" + kebabCase(name) + envVar := spec.ServerVariableEnvName(cliUpper, name) + desc := sv.Description + if desc == "" { + desc = "Override server URL variable {" + name + "}" + } + serverVarDecls.WriteString(fmt.Sprintf("\t%s string\n", varName)) + serverVarFlagInits.WriteString(fmt.Sprintf( + "\trootCmd.PersistentFlags().StringVar(&%s, %q, \"\", %q)\n", + varName, flagName, desc, + )) + serverVarResolveBody.WriteString(fmt.Sprintf(` + { + v := %s + if v == "" { + v = os.Getenv(%q) + } + if v == "" { + v = %q + } + u = strings.ReplaceAll(u, %q, v) + } +`, varName, envVar, sv.Default, "{"+name+"}")) + } + if len(serverVarKeys) > 0 { + needsStrings = true + } + // Build the import list var imports strings.Builder imports.WriteString("\t\"encoding/json\"\n") @@ -390,6 +437,8 @@ func rootGoContent(openAPI *spec.OpenAPI, cliName string, schemes []auth.Scheme) if needsNetHTTP { imports.WriteString("\t\"net/http\"\n") imports.WriteString("\t\"net/url\"\n") + } + if needsNetHTTP || needsStrings { imports.WriteString("\t\"strings\"\n") } imports.WriteString("\t\"os\"\n") @@ -398,15 +447,16 @@ func rootGoContent(openAPI *spec.OpenAPI, cliName string, schemes []auth.Scheme) } imports.WriteString("\n\t\"github.com/spf13/cobra\"\n") - baseURL := "" - if len(openAPI.Servers) > 0 { - baseURL = openAPI.Servers[0].URL - } description := openAPI.Info.Description if description == "" { description = openAPI.Info.Title + " CLI" } + defaultBaseURLResolver := "\treturn defaultBaseURLTemplate\n" + if len(serverVarKeys) > 0 { + defaultBaseURLResolver = " u := defaultBaseURLTemplate\n" + serverVarResolveBody.String() + "\treturn u\n" + } + // OAuth2 helper function — only emitted when needed oauth2Helper := "" if needsNetHTTP { @@ -459,9 +509,9 @@ import ( var ( outputFormat string baseURL string -%s) +%s%s) -const defaultBaseURL = %q +const defaultBaseURLTemplate = %q var version = %q @@ -479,7 +529,7 @@ func Execute() error { func init() { rootCmd.PersistentFlags().StringVar(&outputFormat, "output", "json", "Output format: json|table|raw") rootCmd.PersistentFlags().StringVar(&baseURL, "base-url", "", "Override API base URL") -%s} +%s%s} func getBaseURL() string { if baseURL != "" { @@ -488,7 +538,11 @@ func getBaseURL() string { if v := os.Getenv(%q); v != "" { return v } - return defaultBaseURL + return resolveDefaultBaseURL() +} + +func resolveDefaultBaseURL() string { +%s } // getAuthHeaders returns HTTP headers required for authentication. @@ -533,16 +587,19 @@ func exitWithError(statusCode int, code, message string, raw interface{}) { enc.SetIndent("", " ") _ = enc.Encode(obj) os.Exit(1) -} + } %s`, imports.String(), authVarDecls.String(), + serverVarDecls.String(), baseURL, openAPI.Info.Version, cliName, description, authFlagInits.String(), + serverVarFlagInits.String(), cliUpper+"_BASE_URL", + defaultBaseURLResolver, authHeadersBody.String(), authQueryBody.String(), oauth2Helper, @@ -902,7 +959,7 @@ func (c *Client) Do(method, path string, query map[string]string, body []byte, e Raw: raw, }, nil } -`, baseURL) + `, baseURL) } // --- Naming helpers --- diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go index 93591f7..9505ef1 100644 --- a/internal/generator/generator_test.go +++ b/internal/generator/generator_test.go @@ -232,3 +232,60 @@ func TestGenerateRootVersionIsBuildOverridable(t *testing.T) { t.Fatal("root.go should wire cobra version through the version variable") } } + +func TestGenerateServerVariableFlagsAndInterpolation(t *testing.T) { + outDir := t.TempDir() + openAPI := sampleOpenAPI() + openAPI.Servers = []spec.Server{ + { + URL: "https://{region}.api.example.com/{basePath}", + Variables: map[string]spec.ServerVariable{ + "region": { + Default: "eu", + }, + "basePath": { + Default: "v1", + }, + }, + }, + } + rawSpec := []byte(`{}`) + + _, err := generator.Generate(openAPI, rawSpec, generator.Options{ + CLIName: "petstore", + OutDir: outDir, + NoBuild: true, + Force: true, + }) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } + + data, err := os.ReadFile(filepath.Join(outDir, "cmd", "root.go")) + if err != nil { + t.Fatalf("reading root.go: %v", err) + } + content := string(data) + + if !strings.Contains(content, "const defaultBaseURLTemplate = \"https://{region}.api.example.com/{basePath}\"") { + t.Fatal("root.go should keep the templated server URL") + } + if !strings.Contains(content, `StringVar(&serverVarRegion, "server-var-region"`) { + t.Fatal("root.go should declare --server-var-region") + } + if !strings.Contains(content, `StringVar(&serverVarBasePath, "server-var-base-path"`) { + t.Fatal("root.go should declare --server-var-base-path") + } + if !strings.Contains(content, "PETSTORE_SERVER_VAR_REGION") { + t.Fatal("root.go should expose PETSTORE_SERVER_VAR_REGION env override") + } + if !strings.Contains(content, "PETSTORE_SERVER_VAR_BASE_PATH") { + t.Fatal("root.go should expose PETSTORE_SERVER_VAR_BASE_PATH env override") + } + if !strings.Contains(content, `u = strings.ReplaceAll(u, "{region}", v)`) { + t.Fatal("root.go should interpolate {region}") + } + if !strings.Contains(content, `u = strings.ReplaceAll(u, "{basePath}", v)`) { + t.Fatal("root.go should interpolate {basePath}") + } +} diff --git a/internal/mock/mock_test.go b/internal/mock/mock_test.go index 8aab238..8239b5f 100644 --- a/internal/mock/mock_test.go +++ b/internal/mock/mock_test.go @@ -364,3 +364,56 @@ func TestEmitEvent(t *testing.T) { t.Errorf("event = %v, want order.created", gotBody["event"]) } } + +func TestGenerateAndEmitEventToEndpoint(t *testing.T) { + openAPI := &spec.OpenAPI{ + OpenAPI: "3.0.0", + Info: spec.Info{Title: "Events", Version: "1.0.0"}, + Paths: map[string]spec.PathItem{ + "/events/order-created": { + Post: &spec.Operation{ + RequestBody: &spec.RequestBody{ + Content: map[string]spec.MediaType{ + "application/json": { + Schema: &spec.Schema{ + Type: "object", + Properties: map[string]*spec.Schema{ + "eventId": {Type: "string"}, + "amount": {Type: "number"}, + }, + }, + }, + }, + }, + Responses: map[string]spec.Response{"202": {}}, + }, + }, + }, + } + + var gotBody map[string]interface{} + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusAccepted) + })) + defer target.Close() + + payload, err := mock.GenerateEventPayload(openAPI, "/events/order-created", http.MethodPost) + if err != nil { + t.Fatalf("GenerateEventPayload error: %v", err) + } + + status, err := mock.EmitEvent(target.URL, http.MethodPost, payload) + if err != nil { + t.Fatalf("EmitEvent error: %v", err) + } + if status != http.StatusAccepted { + t.Fatalf("status = %d, want %d", status, http.StatusAccepted) + } + if _, ok := gotBody["eventId"]; !ok { + t.Error("endpoint payload missing eventId") + } + if _, ok := gotBody["amount"]; !ok { + t.Error("endpoint payload missing amount") + } +} diff --git a/internal/skill/skill.go b/internal/skill/skill.go index 4539dc1..9e7daad 100644 --- a/internal/skill/skill.go +++ b/internal/skill/skill.go @@ -89,6 +89,24 @@ func GenerateCLIPrompt(entry manifest.CLIEntry, openAPI *spec.OpenAPI, mode Mode if len(openAPI.Servers) > 0 { b.WriteString("Default base URL: `" + openAPI.Servers[0].URL + "` ") b.WriteString("(override with `--base-url` or `" + envUpper(entry.Name) + "_BASE_URL`).\n") + if len(openAPI.Servers[0].Variables) > 0 { + varNames := make([]string, 0, len(openAPI.Servers[0].Variables)) + for name := range openAPI.Servers[0].Variables { + varNames = append(varNames, name) + } + sort.Strings(varNames) + b.WriteString("\nServer URL variables:\n") + for _, name := range varNames { + v := openAPI.Servers[0].Variables[name] + flag := "--server-var-" + kebabCase(name) + env := spec.ServerVariableEnvName(envUpper(entry.Name), name) + def := v.Default + if def == "" { + def = "(empty)" + } + b.WriteString(fmt.Sprintf("- `%s` / `%s` (default: `%s`)\n", flag, env, def)) + } + } } b.WriteString("\n") diff --git a/internal/skill/skill_test.go b/internal/skill/skill_test.go index 59095f0..dedeeff 100644 --- a/internal/skill/skill_test.go +++ b/internal/skill/skill_test.go @@ -157,6 +157,35 @@ func TestGenerateCLIPromptBodyFlags(t *testing.T) { } } +func TestGenerateCLIPromptServerVariables(t *testing.T) { + entry := manifest.CLIEntry{Name: "petstore", Version: "1.0.0"} + openAPI := sampleOpenAPI() + openAPI.Servers = []spec.Server{ + { + URL: "https://{region}.api.example.com/{basePath}", + Variables: map[string]spec.ServerVariable{ + "region": {Default: "eu"}, + "basePath": {Default: "v1"}, + }, + }, + } + + prompt := skill.GenerateCLIPrompt(entry, openAPI, skill.ModeFull) + + if !strings.Contains(prompt, "--server-var-region") { + t.Error("prompt should document --server-var-region") + } + if !strings.Contains(prompt, "--server-var-base-path") { + t.Error("prompt should document --server-var-base-path") + } + if !strings.Contains(prompt, "PETSTORE_SERVER_VAR_REGION") { + t.Error("prompt should mention PETSTORE_SERVER_VAR_REGION env var") + } + if !strings.Contains(prompt, "PETSTORE_SERVER_VAR_BASE_PATH") { + t.Error("prompt should mention PETSTORE_SERVER_VAR_BASE_PATH env var") + } +} + func TestGenerateCLIPromptRequiredParam(t *testing.T) { entry := manifest.CLIEntry{Name: "petstore", Version: "1.0.0"} openAPI := sampleOpenAPI() diff --git a/internal/spec/env.go b/internal/spec/env.go new file mode 100644 index 0000000..007e4c9 --- /dev/null +++ b/internal/spec/env.go @@ -0,0 +1,42 @@ +package spec + +import ( + "strings" + "unicode" +) + +// ServerVariableEnvName builds a normalized environment variable name for an +// OpenAPI server variable override, using an uppercase CLI prefix. +func ServerVariableEnvName(cliPrefixUpper, variableName string) string { + var b strings.Builder + prevLowerOrDigit := false + prevUnderscore := false + + for _, r := range variableName { + switch { + case unicode.IsUpper(r): + if prevLowerOrDigit && !prevUnderscore && b.Len() > 0 { + b.WriteRune('_') + } + b.WriteRune(r) + prevLowerOrDigit = false + prevUnderscore = false + case unicode.IsLower(r) || unicode.IsDigit(r): + b.WriteRune(unicode.ToUpper(r)) + prevLowerOrDigit = true + prevUnderscore = false + default: + if !prevUnderscore && b.Len() > 0 { + b.WriteRune('_') + prevUnderscore = true + } + prevLowerOrDigit = false + } + } + + suffix := strings.Trim(b.String(), "_") + if suffix == "" { + suffix = "VAR" + } + return cliPrefixUpper + "_SERVER_VAR_" + suffix +} diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go index a2d10cd..9ddec02 100644 --- a/internal/spec/spec_test.go +++ b/internal/spec/spec_test.go @@ -135,6 +135,45 @@ paths: } } +func TestParse_ServerVariables(t *testing.T) { + yamlSpec := ` +openapi: "3.0.0" +info: + title: "Server Vars API" + version: "1.0.0" +servers: + - url: "https://{region}.api.example.com/{basePath}" + variables: + region: + default: "eu" + enum: ["eu", "us"] + basePath: + default: "v1" +paths: + /ping: {} +` + s, err := spec.Parse("server-vars.yaml", []byte(yamlSpec)) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if len(s.Servers) != 1 { + t.Fatalf("servers count = %d, want 1", len(s.Servers)) + } + srv := s.Servers[0] + if srv.URL != "https://{region}.api.example.com/{basePath}" { + t.Fatalf("server url = %q", srv.URL) + } + if got := srv.Variables["region"].Default; got != "eu" { + t.Errorf("region default = %q, want %q", got, "eu") + } + if got := len(srv.Variables["region"].Enum); got != 2 { + t.Errorf("region enum len = %d, want 2", got) + } + if got := srv.Variables["basePath"].Default; got != "v1" { + t.Errorf("basePath default = %q, want %q", got, "v1") + } +} + func TestIsURL(t *testing.T) { tests := []struct { input string @@ -190,3 +229,21 @@ func TestPathItemOperations(t *testing.T) { t.Error("POST operation not found") } } + +func TestServerVariableEnvName(t *testing.T) { + tests := []struct { + name string + variable string + want string + }{ + {name: "PETSTORE", variable: "region", want: "PETSTORE_SERVER_VAR_REGION"}, + {name: "PETSTORE", variable: "basePath", want: "PETSTORE_SERVER_VAR_BASE_PATH"}, + {name: "MY_API", variable: "region-id", want: "MY_API_SERVER_VAR_REGION_ID"}, + } + for _, tt := range tests { + got := spec.ServerVariableEnvName(tt.name, tt.variable) + if got != tt.want { + t.Errorf("ServerVariableEnvName(%q,%q) = %q, want %q", tt.name, tt.variable, got, tt.want) + } + } +} diff --git a/internal/spec/types.go b/internal/spec/types.go index 4829f02..d8ceb87 100644 --- a/internal/spec/types.go +++ b/internal/spec/types.go @@ -21,8 +21,16 @@ type Info struct { // Server represents an API server. type Server struct { - URL string `json:"url" yaml:"url"` - Description string `json:"description" yaml:"description"` + URL string `json:"url" yaml:"url"` + Description string `json:"description" yaml:"description"` + Variables map[string]ServerVariable `json:"variables" yaml:"variables"` +} + +// ServerVariable represents one templated variable for a server URL. +type ServerVariable struct { + Enum []string `json:"enum" yaml:"enum"` + Default string `json:"default" yaml:"default"` + Description string `json:"description" yaml:"description"` } // Tag represents an OpenAPI tag. diff --git a/skills/climate-generator/SKILL.md b/skills/climate-generator/SKILL.md index a7b6568..649b663 100644 --- a/skills/climate-generator/SKILL.md +++ b/skills/climate-generator/SKILL.md @@ -146,7 +146,10 @@ climate publish petstore --owner disk0Dancer ## Notes -- All climate output is JSON on success. +- Most climate commands output JSON on success (`generate`, `compose`, `list`, + `publish`, `remove`, `upgrade`). +- `climate mock` in server mode and both `climate skill ...` commands output + plain text / Markdown by design. - Errors are emitted as structured JSON on stderr. - Generated CLIs follow the shape ` [flags] --output=json|table|raw`. - Homebrew install is available via `brew tap disk0Dancer/tap && brew install climate`. diff --git a/skills/climate.md b/skills/climate.md index e1f9df7..49e0461 100644 --- a/skills/climate.md +++ b/skills/climate.md @@ -142,7 +142,15 @@ Re-generates and rebuilds a CLI. Pass `--openapi` to use a different spec. ## Output format -On success all commands exit 0 and print JSON to stdout. +Most commands exit 0 and print JSON to stdout (`generate`, `compose`, `list`, +`publish`, `remove`, `upgrade`). + +Text/Markdown-oriented commands intentionally print plain text: + +- `climate mock` (server mode) prints startup info and route table +- `climate mock --emit-url ...` prints a one-line emission result +- `climate skill generate` prints Markdown prompt text +- `climate skill generator` prints the built-in Markdown skill On error commands exit non-zero and print to stderr: