Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/design-compose.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<CLI>_BASE_URL`). If the server URL contains template
variables (`{region}`), the generated CLI also supports
`--server-var-<name>` / `<CLI>_SERVER_VAR_<NAME>` 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.
4 changes: 2 additions & 2 deletions docs/openapi-3-support-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>` and `<CLI>_SERVER_VAR_<NAME>` 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 |
Expand Down
77 changes: 67 additions & 10 deletions internal/generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,13 +224,17 @@ 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{}

// Track which imports we actually need
needsBase64 := false
needsNetHTTP := false
needsIOUtil := false
needsStrings := false

for _, scheme := range schemes {
switch scheme.Type {
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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 {
Expand Down Expand Up @@ -459,9 +509,9 @@ import (
var (
outputFormat string
baseURL string
%s)
%s%s)

const defaultBaseURL = %q
const defaultBaseURLTemplate = %q

var version = %q

Expand All @@ -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 != "" {
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ---
Expand Down
57 changes: 57 additions & 0 deletions internal/generator/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
}
}
53 changes: 53 additions & 0 deletions internal/mock/mock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
18 changes: 18 additions & 0 deletions internal/skill/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
29 changes: 29 additions & 0 deletions internal/skill/skill_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading