From fd144593f9588cca769c74ad2d0ebc501cde8667 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:19:21 +0000 Subject: [PATCH 01/10] feat: add compose and mock commands with tests and design docs Agent-Logs-Url: https://github.com/disk0Dancer/climate/sessions/6163d378-07f4-4b75-804c-ed5506a7c703 Co-authored-by: disk0Dancer <89835485+disk0Dancer@users.noreply.github.com> --- cmd/climate/commands/compose.go | 149 +++++++++++++++ cmd/climate/commands/mock.go | 71 +++++++ docs/design-compose.md | 91 +++++++++ docs/design-mock.md | 122 ++++++++++++ internal/compose/compose.go | 312 +++++++++++++++++++++++++++++++ internal/compose/compose_test.go | 180 ++++++++++++++++++ internal/mock/mock.go | 307 ++++++++++++++++++++++++++++++ internal/mock/mock_test.go | 270 ++++++++++++++++++++++++++ 8 files changed, 1502 insertions(+) create mode 100644 cmd/climate/commands/compose.go create mode 100644 cmd/climate/commands/mock.go create mode 100644 docs/design-compose.md create mode 100644 docs/design-mock.md create mode 100644 internal/compose/compose.go create mode 100644 internal/compose/compose_test.go create mode 100644 internal/mock/mock.go create mode 100644 internal/mock/mock_test.go diff --git a/cmd/climate/commands/compose.go b/cmd/climate/commands/compose.go new file mode 100644 index 0000000..04dad9b --- /dev/null +++ b/cmd/climate/commands/compose.go @@ -0,0 +1,149 @@ +package commands + +import ( + "fmt" + "strings" + + "github.com/disk0Dancer/climate/internal/compose" + "github.com/disk0Dancer/climate/internal/generator" + "github.com/disk0Dancer/climate/internal/manifest" + "github.com/spf13/cobra" +) + +var ( + composeName string + composeOutDir string + composeNoBuild bool + composeForce bool + composeTitle string + composeVersion string + composeDesc string +) + +var composeCmd = &cobra.Command{ + Use: "compose [flags] : [: ...]", + Short: "Compose multiple OpenAPI specs into a single gateway CLI", + Long: `Merge several OpenAPI 3.x specifications — each assigned a path prefix — +into one composite spec, then generate a CLI from the result. + +This is the recommended workflow for microservice environments where each +service owns its own OpenAPI document. The resulting CLI acts as a single +facade: one binary, one authentication model, all services. + +Each positional argument has the form: + + : + +Where is a file path or URL and is a non-empty path prefix +that starts with "/" (e.g. "/api/v1"). + +Examples: + climate compose orders.yaml:/api/orders users.yaml:/api/users + climate compose --name gateway --title "Gateway API" \ + https://orders.svc/openapi.json:/orders \ + https://users.svc/openapi.json:/users`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + inputs, err := parseSpecInputs(args) + if err != nil { + exitError("Invalid spec:prefix arguments", err) + } + + merged, rawBytes, err := compose.MergeToBytes(inputs, compose.Options{ + Title: composeTitle, + Version: composeVersion, + Description: composeDesc, + }) + if err != nil { + exitError("Failed to compose specs", err) + } + + opts := generator.Options{ + CLIName: composeName, + OutDir: composeOutDir, + NoBuild: composeNoBuild, + Force: composeForce, + SpecSource: buildSpecSourceLabel(inputs), + } + + result, err := generator.Generate(merged, rawBytes, opts) + if err != nil { + exitError("Generation failed", err) + } + + // Update manifest. + mf, err := manifest.Load() + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not load manifest: %v\n", err) + } else { + mf.Upsert(manifest.CLIEntry{ + Name: result.CLIName, + BinaryPath: result.BinaryPath, + SourceDir: result.SourceDir, + Version: result.Version, + OpenAPIHash: result.OpenAPIHash, + OpenAPISpec: opts.SpecSource, + }) + if saveErr := mf.Save(); saveErr != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not save manifest: %v\n", saveErr) + } + } + + writeJSON(result) + return nil + }, +} + +// parseSpecInputs splits each "spec:prefix" argument into a SpecInput. +func parseSpecInputs(args []string) ([]compose.SpecInput, error) { + inputs := make([]compose.SpecInput, 0, len(args)) + for _, arg := range args { + // A URL contains "://" so we must find the colon that separates the + // spec from the prefix carefully: the prefix always starts with "/" so + // the last occurrence of ":/" is the boundary. + idx := strings.LastIndex(arg, ":/") + if idx < 0 { + // Plain colon split (local path with no scheme). + parts := strings.SplitN(arg, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("argument %q must have the form :", arg) + } + inputs = append(inputs, compose.SpecInput{Source: parts[0], Prefix: parts[1]}) + continue + } + + // Check whether ":/" is the scheme separator ("://") or the + // spec/prefix boundary. + schemeIdx := strings.Index(arg, "://") + if schemeIdx >= 0 && schemeIdx == idx { + // The only ":/" is the scheme — there's no prefix separator. + return nil, fmt.Errorf("argument %q must have the form : (e.g. https://host/spec.json:/prefix)", arg) + } + + // The last ":/" is the boundary (handles https://…:/prefix). + source := arg[:idx] + prefix := arg[idx+1:] // keeps the leading "/" + inputs = append(inputs, compose.SpecInput{Source: source, Prefix: prefix}) + } + return inputs, nil +} + +// buildSpecSourceLabel returns a human-readable label listing all source specs. +func buildSpecSourceLabel(inputs []compose.SpecInput) string { + parts := make([]string, len(inputs)) + for i, inp := range inputs { + parts[i] = inp.Source + inp.Prefix + } + return "compose:[" + strings.Join(parts, ",") + "]" +} + +func init() { + composeCmd.Flags().StringVar(&composeName, "name", "", "Override the generated CLI name") + composeCmd.Flags().StringVar(&composeOutDir, "out-dir", "", "Directory for generated source code") + composeCmd.Flags().BoolVar(&composeNoBuild, "no-build", false, "Skip building the binary") + composeCmd.Flags().BoolVar(&composeForce, "force", false, "Overwrite existing output directory") + composeCmd.Flags().StringVar(&composeTitle, "title", "", "Title for the composed API (info.title)") + composeCmd.Flags().StringVar(&composeVersion, "api-version", "1.0.0", "Version for the composed API (info.version)") + composeCmd.Flags().StringVar(&composeDesc, "description", "", "Description for the composed API (info.description)") + rootCmd.AddCommand(composeCmd) +} diff --git a/cmd/climate/commands/mock.go b/cmd/climate/commands/mock.go new file mode 100644 index 0000000..0ac2ece --- /dev/null +++ b/cmd/climate/commands/mock.go @@ -0,0 +1,71 @@ +package commands + +import ( + "fmt" + "time" + + "github.com/disk0Dancer/climate/internal/mock" + "github.com/disk0Dancer/climate/internal/spec" + "github.com/spf13/cobra" +) + +var ( + mockPort int + mockLatency int +) + +var mockCmd = &cobra.Command{ + Use: "mock [flags] ", + Short: "Start a local HTTP mock server from an OpenAPI spec", + Long: `Start a local HTTP mock server that serves synthetic responses for every +endpoint defined in an OpenAPI 3.x specification. + +This is useful for local development and testing when the real service is +unavailable, produces side-effects, or you simply want to experiment with +the API surface without any credentials. + +The server inspects each operation's first successful (2xx) response schema +and generates a plausible JSON value — objects with all declared properties +filled in, arrays with one example element, and scalars set to sensible +zero values. + +The spec can be a local file path or an HTTP(S) URL. + +Examples: + climate mock ./openapi.yaml + climate mock --port 9090 https://petstore3.swagger.io/api/v3/openapi.json + climate mock --latency 200 ./orders.yaml`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + specSource := args[0] + + openAPI, err := spec.Load(specSource) + if err != nil { + exitError("Failed to load spec", err) + } + + addr := fmt.Sprintf(":%d", mockPort) + latency := time.Duration(mockLatency) * time.Millisecond + s := mock.NewServer(openAPI, addr, latency) + + fmt.Fprintf(cmd.OutOrStdout(), "Mock server for %q listening on http://localhost%s\n", + openAPI.Info.Title, addr) + if mockLatency > 0 { + fmt.Fprintf(cmd.OutOrStdout(), "Artificial latency: %dms\n", mockLatency) + } + fmt.Fprintln(cmd.OutOrStdout(), "\nRoutes:") + fmt.Fprint(cmd.OutOrStdout(), s.Summary()) + fmt.Fprintln(cmd.OutOrStdout(), "\nPress Ctrl+C to stop.") + + if err := s.ListenAndServe(); err != nil { + exitError("Mock server error", err) + } + return nil + }, +} + +func init() { + mockCmd.Flags().IntVar(&mockPort, "port", 8080, "TCP port to listen on") + mockCmd.Flags().IntVar(&mockLatency, "latency", 0, "Artificial response latency in milliseconds") + rootCmd.AddCommand(mockCmd) +} diff --git a/docs/design-compose.md b/docs/design-compose.md new file mode 100644 index 0000000..191aba3 --- /dev/null +++ b/docs/design-compose.md @@ -0,0 +1,91 @@ +# Design: OpenAPI Spec Composition (`climate compose`) + +## Overview + +`climate compose` merges multiple OpenAPI 3.x specifications — each assigned a +dedicated path prefix — into a single composite specification and generates a +CLI from the result. The CLI acts as a **facade** over several microservices: +one binary, one authentication model, all services. + +## Motivation + +Modern back-ends are decomposed into microservices. Each service owns its own +OpenAPI document. Without tooling, developers and agents must juggle many +separate CLIs or issue raw HTTP calls to reach different services. A single +gateway CLI is far more ergonomic and aligns with the Backends-for-Frontends +(BFF) pattern. + +## Usage + +```bash +# Two microservices, each mounted under a different prefix +climate compose \ + orders.yaml:/api/orders \ + users.yaml:/api/users + +# With all optional flags +climate compose \ + --name gateway \ + --title "My Gateway API" \ + --api-version 2.0.0 \ + --description "Unified facade over Orders and Users services" \ + --out-dir /tmp/gateway \ + --force \ + https://orders.svc/openapi.json:/orders \ + https://users.svc/openapi.json:/users +``` + +Each positional argument has the form `:` where `` is a +local file or URL and `` starts with `/`. + +## Algorithm + +The merge is performed by `internal/compose.Merge`: + +1. **Load** — each source spec is loaded and validated with `spec.Load`. +2. **Namespace components** — every schema, parameter, and security scheme + defined under `components/` is renamed to `-` where `` is + derived from the prefix (e.g. `/api/orders` → `api-orders`). This prevents + name collisions between services that happen to share component names. +3. **Rewrite `$ref`s** — every `$ref` inside the spec (operation parameters, + request bodies, responses) is updated to point at the new namespaced name. +4. **Prefix paths** — every path key is prepended with the caller-specified + prefix (trailing slashes on the prefix are removed; the path's leading `/` + is preserved). +5. **Merge** — paths, components and tags are accumulated into a single output + `spec.OpenAPI` struct. Tags are de-duplicated by name. Security schemes + follow a last-writer-wins strategy so that a centralised gateway scheme can + override individual service schemes. +6. **Generate** — the merged spec is passed to `generator.Generate` exactly as + a regular `climate generate` would; the resulting CLI binary works against + all services. + +## Authentication + +Security schemes from all input specs are merged into the composed document. +The recommended approach for a real gateway is to pass a shared Bearer-token +scheme via the first or only input that defines one, letting the gateway +validate it and forward downstream with service-account tokens. Alternatively, +pass a custom `--auth` header per sub-command using the generated CLI's +persistent flags. + +## Component namespacing example + +Given two services that each define a `components/schemas/Error` schema: + +| Service | Original ref | Namespaced ref | +|---------|-------------|----------------| +| `/api/orders` | `#/components/schemas/Error` | `#/components/schemas/api-orders-Error` | +| `/api/users` | `#/components/schemas/Error` | `#/components/schemas/api-users-Error` | + +Both schemas are preserved in the merged document without conflict. + +## Limitations + +- 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. diff --git a/docs/design-mock.md b/docs/design-mock.md new file mode 100644 index 0000000..b667dc9 --- /dev/null +++ b/docs/design-mock.md @@ -0,0 +1,122 @@ +# Design: Local API Mock Server (`climate mock`) + +## Overview + +`climate mock` starts a local HTTP server that serves synthetic JSON responses +for every endpoint defined in an OpenAPI 3.x specification. It acts as a +**simulator** of the real service, letting developers and agents work against a +live HTTP interface without hitting production, without credentials, and without +side-effects. + +## Motivation + +During development and testing, APIs are often unavailable because: + +- The service is deployed only in a remote environment. +- Credentials or network access are restricted. +- Calling the real service would produce irreversible side-effects (billing, + e-mails, database writes). +- The service doesn't exist yet (spec-first / design-first workflow). + +A mock server derived directly from the OpenAPI spec gives developers a +realistic HTTP surface to code against immediately. + +## Usage + +```bash +# Default port 8080 +climate mock ./openapi.yaml + +# Custom port +climate mock --port 9090 https://petstore3.swagger.io/api/v3/openapi.json + +# Simulate network latency (milliseconds) +climate mock --latency 200 ./orders.yaml +``` + +On start-up the command prints a route table and the listen address: + +``` +Mock server for "Petstore" listening on http://localhost:8080 + +Routes: + DELETE /pets/{petId} + GET /pets + GET /pets/{petId} + POST /pets + +Press Ctrl+C to stop. +``` + +## Request Matching + +Paths are matched against incoming requests using regular expressions compiled +from the OpenAPI path templates. A path parameter placeholder like `{petId}` +becomes `[^/]+` in the pattern, matching any single path segment. Patterns +are sorted longest-first so that more specific paths take precedence. + +## Response Generation + +For each incoming request the server: + +1. Looks up the operation for the HTTP method. +2. Selects the first response with a 2xx status code (lowest code wins). +3. Extracts the JSON schema from the response's `content` map. +4. Recursively generates a synthetic value: + +| Schema type | Generated value | +|-------------|----------------| +| `object` | `{"field": , ...}` for each declared property | +| `array` | `[]` | +| `string` | `"example"` (or the first `enum` value if present) | +| `integer` | `1` | +| `number` | `1.0` | +| `boolean` | `true` | +| `$ref` | resolved and generated recursively (max depth 4) | +| unknown | `{}` | + +A recursion-depth guard (max 4) prevents infinite loops from self-referential +schemas. + +## Error responses + +| Condition | HTTP status | +|-----------|-------------| +| Path not registered | 404 Not Found | +| Method not defined for path | 405 Method Not Allowed (with `Allow` header) | +| No 2xx response in spec | 200 with `{}` | + +## Artificial Latency + +Pass `--latency ` to add a uniform sleep before every response. This is +useful for testing timeout handling and UI loading states. + +## Limitations + +- **No request validation** — the server accepts any request body regardless + of the schema. +- **No state** — each request is independent; there is no in-memory database. + POST / PUT / DELETE do not actually modify anything. +- **No auth enforcement** — security scheme requirements in the spec are + ignored. Add a reverse proxy or middleware if auth simulation is needed. +- **Static mock data** — responses are always the same synthetic values. Use + a dedicated contract-testing tool (e.g. Prism, WireMock) for dynamic, + example-driven mocks. +- **No WebSocket / SSE** — only regular HTTP/1.1 is supported. + +## Integration with `compose` + +`climate mock` works with composed specs. Generate a composite spec, write it +to a file, and pass it to `mock`: + +```bash +# Write the merged spec to a file first +climate compose orders.yaml:/api/orders users.yaml:/api/users \ + --no-build --out-dir /tmp/gateway-src + +# Then start a mock server against the merged spec +climate mock /tmp/gateway-spec.json +``` + +Alternatively, use the spec produced by `compose` directly via a temporary +file or pipe. diff --git a/internal/compose/compose.go b/internal/compose/compose.go new file mode 100644 index 0000000..88c679a --- /dev/null +++ b/internal/compose/compose.go @@ -0,0 +1,312 @@ +// Package compose merges multiple OpenAPI 3.x specifications into a single +// composite specification. Each source spec is assigned a path prefix so +// that all its routes are namespaced under that prefix in the merged document. +// The resulting spec can be fed directly to the generator to produce a single +// CLI that acts as a facade over several microservices. +// +// # Design +// +// ## Problem +// +// Large systems are commonly decomposed into microservices, each with its own +// OpenAPI spec. Developers and agents must juggle many separate CLIs or deal +// with raw HTTP calls to reach different services. A single "gateway" CLI +// that knows about all services is far more ergonomic. +// +// ## Approach +// +// compose.Merge takes a list of (source, prefix) pairs. For every source +// spec it: +// +// 1. Loads and validates the individual spec. +// 2. Rewrites every path key so that the given prefix is prepended. +// (e.g. "/pets" → "/v1/pets" when prefix is "/v1") +// 3. Namespaces every component schema, parameter and security scheme with +// a sanitised version of the prefix to avoid name collisions between +// services. +// 4. Rewrites intra-document $ref strings inside the spec to point at the +// new, namespaced component names. +// 5. Merges all paths, components and tags into a single OpenAPI document. +// +// Security schemes are merged from all input specs. The caller may choose a +// single unified auth strategy (e.g. a shared Bearer token gateway) or leave +// each service's scheme in place. +// +// ## Output +// +// Merge returns a *spec.OpenAPI value that is ready to be passed to +// generator.Generate. A convenience MergeToBytes helper also produces the +// canonical JSON serialisation, which is required by generator.Generate. +package compose + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/disk0Dancer/climate/internal/spec" +) + +// SpecInput describes one microservice spec that should be included in the +// composed output. +type SpecInput struct { + // Source is a file path or HTTP(S) URL pointing at an OpenAPI 3.x document. + Source string + // Prefix is a non-empty path prefix (e.g. "/orders/v1") prepended to every + // path defined in Source. It must start with "/". + Prefix string +} + +// Options controls how the merge is performed. +type Options struct { + // Title is the info.title of the merged spec. Defaults to "Composed API". + Title string + // Version is the info.version of the merged spec. Defaults to "1.0.0". + Version string + // Description is the info.description of the merged spec. + Description string + // Servers are the server entries written to the merged spec. When empty + // the servers of the first input spec are used. + Servers []spec.Server +} + +// Merge loads each input spec, applies the configured prefix to all of its +// paths, namespaces its component names, and merges everything into one +// OpenAPI document. +func Merge(inputs []SpecInput, opts Options) (*spec.OpenAPI, error) { + if len(inputs) == 0 { + return nil, fmt.Errorf("compose: at least one spec input is required") + } + + if opts.Title == "" { + opts.Title = "Composed API" + } + if opts.Version == "" { + opts.Version = "1.0.0" + } + + out := &spec.OpenAPI{ + OpenAPI: "3.0.0", + Info: spec.Info{ + Title: opts.Title, + Version: opts.Version, + Description: opts.Description, + }, + Paths: make(map[string]spec.PathItem), + Components: spec.Components{ + SecuritySchemes: make(map[string]spec.SecurityScheme), + Schemas: make(map[string]*spec.Schema), + Parameters: make(map[string]spec.Parameter), + }, + } + + for i, inp := range inputs { + if inp.Source == "" { + return nil, fmt.Errorf("compose: input %d has empty source", i) + } + if err := validatePrefix(inp.Prefix); err != nil { + return nil, fmt.Errorf("compose: input %d (%s): %w", i, inp.Source, err) + } + + s, err := spec.Load(inp.Source) + if err != nil { + return nil, fmt.Errorf("compose: loading %s: %w", inp.Source, err) + } + + ns := prefixToNamespace(inp.Prefix) + mergeSpec(out, s, inp.Prefix, ns) + + // Use the first spec's servers when the caller did not supply any. + if i == 0 && len(opts.Servers) == 0 && len(s.Servers) > 0 { + out.Servers = s.Servers + } + } + + if len(opts.Servers) > 0 { + out.Servers = opts.Servers + } + + return out, nil +} + +// MergeToBytes calls Merge and then JSON-encodes the result. The returned +// bytes can be passed as the rawSpec argument to generator.Generate. +func MergeToBytes(inputs []SpecInput, opts Options) (*spec.OpenAPI, []byte, error) { + merged, err := Merge(inputs, opts) + if err != nil { + return nil, nil, err + } + raw, err := json.Marshal(merged) + if err != nil { + return nil, nil, fmt.Errorf("compose: serialising merged spec: %w", err) + } + return merged, raw, nil +} + +// validatePrefix checks that prefix is non-empty and starts with "/". +func validatePrefix(prefix string) error { + if prefix == "" { + return fmt.Errorf("prefix must not be empty") + } + if !strings.HasPrefix(prefix, "/") { + return fmt.Errorf("prefix %q must start with '/'", prefix) + } + return nil +} + +// prefixToNamespace converts a path prefix like "/orders/v1" to a safe +// component-name namespace like "orders-v1". +func prefixToNamespace(prefix string) string { + ns := strings.TrimPrefix(prefix, "/") + ns = strings.ReplaceAll(ns, "/", "-") + ns = strings.ReplaceAll(ns, "_", "-") + // Keep only alphanumeric and hyphens. + var b strings.Builder + for _, r := range ns { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { + b.WriteRune(r) + } + } + result := strings.Trim(b.String(), "-") + if result == "" { + return "svc" + } + return result +} + +// mergeSpec copies the paths and components of src into dst, applying the +// given prefix to all paths and namespacing component names with ns. +func mergeSpec(dst, src *spec.OpenAPI, prefix, ns string) { + // Namespace component schemas. + schemaMap := make(map[string]string) // oldName → newName + for name, schema := range src.Components.Schemas { + newName := ns + "-" + name + schemaMap[name] = newName + dst.Components.Schemas[newName] = rewriteSchemaRefs(schema, schemaMap, ns) + } + + // Namespace component parameters. + paramMap := make(map[string]string) + for name, param := range src.Components.Parameters { + newName := ns + "-" + name + paramMap[name] = newName + p := param + p.Schema = rewriteSchemaRefs(p.Schema, schemaMap, ns) + dst.Components.Parameters[newName] = p + } + + // Merge security schemes (last writer wins for same name). + for name, scheme := range src.Components.SecuritySchemes { + dst.Components.SecuritySchemes[name] = scheme + } + + // Merge tags (de-duplicate by name). + existingTags := make(map[string]bool) + for _, t := range dst.Tags { + existingTags[t.Name] = true + } + for _, t := range src.Tags { + if !existingTags[t.Name] { + dst.Tags = append(dst.Tags, t) + existingTags[t.Name] = true + } + } + + // Prefix and copy paths. + normalised := strings.TrimRight(prefix, "/") + for path, item := range src.Paths { + newPath := normalised + path + dst.Paths[newPath] = rewritePathItem(item, schemaMap, paramMap, ns) + } +} + +// rewritePathItem rewrites all $ref strings inside a PathItem's operations. +func rewritePathItem(item spec.PathItem, schemaMap, paramMap map[string]string, ns string) spec.PathItem { + item.Get = rewriteOp(item.Get, schemaMap, paramMap, ns) + item.Post = rewriteOp(item.Post, schemaMap, paramMap, ns) + item.Put = rewriteOp(item.Put, schemaMap, paramMap, ns) + item.Patch = rewriteOp(item.Patch, schemaMap, paramMap, ns) + item.Delete = rewriteOp(item.Delete, schemaMap, paramMap, ns) + item.Head = rewriteOp(item.Head, schemaMap, paramMap, ns) + item.Options = rewriteOp(item.Options, schemaMap, paramMap, ns) + return item +} + +// rewriteOp rewrites $ref strings inside an operation, or returns nil if op +// is nil. +func rewriteOp(op *spec.Operation, schemaMap, paramMap map[string]string, ns string) *spec.Operation { + if op == nil { + return nil + } + rewritten := rewriteOperation(*op, schemaMap, paramMap, ns) + return &rewritten +} + +// rewriteOperation rewrites $ref strings inside an operation. +func rewriteOperation(op spec.Operation, schemaMap, paramMap map[string]string, ns string) spec.Operation { + for i, p := range op.Parameters { + if p.Ref != "" { + op.Parameters[i].Ref = rewriteRef(p.Ref, "#/components/parameters/", paramMap, ns) + } + op.Parameters[i].Schema = rewriteSchemaRefs(p.Schema, schemaMap, ns) + } + if op.RequestBody != nil { + rb := *op.RequestBody + newContent := make(map[string]spec.MediaType, len(rb.Content)) + for mediaType, mt := range rb.Content { + mt.Schema = rewriteSchemaRefs(mt.Schema, schemaMap, ns) + newContent[mediaType] = mt + } + rb.Content = newContent + op.RequestBody = &rb + } + newResponses := make(map[string]spec.Response, len(op.Responses)) + for code, resp := range op.Responses { + newContent := make(map[string]spec.MediaType, len(resp.Content)) + for mediaType, mt := range resp.Content { + mt.Schema = rewriteSchemaRefs(mt.Schema, schemaMap, ns) + newContent[mediaType] = mt + } + resp.Content = newContent + newResponses[code] = resp + } + op.Responses = newResponses + return op +} + +// rewriteSchemaRefs rewrites $ref fields inside a Schema tree. +func rewriteSchemaRefs(s *spec.Schema, schemaMap map[string]string, ns string) *spec.Schema { + if s == nil { + return nil + } + out := *s + if out.Ref != "" { + out.Ref = rewriteRef(out.Ref, "#/components/schemas/", schemaMap, ns) + } + if out.Items != nil { + out.Items = rewriteSchemaRefs(out.Items, schemaMap, ns) + } + if len(out.Properties) > 0 { + newProps := make(map[string]*spec.Schema, len(out.Properties)) + for k, v := range out.Properties { + newProps[k] = rewriteSchemaRefs(v, schemaMap, ns) + } + out.Properties = newProps + } + return &out +} + +// rewriteRef rewrites a $ref string that uses the given prefix. +// nameMap maps the old bare name to the new namespaced name. +func rewriteRef(ref, prefix string, nameMap map[string]string, ns string) string { + if !strings.HasPrefix(ref, prefix) { + return ref + } + oldName := strings.TrimPrefix(ref, prefix) + if newName, ok := nameMap[oldName]; ok { + return prefix + newName + } + // Fall back: namespace the name even if it was not found in the map + // (e.g. inline schemas referenced before being declared). + return prefix + ns + "-" + oldName +} diff --git a/internal/compose/compose_test.go b/internal/compose/compose_test.go new file mode 100644 index 0000000..56a4a9f --- /dev/null +++ b/internal/compose/compose_test.go @@ -0,0 +1,180 @@ +package compose_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/disk0Dancer/climate/internal/compose" + "github.com/disk0Dancer/climate/internal/spec" +) + +// writeTempSpec writes a minimal OpenAPI JSON spec to a temp file and returns +// its path. +func writeTempSpec(t *testing.T, title, version string, paths map[string]spec.PathItem) string { + t.Helper() + s := spec.OpenAPI{ + OpenAPI: "3.0.0", + Info: spec.Info{Title: title, Version: version}, + Paths: paths, + } + data, err := json.Marshal(s) + if err != nil { + t.Fatalf("marshal spec: %v", err) + } + f := filepath.Join(t.TempDir(), "spec.json") + if err := os.WriteFile(f, data, 0o644); err != nil { + t.Fatalf("write spec: %v", err) + } + return f +} + +func TestMerge_PathPrefixing(t *testing.T) { + src1 := writeTempSpec(t, "Orders", "1.0.0", map[string]spec.PathItem{ + "/orders": {Get: &spec.Operation{OperationID: "list_orders"}}, + }) + src2 := writeTempSpec(t, "Users", "1.0.0", map[string]spec.PathItem{ + "/users": {Get: &spec.Operation{OperationID: "list_users"}}, + }) + + merged, err := compose.Merge([]compose.SpecInput{ + {Source: src1, Prefix: "/api/v1"}, + {Source: src2, Prefix: "/api/v2"}, + }, compose.Options{Title: "Gateway", Version: "1.0.0"}) + + if err != nil { + t.Fatalf("Merge() error = %v", err) + } + + if _, ok := merged.Paths["/api/v1/orders"]; !ok { + t.Error("expected path /api/v1/orders in merged spec") + } + if _, ok := merged.Paths["/api/v2/users"]; !ok { + t.Error("expected path /api/v2/users in merged spec") + } + if _, ok := merged.Paths["/orders"]; ok { + t.Error("original unprefixed path /orders should not appear in merged spec") + } +} + +func TestMerge_MetaDefaults(t *testing.T) { + src := writeTempSpec(t, "Svc", "1.0.0", map[string]spec.PathItem{ + "/ping": {Get: &spec.Operation{OperationID: "ping"}}, + }) + + merged, err := compose.Merge([]compose.SpecInput{{Source: src, Prefix: "/svc"}}, + compose.Options{}) + if err != nil { + t.Fatalf("Merge() error = %v", err) + } + if merged.Info.Title != "Composed API" { + t.Errorf("default title = %q, want %q", merged.Info.Title, "Composed API") + } + if merged.Info.Version != "1.0.0" { + t.Errorf("default version = %q, want %q", merged.Info.Version, "1.0.0") + } +} + +func TestMerge_ComponentNamespacing(t *testing.T) { + s := spec.OpenAPI{ + OpenAPI: "3.0.0", + Info: spec.Info{Title: "A", Version: "1.0.0"}, + Paths: map[string]spec.PathItem{ + "/items": {Get: &spec.Operation{OperationID: "listItems"}}, + }, + Components: spec.Components{ + Schemas: map[string]*spec.Schema{ + "Item": {Type: "object"}, + }, + }, + } + data, _ := json.Marshal(s) + f := filepath.Join(t.TempDir(), "a.json") + _ = os.WriteFile(f, data, 0o644) + + merged, err := compose.Merge([]compose.SpecInput{{Source: f, Prefix: "/svc-a"}}, + compose.Options{Title: "T", Version: "1.0.0"}) + if err != nil { + t.Fatalf("Merge() error = %v", err) + } + if _, ok := merged.Components.Schemas["svc-a-Item"]; !ok { + t.Error("expected namespaced schema svc-a-Item") + } + if _, ok := merged.Components.Schemas["Item"]; ok { + t.Error("un-namespaced schema Item should not appear in merged spec") + } +} + +func TestMerge_NoInputs(t *testing.T) { + _, err := compose.Merge(nil, compose.Options{}) + if err == nil { + t.Error("expected error for empty inputs, got nil") + } +} + +func TestMerge_BadPrefix(t *testing.T) { + src := writeTempSpec(t, "X", "1.0.0", map[string]spec.PathItem{ + "/x": {Get: &spec.Operation{OperationID: "x"}}, + }) + _, err := compose.Merge([]compose.SpecInput{{Source: src, Prefix: "noslash"}}, + compose.Options{}) + if err == nil { + t.Error("expected error for prefix without leading slash") + } +} + +func TestMergeToBytes(t *testing.T) { + src := writeTempSpec(t, "B", "1.0.0", map[string]spec.PathItem{ + "/b": {Get: &spec.Operation{OperationID: "b"}}, + }) + merged, raw, err := compose.MergeToBytes([]compose.SpecInput{{Source: src, Prefix: "/b"}}, + compose.Options{Title: "B-Facade", Version: "1.0.0"}) + if err != nil { + t.Fatalf("MergeToBytes() error = %v", err) + } + if merged == nil { + t.Fatal("expected non-nil merged spec") + } + if len(raw) == 0 { + t.Error("expected non-empty raw bytes") + } + var check spec.OpenAPI + if err := json.Unmarshal(raw, &check); err != nil { + t.Errorf("raw bytes are not valid JSON: %v", err) + } +} + +func TestMerge_TagDeduplication(t *testing.T) { + makeSpec := func(title string, tag spec.Tag) string { + s := spec.OpenAPI{ + OpenAPI: "3.0.0", + Info: spec.Info{Title: title, Version: "1.0.0"}, + Paths: map[string]spec.PathItem{"/x": {Get: &spec.Operation{OperationID: title}}}, + Tags: []spec.Tag{tag}, + } + data, _ := json.Marshal(s) + f := filepath.Join(t.TempDir(), "s.json") + _ = os.WriteFile(f, data, 0o644) + return f + } + src1 := makeSpec("A", spec.Tag{Name: "shared", Description: "first"}) + src2 := makeSpec("B", spec.Tag{Name: "shared", Description: "second"}) + + merged, err := compose.Merge([]compose.SpecInput{ + {Source: src1, Prefix: "/a"}, + {Source: src2, Prefix: "/b"}, + }, compose.Options{}) + if err != nil { + t.Fatalf("Merge() error = %v", err) + } + count := 0 + for _, tg := range merged.Tags { + if tg.Name == "shared" { + count++ + } + } + if count != 1 { + t.Errorf("expected tag 'shared' to appear exactly once, got %d", count) + } +} diff --git a/internal/mock/mock.go b/internal/mock/mock.go new file mode 100644 index 0000000..aab1dc9 --- /dev/null +++ b/internal/mock/mock.go @@ -0,0 +1,307 @@ +// Package mock provides a local HTTP mock server that serves synthetic +// responses for every endpoint defined in an OpenAPI 3.x specification. +// +// # Design +// +// ## Problem +// +// During development and testing, developers often need to work against APIs +// that are unavailable — because the real service is slow, requires special +// credentials, produces side-effects, or simply doesn't exist yet. Standing +// up a full service just to test a CLI or a frontend is expensive. +// +// ## Approach +// +// mock.Server reads an OpenAPI spec and registers one HTTP handler per path. +// When a request arrives the server: +// +// 1. Matches the request path against each registered pattern, resolving +// path parameters (e.g. /pets/{petId}). +// 2. Looks up the operation's first successful response (2xx) and its schema. +// 3. Generates a synthetic JSON value that conforms to the schema — objects +// get all declared properties with placeholder values, arrays get one +// example element, scalars get type-appropriate zero values. +// 4. Returns the value with the appropriate HTTP status code and +// Content-Type: application/json header. +// +// An optional artificial latency can be configured to simulate realistic +// network conditions. +// +// ## Supported response types +// +// - object → {"field": , ...} +// - array → [] +// - string → "example" +// - integer → 1 +// - number → 1.0 +// - boolean → true +// - (unknown/empty) → {} +// +// ## Not in scope +// +// Request body validation, stateful CRUD simulation, and auth enforcement are +// intentionally out of scope for the mock server. Use a dedicated contract- +// testing tool (e.g. Prism, WireMock) when those features are needed. +package mock + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/disk0Dancer/climate/internal/spec" +) + +// Server is a local HTTP mock server driven by an OpenAPI specification. +type Server struct { + openAPI *spec.OpenAPI + addr string + latency time.Duration + mux *http.ServeMux + patterns []routePattern +} + +// routePattern holds a compiled path pattern and the operations it handles. +type routePattern struct { + raw string // original OpenAPI path, e.g. "/pets/{petId}" + re *regexp.Regexp // compiled pattern for matching request paths + handler http.HandlerFunc +} + +// NewServer creates a mock server for the given spec. addr is a TCP address +// such as ":8080". latency adds an artificial delay to every response. +func NewServer(openAPI *spec.OpenAPI, addr string, latency time.Duration) *Server { + s := &Server{ + openAPI: openAPI, + addr: addr, + latency: latency, + mux: http.NewServeMux(), + } + s.registerRoutes() + return s +} + +// Handler returns the underlying http.Handler so the server can be embedded +// in tests via httptest.NewServer. +func (s *Server) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.latency > 0 { + time.Sleep(s.latency) + } + // Try parametric patterns first (longest match first for specificity). + for _, pat := range s.patterns { + if pat.re.MatchString(r.URL.Path) { + pat.handler(w, r) + return + } + } + http.NotFound(w, r) + }) +} + +// ListenAndServe starts the HTTP server and blocks until it returns an error. +func (s *Server) ListenAndServe() error { + srv := &http.Server{ + Addr: s.addr, + Handler: s.Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + return srv.ListenAndServe() +} + +// Addr returns the configured listen address. +func (s *Server) Addr() string { + return s.addr +} + +// registerRoutes builds one handler per OpenAPI path. +func (s *Server) registerRoutes() { + // Sort paths so that more specific (longer) paths are matched first. + paths := make([]string, 0, len(s.openAPI.Paths)) + for p := range s.openAPI.Paths { + paths = append(paths, p) + } + sort.Slice(paths, func(i, j int) bool { + return len(paths[i]) > len(paths[j]) + }) + + for _, path := range paths { + item := s.openAPI.Paths[path] + re := pathToRegexp(path) + handler := s.makeHandler(path, item) + s.patterns = append(s.patterns, routePattern{raw: path, re: re, handler: handler}) + } +} + +// pathToRegexp converts an OpenAPI path template like "/pets/{petId}" to a +// regexp that matches actual request paths. +func pathToRegexp(path string) *regexp.Regexp { + escaped := regexp.QuoteMeta(path) + // Replace escaped \{name\} placeholders with a catch-all segment. + re := regexp.MustCompile(`\\\{[^}]+\\\}`) + pattern := re.ReplaceAllString(escaped, `[^/]+`) + return regexp.MustCompile(`^` + pattern + `$`) +} + +// makeHandler returns an http.HandlerFunc that serves mock responses for all +// HTTP methods defined on the given PathItem. +func (s *Server) makeHandler(path string, item spec.PathItem) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ops := item.Operations() + op, ok := ops[r.Method] + if !ok { + // Method not defined — return 405. + w.Header().Set("Allow", allowedMethods(ops)) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + statusCode, body := s.generateResponse(op) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _ = json.NewEncoder(w).Encode(body) + } +} + +// generateResponse picks the first 2xx response defined on the operation and +// produces a synthetic value matching its schema. +func (s *Server) generateResponse(op *spec.Operation) (int, interface{}) { + if op == nil { + return http.StatusOK, map[string]interface{}{} + } + + // Collect and sort response codes so we pick the lowest 2xx deterministically. + codes := make([]string, 0, len(op.Responses)) + for c := range op.Responses { + codes = append(codes, c) + } + sort.Strings(codes) + + for _, code := range codes { + statusCode := parseStatusCode(code) + if statusCode < 200 || statusCode >= 300 { + continue + } + resp := op.Responses[code] + schema := responseSchema(resp) + return statusCode, generateValue(schema, s.openAPI, 0) + } + + // No 2xx response defined — return 200 with an empty object. + return http.StatusOK, map[string]interface{}{} +} + +// responseSchema extracts the first JSON schema from a response's content map. +func responseSchema(resp spec.Response) *spec.Schema { + for _, mt := range resp.Content { + if mt.Schema != nil { + return mt.Schema + } + } + return nil +} + +// generateValue produces a Go value that conforms to the given schema. +// depth prevents infinite recursion for self-referential schemas. +func generateValue(s *spec.Schema, openAPI *spec.OpenAPI, depth int) interface{} { + const maxDepth = 4 + if depth > maxDepth { + return nil + } + if s == nil { + return map[string]interface{}{} + } + + // Resolve $ref. + if s.Ref != "" { + resolved := resolveRef(s.Ref, openAPI) + return generateValue(resolved, openAPI, depth+1) + } + + switch s.Type { + case "object": + obj := map[string]interface{}{} + for name, prop := range s.Properties { + obj[name] = generateValue(prop, openAPI, depth+1) + } + return obj + case "array": + elem := generateValue(s.Items, openAPI, depth+1) + return []interface{}{elem} + case "string": + if len(s.Enum) > 0 { + if str, ok := s.Enum[0].(string); ok { + return str + } + } + return "example" + case "integer": + return 1 + case "number": + return 1.0 + case "boolean": + return true + default: + return map[string]interface{}{} + } +} + +// resolveRef resolves a JSON reference like "#/components/schemas/Pet" to a +// Schema from the given OpenAPI document. +func resolveRef(ref string, openAPI *spec.OpenAPI) *spec.Schema { + const schemaPrefix = "#/components/schemas/" + if strings.HasPrefix(ref, schemaPrefix) { + name := strings.TrimPrefix(ref, schemaPrefix) + if s, ok := openAPI.Components.Schemas[name]; ok { + return s + } + } + return nil +} + +// allowedMethods returns a comma-separated string of HTTP methods that have +// operations defined. +func allowedMethods(ops map[string]*spec.Operation) string { + methods := make([]string, 0, len(ops)) + for m := range ops { + methods = append(methods, m) + } + sort.Strings(methods) + return strings.Join(methods, ", ") +} + +// parseStatusCode converts an OpenAPI response code string (e.g. "200", +// "default") to an integer. "default" maps to 200. +func parseStatusCode(code string) int { + if code == "default" { + return http.StatusOK + } + n, err := strconv.Atoi(code) + if err != nil { + return http.StatusOK + } + return n +} + +// Summary returns a human-readable table of all registered routes, one per +// line, formatted as "METHOD /path". +func (s *Server) Summary() string { + var sb strings.Builder + paths := make([]string, 0, len(s.openAPI.Paths)) + for p := range s.openAPI.Paths { + paths = append(paths, p) + } + sort.Strings(paths) + for _, path := range paths { + item := s.openAPI.Paths[path] + for method := range item.Operations() { + sb.WriteString(fmt.Sprintf(" %-7s %s\n", method, path)) + } + } + return sb.String() +} diff --git a/internal/mock/mock_test.go b/internal/mock/mock_test.go new file mode 100644 index 0000000..ba6ea78 --- /dev/null +++ b/internal/mock/mock_test.go @@ -0,0 +1,270 @@ +package mock_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/disk0Dancer/climate/internal/mock" + "github.com/disk0Dancer/climate/internal/spec" +) + +func petStoreSpec() *spec.OpenAPI { + return &spec.OpenAPI{ + OpenAPI: "3.0.0", + Info: spec.Info{Title: "Petstore", Version: "1.0.0"}, + Paths: map[string]spec.PathItem{ + "/pets": { + Get: &spec.Operation{ + OperationID: "listPets", + Responses: map[string]spec.Response{ + "200": { + Content: map[string]spec.MediaType{ + "application/json": { + Schema: &spec.Schema{ + Type: "array", + Items: &spec.Schema{Type: "object"}, + }, + }, + }, + }, + }, + }, + Post: &spec.Operation{ + OperationID: "createPet", + Responses: map[string]spec.Response{ + "201": { + Content: map[string]spec.MediaType{ + "application/json": { + Schema: &spec.Schema{Type: "object"}, + }, + }, + }, + }, + }, + }, + "/pets/{petId}": { + Get: &spec.Operation{ + OperationID: "getPet", + Responses: map[string]spec.Response{ + "200": { + Content: map[string]spec.MediaType{ + "application/json": { + Schema: &spec.Schema{ + Type: "object", + Properties: map[string]*spec.Schema{ + "id": {Type: "integer"}, + "name": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + Delete: &spec.Operation{ + OperationID: "deletePet", + Responses: map[string]spec.Response{ + "204": {}, + }, + }, + }, + }, + } +} + +func newTestServer(t *testing.T, openAPI *spec.OpenAPI) *httptest.Server { + t.Helper() + s := mock.NewServer(openAPI, ":0", 0) + ts := httptest.NewServer(s.Handler()) + t.Cleanup(ts.Close) + return ts +} + +func TestMock_GetList(t *testing.T) { + ts := newTestServer(t, petStoreSpec()) + resp, err := http.Get(ts.URL + "/pets") + if err != nil { + t.Fatalf("GET /pets: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + var body interface{} + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Errorf("decode body: %v", err) + } + arr, ok := body.([]interface{}) + if !ok { + t.Errorf("expected JSON array, got %T", body) + } + if len(arr) == 0 { + t.Error("expected at least one element in array response") + } +} + +func TestMock_GetByID(t *testing.T) { + ts := newTestServer(t, petStoreSpec()) + resp, err := http.Get(ts.URL + "/pets/42") + if err != nil { + t.Fatalf("GET /pets/42: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + var obj map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&obj); err != nil { + t.Fatalf("decode: %v", err) + } + if _, ok := obj["id"]; !ok { + t.Error("response object missing 'id' field") + } + if _, ok := obj["name"]; !ok { + t.Error("response object missing 'name' field") + } +} + +func TestMock_Post(t *testing.T) { + ts := newTestServer(t, petStoreSpec()) + resp, err := http.Post(ts.URL+"/pets", "application/json", + strings.NewReader(`{"name":"Fido"}`)) + if err != nil { + t.Fatalf("POST /pets: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Errorf("status = %d, want 201", resp.StatusCode) + } +} + +func TestMock_Delete(t *testing.T) { + ts := newTestServer(t, petStoreSpec()) + req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/pets/1", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("DELETE /pets/1: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Errorf("status = %d, want 204", resp.StatusCode) + } +} + +func TestMock_MethodNotAllowed(t *testing.T) { + ts := newTestServer(t, petStoreSpec()) + req, _ := http.NewRequest(http.MethodPut, ts.URL+"/pets", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("PUT /pets: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", resp.StatusCode) + } +} + +func TestMock_NotFound(t *testing.T) { + ts := newTestServer(t, petStoreSpec()) + resp, err := http.Get(ts.URL + "/nonexistent") + if err != nil { + t.Fatalf("GET /nonexistent: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("status = %d, want 404", resp.StatusCode) + } +} + +func TestMock_Latency(t *testing.T) { + s := mock.NewServer(petStoreSpec(), ":0", 50*time.Millisecond) + ts := httptest.NewServer(s.Handler()) + defer ts.Close() + + start := time.Now() + resp, err := http.Get(ts.URL + "/pets") + if err != nil { + t.Fatalf("GET /pets: %v", err) + } + resp.Body.Close() + elapsed := time.Since(start) + if elapsed < 50*time.Millisecond { + t.Errorf("expected at least 50ms latency, got %v", elapsed) + } +} + +func TestMock_Summary(t *testing.T) { + s := mock.NewServer(petStoreSpec(), ":8080", 0) + summary := s.Summary() + if summary == "" { + t.Error("expected non-empty summary") + } + // Should contain both paths and methods + if !strings.Contains(summary, "/pets") { + t.Error("summary should mention /pets") + } +} + +func TestMock_ContentTypeJSON(t *testing.T) { + ts := newTestServer(t, petStoreSpec()) + resp, err := http.Get(ts.URL + "/pets") + if err != nil { + t.Fatalf("GET /pets: %v", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + ct := resp.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "application/json") { + t.Errorf("Content-Type = %q, want application/json; body: %s", ct, body) + } +} + +func TestMock_SchemaWithRef(t *testing.T) { + openAPI := &spec.OpenAPI{ + OpenAPI: "3.0.0", + Info: spec.Info{Title: "T", Version: "1.0.0"}, + Paths: map[string]spec.PathItem{ + "/items": { + Get: &spec.Operation{ + Responses: map[string]spec.Response{ + "200": {Content: map[string]spec.MediaType{ + "application/json": {Schema: &spec.Schema{ + Ref: "#/components/schemas/Item", + }}, + }}, + }, + }, + }, + }, + Components: spec.Components{ + Schemas: map[string]*spec.Schema{ + "Item": { + Type: "object", + Properties: map[string]*spec.Schema{ + "id": {Type: "integer"}, + "name": {Type: "string"}, + }, + }, + }, + }, + } + + ts := newTestServer(t, openAPI) + resp, err := http.Get(ts.URL + "/items") + if err != nil { + t.Fatalf("GET /items: %v", err) + } + defer resp.Body.Close() + var obj map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&obj); err != nil { + t.Fatalf("decode: %v", err) + } + if _, ok := obj["id"]; !ok { + t.Error("resolved $ref: response missing 'id'") + } +} From bca712dee50977ab9885c1a3e0415f97b139615b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:20:33 +0000 Subject: [PATCH 02/10] fix: address code review feedback on compose and mock Agent-Logs-Url: https://github.com/disk0Dancer/climate/sessions/6163d378-07f4-4b75-804c-ed5506a7c703 Co-authored-by: disk0Dancer <89835485+disk0Dancer@users.noreply.github.com> --- cmd/climate/commands/compose.go | 2 +- internal/compose/compose.go | 5 +---- internal/mock/mock.go | 5 ++++- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/climate/commands/compose.go b/cmd/climate/commands/compose.go index 04dad9b..ba7ba07 100644 --- a/cmd/climate/commands/compose.go +++ b/cmd/climate/commands/compose.go @@ -132,7 +132,7 @@ func parseSpecInputs(args []string) ([]compose.SpecInput, error) { func buildSpecSourceLabel(inputs []compose.SpecInput) string { parts := make([]string, len(inputs)) for i, inp := range inputs { - parts[i] = inp.Source + inp.Prefix + parts[i] = inp.Source + "@" + inp.Prefix } return "compose:[" + strings.Join(parts, ",") + "]" } diff --git a/internal/compose/compose.go b/internal/compose/compose.go index 88c679a..6195c54 100644 --- a/internal/compose/compose.go +++ b/internal/compose/compose.go @@ -143,11 +143,8 @@ func MergeToBytes(inputs []SpecInput, opts Options) (*spec.OpenAPI, []byte, erro return merged, raw, nil } -// validatePrefix checks that prefix is non-empty and starts with "/". +// validatePrefix checks that prefix starts with "/". func validatePrefix(prefix string) error { - if prefix == "" { - return fmt.Errorf("prefix must not be empty") - } if !strings.HasPrefix(prefix, "/") { return fmt.Errorf("prefix %q must start with '/'", prefix) } diff --git a/internal/mock/mock.go b/internal/mock/mock.go index aab1dc9..49b98e8 100644 --- a/internal/mock/mock.go +++ b/internal/mock/mock.go @@ -164,7 +164,10 @@ func (s *Server) makeHandler(path string, item spec.PathItem) http.HandlerFunc { statusCode, body := s.generateResponse(op) w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) - _ = json.NewEncoder(w).Encode(body) + if encErr := json.NewEncoder(w).Encode(body); encErr != nil { + // The header is already written; log to stderr as best effort. + fmt.Fprintf(w, `{"error":"response encoding failed"}`) + } } } From bb003c1310132f96e00ba2ef89ffd4da1a9db0b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:58:04 +0000 Subject: [PATCH 03/10] docs: update skills and add OpenAPI support roadmap with contribution guide Agent-Logs-Url: https://github.com/disk0Dancer/climate/sessions/de38ce8b-9d81-498c-934f-97dbc7d756b5 Co-authored-by: disk0Dancer <89835485+disk0Dancer@users.noreply.github.com> --- AGENTS.md | 48 ++++++++++++++++++ README.md | 5 ++ docs/index.md | 8 +++ docs/openapi-3-support-matrix.md | 83 +++++++++++++++++++++++++++++++ skills/climate-generator/SKILL.md | 39 +++++++++++++-- skills/climate.md | 41 +++++++++++++-- 6 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/openapi-3-support-matrix.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8630fd3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# AGENTS contribution workflow + +This repository uses an implementation-first workflow for all feature work. + +## Required sequence for any feature + +1. **Design first** + - Describe problem, goals, non-goals, API/CLI UX, and edge cases. + - Add or update design docs in `docs/`. +2. **Document behavior** + - Update user-facing docs (`README.md`, `docs/index.md`) when commands or + capabilities change. +3. **Write tests** + - Add targeted unit tests for new logic before/with implementation. +4. **Implement code** + - Keep changes surgical and consistent with existing project style. +5. **Update skills** + - Update `skills/climate.md` and `skills/climate-generator/SKILL.md` when + command set or workflows change. +6. **Validate locally** + - Run: + - `go build ./...` + - `go test ./...` + - Run targeted tests during development for faster feedback. +7. **Validate CI health** + - Ensure PR checks are green before merge. +8. **Commit discipline** + - Small, meaningful commits with clear messages. +9. **Push and PR hygiene** + - Push branch updates, keep PR description/checklist current, and respond to + review comments with the commit hash that addresses each request. + +## Quality rules + +- Do not remove or weaken unrelated tests. +- Do not introduce breaking CLI changes without docs + migration notes. +- Prefer deterministic behavior (sorted output, stable iteration). +- Keep generated/manifest behavior backward compatible where practical. + +## Feature checklist template + +- [ ] Design doc added/updated +- [ ] README/docs updated +- [ ] Skills updated +- [ ] Tests added/updated +- [ ] `go build ./...` passes +- [ ] `go test ./...` passes +- [ ] CI checks green diff --git a/README.md b/README.md index 94dc6c2..fb3a9d6 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ Demo: [disk0Dancer/github](https://github.com/disk0Dancer/github) — 1 100+ end | Command | Purpose | |---|---| | `generate` | Create CLI from OpenAPI spec | +| `compose` | Merge multiple specs (with prefixes) into one facade CLI | +| `mock` | Run local mock HTTP server from OpenAPI spec | | `list` | Show registered CLIs | | `remove` | Delete a generated CLI | | `upgrade` | Regenerate from updated spec | @@ -69,6 +71,9 @@ Demo: [disk0Dancer/github](https://github.com/disk0Dancer/github) — 1 100+ end - [Site](https://disk0dancer.github.io/climate/) - [LLM index](https://disk0dancer.github.io/climate/llms.txt) +- [Compose design](docs/design-compose.md) +- [Mock design](docs/design-mock.md) +- [OpenAPI 3.0 support matrix](docs/openapi-3-support-matrix.md) ## Development diff --git a/docs/index.md b/docs/index.md index 2bcd887..dd10e9f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -42,6 +42,8 @@ petstore pet get --pet-id 1 | Command | Purpose | |---|---| | `generate` | Create CLI from OpenAPI spec | +| `compose` | Merge multiple specs (with prefixes) into one facade CLI | +| `mock` | Run local mock HTTP server from OpenAPI spec | | `list` | Show registered CLIs | | `remove` | Delete a generated CLI | | `upgrade` | Regenerate from updated spec | @@ -61,6 +63,12 @@ npx skills add https://github.com/disk0Dancer/climate --skill climate-generator - [llms.txt](./llms.txt) - [robots.txt](./robots.txt) +## Design docs + +- [Compose design](./design-compose.md) +- [Mock design](./design-mock.md) +- [OpenAPI 3.0 support matrix](./openapi-3-support-matrix.md) + ## License Apache-2.0 diff --git a/docs/openapi-3-support-matrix.md b/docs/openapi-3-support-matrix.md new file mode 100644 index 0000000..17438ad --- /dev/null +++ b/docs/openapi-3-support-matrix.md @@ -0,0 +1,83 @@ +# OpenAPI 3.0 support matrix (current + planned) + +This document summarizes what `climate` already supports when generating CLIs, +what is partially supported, and what should be designed/implemented next. + +## Scope + +- OpenAPI version: 3.x (3.0 / 3.1 input accepted by parser/validator) +- Main commands affected: `generate`, `compose`, `mock`, `skill generate` + +## Matrix + +| OpenAPI feature | Status | Current behavior | Next step | +|---|---|---|---| +| `info`, `paths`, HTTP methods | ✅ Implemented | Used as core input for command tree generation | Keep stable | +| Path/query/header parameters | ✅ Implemented | Converted to CLI flags | Add richer validations from schema constraints | +| Request body (`application/json`) | ✅ Implemented | Supported via generated payload flags | Add optional strict schema validation before send | +| Response handling | ✅ Implemented | Structured output + generated error envelope | Add per-status typed formatting hooks | +| Auth: API key / bearer / basic / OAuth2 | ✅ Implemented | Mapped to generated auth flags/env vars | Add multi-scheme policy docs | +| Tags → command groups | ✅ Implemented | Tag-based group hierarchy | Add optional custom grouping strategies | +| `components.schemas` + `$ref` | ✅ Implemented | Used in generator and mock response synthesis | Improve support for schema combiners | +| Multi-spec composition (`compose`) | ✅ Implemented | Path prefixing + component namespacing + merge | Add callbacks/webhooks merge policy | +| 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` | ❌ Planned | Not mapped to CLI surface yet | Add event command model (`events subscribe`/`events trigger`) | +| `webhooks` (3.1) | ❌ Planned | Not exposed by generator/mocks yet | 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 | + +## Webhooks and event APIs — proposed behavior + +Some APIs are event-driven and include webhooks/callbacks instead of (or in +addition to) plain request/response endpoints. + +Proposed direction for generated CLIs: + +1. **Expose webhook declarations as event commands** + - `myapi events list` + - `myapi events emit --data-json ...` (test mode) +2. **Support local receiver** + - `myapi events listen --port 8081` to receive and inspect payloads +3. **Support production replay/import** + - `myapi events import --file payload.json --event ` + - `myapi events replay --source prod-export.ndjson` +4. **Compose awareness** + - In `compose`, namespace event names with prefix (same as path/components) +5. **Mock integration** + - `climate mock` can emit synthetic webhook payloads at intervals or on + demand for integration tests + +## Pagination — proposed behavior + +Pagination should be generated as a first-class UX pattern, regardless of API +style: + +- Page/pageSize style (`page`, `size`, `limit`, `offset`) +- Cursor style (`cursor`, `next`, `before`, `after`) +- Token style (`nextPageToken`) +- Link-header style (`Link: <...>; rel="next"`) + +Proposed CLI conventions: + +- `--all` to auto-fetch all pages safely +- `--max-items ` as guardrail +- `--page-size ` override when supported +- `--starting-token ` for resume +- `--pagination-debug` to print paging metadata + +Safety defaults: + +- `--all` should require confirmation for very large transfers unless + `--yes` is set +- bounded retries/backoff for transient failures + +## Prioritized implementation roadmap + +1. Pagination abstraction + generated paging flags (`--all`, `--max-items`) +2. `callbacks` support in generator command tree +3. `webhooks` support + local listener/emitter helpers +4. examples-first generation mode (payloads + mock responses) +5. advanced schema combiner normalization (`allOf`/`oneOf`/`anyOf`) diff --git a/skills/climate-generator/SKILL.md b/skills/climate-generator/SKILL.md index 58d20e5..c84fdbc 100644 --- a/skills/climate-generator/SKILL.md +++ b/skills/climate-generator/SKILL.md @@ -20,15 +20,19 @@ clients from OpenAPI specifications and can emit Markdown prompts for agent skil - The user has an OpenAPI 3.x URL or file and wants a CLI quickly. - The user wants a human-usable API client rather than writing SDK glue code. - The user wants to turn a generated CLI into a reusable agent skill. +- The user wants to compose multiple microservice specs into one facade CLI. +- The user wants a local OpenAPI simulator (mock server) for testing. - The user wants to list, remove, or upgrade a previously generated CLI. ## Core workflow 1. Generate a CLI from the provided spec. -2. Capture the resulting `cli_name`, `binary_path`, and `source_dir`. -3. If the user wants agent integration, run `climate skill generate `. -4. If the user wants the CLI managed on GitHub, run `climate publish `. -5. Follow the generated instructions from that Markdown prompt. +2. If user provides multiple specs, use `climate compose` instead of `climate generate`. +3. Capture the resulting `cli_name`, `binary_path`, and `source_dir`. +4. If the user wants agent integration, run `climate skill generate `. +5. If the user needs sandbox/simulator behavior, run `climate mock `. +6. If the user wants the CLI managed on GitHub, run `climate publish `. +7. Follow the generated instructions from that Markdown prompt. ## Commands @@ -60,6 +64,21 @@ Success output is JSON: climate list ``` +### Compose multiple OpenAPI specs into one CLI + +```bash +climate compose [--name ] [--out-dir ] [--no-build] [--force] [--title ] [--api-version <version>] [--description <text>] <spec1>:<prefix1> [<spec2>:<prefix2> ...] +``` + +Each positional argument is `<spec>:<prefix>` where `<spec>` can be a local +path or URL and `<prefix>` starts with `/`. + +### Start local mock server + +```bash +climate mock [--port <port>] [--latency <ms>] <openapi_spec> +``` + ### Remove a generated CLI ```bash @@ -101,6 +120,18 @@ Generate a CLI: climate generate --name petstore https://petstore3.swagger.io/api/v3/openapi.json ``` +Compose two service specs into one facade CLI: + +```bash +climate compose orders.yaml:/api/orders users.yaml:/api/users --name gateway +``` + +Run a local mock server: + +```bash +climate mock --port 9090 --latency 150 https://petstore3.swagger.io/api/v3/openapi.json +``` + Generate a compact skill prompt for that CLI: ```bash diff --git a/skills/climate.md b/skills/climate.md index e2cdb1a..caed94f 100644 --- a/skills/climate.md +++ b/skills/climate.md @@ -9,6 +9,8 @@ so you can self-register those CLIs as new skills. ## What you can do - Generate a typed Go CLI from any OpenAPI spec (URL or local file). +- Compose several OpenAPI specs into one facade CLI with per-spec path prefixes. +- Run a local OpenAPI-based mock HTTP server for simulator/sandbox workflows. - List all CLIs you have already generated. - Get a plain-text skill prompt for any generated CLI so you can self-register it. - Publish a generated CLI into a GitHub repository with lifecycle bootstrap. @@ -46,6 +48,33 @@ climate generate [--name <cli-name>] [--out-dir <dir>] [--no-build] [--force] <o --- +### Compose multiple specs into one facade CLI + +``` +climate compose [--name <cli-name>] [--out-dir <dir>] [--no-build] [--force] [--title <title>] [--api-version <version>] [--description <text>] <spec1>:<prefix1> [<spec2>:<prefix2> ...] +``` + +Use this for multi-microservice setups where each service has its own spec. +Each input is `<spec>:<prefix>`, for example: + +``` +climate compose orders.yaml:/api/orders users.yaml:/api/users +climate compose https://orders.svc/openapi.json:/orders https://users.svc/openapi.json:/users +``` + +--- + +### Run a local mock server from an OpenAPI spec + +``` +climate mock [--port <port>] [--latency <ms>] <openapi_spec> +``` + +Starts a local simulator server that serves synthetic responses from response +schemas in the OpenAPI spec. Useful for local development and agent testing. + +--- + ### List generated CLIs ``` @@ -130,11 +159,13 @@ On error commands exit non-zero and print to stderr: ## Typical agent workflow 1. User provides an OpenAPI spec URL or file path. -2. Run `climate generate <url>` → note the `cli_name` in the JSON response. -3. Run `climate skill generate <cli_name>` → read the plain-text prompt it prints. -4. Run `climate publish <cli_name>` if the user wants the generated CLI managed on GitHub. -5. Follow the self-registration instructions inside that prompt. -6. Use the new CLI skill for all subsequent tasks that involve that API. +2. If it is one spec, run `climate generate <url>`; if many microservices, run `climate compose <spec:prefix>...`. +3. Note the `cli_name` in the JSON response. +4. Run `climate skill generate <cli_name>` → read the plain-text prompt it prints. +5. Optional: run `climate mock <openapi_spec>` for local simulator/sandbox testing. +6. Run `climate publish <cli_name>` if the user wants the generated CLI managed on GitHub. +7. Follow the self-registration instructions inside that prompt. +8. Use the new CLI skill for all subsequent tasks that involve that API. --- From ca05d2e0f5092a4f65fd963d9370b740607fcafc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:56:03 +0000 Subject: [PATCH 04/10] feat: add mock event emission for webhook-style local testing Agent-Logs-Url: https://github.com/disk0Dancer/climate/sessions/93c959e7-f56b-44a4-bc4d-ab20f65bacf4 Co-authored-by: disk0Dancer <89835485+disk0Dancer@users.noreply.github.com> --- cmd/climate/commands/mock.go | 32 +++++++++- docs/design-mock.md | 19 ++++++ docs/openapi-3-support-matrix.md | 4 +- internal/mock/mock.go | 75 ++++++++++++++++++++++++ internal/mock/mock_test.go | 97 +++++++++++++++++++++++++++++++ skills/climate-generator/SKILL.md | 2 +- skills/climate.md | 4 +- 7 files changed, 226 insertions(+), 7 deletions(-) diff --git a/cmd/climate/commands/mock.go b/cmd/climate/commands/mock.go index 0ac2ece..ce0dae7 100644 --- a/cmd/climate/commands/mock.go +++ b/cmd/climate/commands/mock.go @@ -2,6 +2,7 @@ package commands import ( "fmt" + "strings" "time" "github.com/disk0Dancer/climate/internal/mock" @@ -10,8 +11,11 @@ import ( ) var ( - mockPort int - mockLatency int + mockPort int + mockLatency int + mockEmitURL string + mockEventPath string + mockEventMethod string ) var mockCmd = &cobra.Command{ @@ -34,7 +38,8 @@ The spec can be a local file path or an HTTP(S) URL. Examples: climate mock ./openapi.yaml climate mock --port 9090 https://petstore3.swagger.io/api/v3/openapi.json - climate mock --latency 200 ./orders.yaml`, + climate mock --latency 200 ./orders.yaml + climate mock --emit-url http://localhost:3001/webhook --event-path /events/order-created --event-method POST ./openapi.yaml`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { specSource := args[0] @@ -44,6 +49,24 @@ Examples: exitError("Failed to load spec", err) } + if strings.TrimSpace(mockEmitURL) != "" { + if strings.TrimSpace(mockEventPath) == "" { + exitError("Missing required flag --event-path when using --emit-url", nil) + } + payload, err := mock.GenerateEventPayload(openAPI, mockEventPath, mockEventMethod) + if err != nil { + exitError("Failed to generate event payload", err) + } + statusCode, err := mock.EmitEvent(mockEmitURL, mockEventMethod, payload) + if err != nil { + exitError("Failed to emit event", err) + } + fmt.Fprintf(cmd.OutOrStdout(), + "Emitted %s event from %s to %s (status: %d)\n", + strings.ToUpper(mockEventMethod), mockEventPath, mockEmitURL, statusCode) + return nil + } + addr := fmt.Sprintf(":%d", mockPort) latency := time.Duration(mockLatency) * time.Millisecond s := mock.NewServer(openAPI, addr, latency) @@ -67,5 +90,8 @@ Examples: func init() { mockCmd.Flags().IntVar(&mockPort, "port", 8080, "TCP port to listen on") mockCmd.Flags().IntVar(&mockLatency, "latency", 0, "Artificial response latency in milliseconds") + mockCmd.Flags().StringVar(&mockEmitURL, "emit-url", "", "Send one synthetic webhook/event payload to this URL and exit") + mockCmd.Flags().StringVar(&mockEventPath, "event-path", "", "OpenAPI path to use for synthetic event payload generation (required with --emit-url)") + mockCmd.Flags().StringVar(&mockEventMethod, "event-method", "POST", "HTTP method to use for event emission with --emit-url") rootCmd.AddCommand(mockCmd) } diff --git a/docs/design-mock.md b/docs/design-mock.md index b667dc9..bb7ad2e 100644 --- a/docs/design-mock.md +++ b/docs/design-mock.md @@ -32,6 +32,10 @@ climate mock --port 9090 https://petstore3.swagger.io/api/v3/openapi.json # Simulate network latency (milliseconds) climate mock --latency 200 ./orders.yaml + +# Emit a synthetic event payload to a local webhook endpoint and exit +climate mock --emit-url http://localhost:3001/webhooks/order-created \ + --event-path /events/order-created --event-method POST ./openapi.yaml ``` On start-up the command prints a route table and the listen address: @@ -91,6 +95,21 @@ schemas. Pass `--latency <ms>` to add a uniform sleep before every response. This is useful for testing timeout handling and UI loading states. +## Event / webhook simulation + +For webhook-oriented integrations, `climate mock` can emit one synthetic event +payload to a target endpoint without starting the long-running mock server: + +- `--emit-url` target URL to receive the event +- `--event-path` OpenAPI path used to select the operation schema +- `--event-method` HTTP method for the target request (default: `POST`) + +Payload generation strategy: + +1. Prefer the selected operation's `requestBody` schema (`application/json` first) +2. Fallback to the first successful `2xx` response schema +3. Generate synthetic JSON recursively using the same schema generator as mock responses + ## Limitations - **No request validation** — the server accepts any request body regardless diff --git a/docs/openapi-3-support-matrix.md b/docs/openapi-3-support-matrix.md index 17438ad..6b1d1dd 100644 --- a/docs/openapi-3-support-matrix.md +++ b/docs/openapi-3-support-matrix.md @@ -24,8 +24,8 @@ what is partially supported, and what should be designed/implemented next. | `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` | ❌ Planned | Not mapped to CLI surface yet | Add event command model (`events subscribe`/`events trigger`) | -| `webhooks` (3.1) | ❌ Planned | Not exposed by generator/mocks yet | Add webhook simulation and event ingestion model | +| `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`) | +| `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/mock/mock.go b/internal/mock/mock.go index 49b98e8..023a9f3 100644 --- a/internal/mock/mock.go +++ b/internal/mock/mock.go @@ -45,7 +45,9 @@ package mock import ( + "bytes" "encoding/json" + "errors" "fmt" "net/http" "regexp" @@ -308,3 +310,76 @@ func (s *Server) Summary() string { } return sb.String() } + +// GenerateEventPayload builds a synthetic JSON payload for a specific OpenAPI +// operation, intended for webhook/event simulation scenarios. +// +// The function prefers the operation requestBody schema (`application/json` +// first). If no requestBody schema is available, it falls back to the first +// successful 2xx response schema. +func GenerateEventPayload(openAPI *spec.OpenAPI, path string, method string) (interface{}, error) { + if openAPI == nil { + return nil, errors.New("openapi spec is nil") + } + item, ok := openAPI.Paths[path] + if !ok { + return nil, fmt.Errorf("path %q not found in spec", path) + } + + ops := item.Operations() + op, ok := ops[strings.ToUpper(strings.TrimSpace(method))] + if !ok || op == nil { + return nil, fmt.Errorf("method %q is not defined for path %q", method, path) + } + + if schema := requestBodySchema(op.RequestBody); schema != nil { + return generateValue(schema, openAPI, 0), nil + } + + _, body := (&Server{openAPI: openAPI}).generateResponse(op) + return body, nil +} + +// EmitEvent sends a JSON payload as an HTTP event to targetURL using method. +// It returns the response status code from the target endpoint. +func EmitEvent(targetURL string, method string, payload interface{}) (int, error) { + b, err := json.Marshal(payload) + if err != nil { + return 0, fmt.Errorf("marshal payload: %w", err) + } + + req, err := http.NewRequest(strings.ToUpper(strings.TrimSpace(method)), targetURL, bytes.NewReader(b)) + if err != nil { + return 0, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) //nolint:noctx + if err != nil { + return 0, fmt.Errorf("send event: %w", err) + } + defer resp.Body.Close() + return resp.StatusCode, nil +} + +func requestBodySchema(rb *spec.RequestBody) *spec.Schema { + if rb == nil || len(rb.Content) == 0 { + return nil + } + if mt, ok := rb.Content["application/json"]; ok && mt.Schema != nil { + return mt.Schema + } + + contentTypes := make([]string, 0, len(rb.Content)) + for ct := range rb.Content { + contentTypes = append(contentTypes, ct) + } + sort.Strings(contentTypes) + for _, ct := range contentTypes { + if mt := rb.Content[ct]; mt.Schema != nil { + return mt.Schema + } + } + return nil +} diff --git a/internal/mock/mock_test.go b/internal/mock/mock_test.go index ba6ea78..0734cf7 100644 --- a/internal/mock/mock_test.go +++ b/internal/mock/mock_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "reflect" "strings" "testing" "time" @@ -268,3 +269,99 @@ func TestMock_SchemaWithRef(t *testing.T) { t.Error("resolved $ref: response missing 'id'") } } + +func TestGenerateEventPayload_RequestBody(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": {}, + }, + }, + }, + }, + } + + payload, err := mock.GenerateEventPayload(openAPI, "/events/order-created", "POST") + if err != nil { + t.Fatalf("GenerateEventPayload error: %v", err) + } + obj, ok := payload.(map[string]interface{}) + if !ok { + t.Fatalf("payload type = %T, want object", payload) + } + if _, ok := obj["eventId"]; !ok { + t.Error("missing eventId in generated payload") + } + if _, ok := obj["amount"]; !ok { + t.Error("missing amount in generated payload") + } +} + +func TestGenerateEventPayload_FallbackToResponse(t *testing.T) { + payload, err := mock.GenerateEventPayload(petStoreSpec(), "/pets", "GET") + if err != nil { + t.Fatalf("GenerateEventPayload error: %v", err) + } + if reflect.TypeOf(payload).Kind() != reflect.Slice { + t.Fatalf("payload type = %T, want slice fallback from response schema", payload) + } +} + +func TestGenerateEventPayload_InvalidOperation(t *testing.T) { + _, err := mock.GenerateEventPayload(petStoreSpec(), "/pets", "TRACE") + if err == nil { + t.Fatal("expected error for undefined method") + } +} + +func TestEmitEvent(t *testing.T) { + var ( + gotMethod string + gotContentType string + gotBody map[string]interface{} + ) + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotContentType = r.Header.Get("Content-Type") + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusAccepted) + })) + defer target.Close() + + status, err := mock.EmitEvent(target.URL, "post", map[string]interface{}{ + "event": "order.created", + "id": "evt_1", + }) + if err != nil { + t.Fatalf("EmitEvent error: %v", err) + } + if status != http.StatusAccepted { + t.Fatalf("status = %d, want %d", status, http.StatusAccepted) + } + if gotMethod != http.MethodPost { + t.Errorf("method = %q, want POST", gotMethod) + } + if !strings.HasPrefix(gotContentType, "application/json") { + t.Errorf("content-type = %q, want application/json", gotContentType) + } + if gotBody["event"] != "order.created" { + t.Errorf("event = %v, want order.created", gotBody["event"]) + } +} diff --git a/skills/climate-generator/SKILL.md b/skills/climate-generator/SKILL.md index c84fdbc..a7b6568 100644 --- a/skills/climate-generator/SKILL.md +++ b/skills/climate-generator/SKILL.md @@ -76,7 +76,7 @@ path or URL and `<prefix>` starts with `/`. ### Start local mock server ```bash -climate mock [--port <port>] [--latency <ms>] <openapi_spec> +climate mock [--port <port>] [--latency <ms>] [--emit-url <url> --event-path <path> [--event-method <method>]] <openapi_spec> ``` ### Remove a generated CLI diff --git a/skills/climate.md b/skills/climate.md index caed94f..e1f9df7 100644 --- a/skills/climate.md +++ b/skills/climate.md @@ -67,11 +67,13 @@ climate compose https://orders.svc/openapi.json:/orders https://users.svc/openap ### Run a local mock server from an OpenAPI spec ``` -climate mock [--port <port>] [--latency <ms>] <openapi_spec> +climate mock [--port <port>] [--latency <ms>] [--emit-url <url> --event-path <path> [--event-method <method>]] <openapi_spec> ``` Starts a local simulator server that serves synthetic responses from response schemas in the OpenAPI spec. Useful for local development and agent testing. +For webhook-style integrations, it can also emit one synthetic event payload to +a target endpoint and exit. --- From c7aaf3437adc4b89ba8aeb816eee77d950b39c2f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:58:22 +0000 Subject: [PATCH 05/10] fix: validate mock event flags and use context-aware event requests Agent-Logs-Url: https://github.com/disk0Dancer/climate/sessions/93c959e7-f56b-44a4-bc4d-ab20f65bacf4 Co-authored-by: disk0Dancer <89835485+disk0Dancer@users.noreply.github.com> --- cmd/climate/commands/mock.go | 43 ++++++++++++++++++++++++++++++------ internal/mock/mock.go | 10 ++++++--- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/cmd/climate/commands/mock.go b/cmd/climate/commands/mock.go index ce0dae7..eb668ea 100644 --- a/cmd/climate/commands/mock.go +++ b/cmd/climate/commands/mock.go @@ -2,6 +2,7 @@ package commands import ( "fmt" + "net/http" "strings" "time" @@ -43,27 +44,38 @@ Examples: Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { specSource := args[0] + method := strings.ToUpper(strings.TrimSpace(mockEventMethod)) + emitMode := strings.TrimSpace(mockEmitURL) != "" + + if !emitMode && (cmd.Flags().Changed("event-path") || cmd.Flags().Changed("event-method")) { + exitError("--event-path and --event-method require --emit-url", nil) + } + if emitMode { + if strings.TrimSpace(mockEventPath) == "" { + exitError("Missing required flag --event-path when using --emit-url", nil) + } + if !isValidHTTPMethod(method) { + exitError("Invalid --event-method value", fmt.Errorf("unsupported HTTP method %q", mockEventMethod)) + } + } openAPI, err := spec.Load(specSource) if err != nil { exitError("Failed to load spec", err) } - if strings.TrimSpace(mockEmitURL) != "" { - if strings.TrimSpace(mockEventPath) == "" { - exitError("Missing required flag --event-path when using --emit-url", nil) - } - payload, err := mock.GenerateEventPayload(openAPI, mockEventPath, mockEventMethod) + if emitMode { + payload, err := mock.GenerateEventPayload(openAPI, mockEventPath, method) if err != nil { exitError("Failed to generate event payload", err) } - statusCode, err := mock.EmitEvent(mockEmitURL, mockEventMethod, payload) + statusCode, err := mock.EmitEvent(mockEmitURL, method, payload) if err != nil { exitError("Failed to emit event", err) } fmt.Fprintf(cmd.OutOrStdout(), "Emitted %s event from %s to %s (status: %d)\n", - strings.ToUpper(mockEventMethod), mockEventPath, mockEmitURL, statusCode) + method, mockEventPath, mockEmitURL, statusCode) return nil } @@ -95,3 +107,20 @@ func init() { mockCmd.Flags().StringVar(&mockEventMethod, "event-method", "POST", "HTTP method to use for event emission with --emit-url") rootCmd.AddCommand(mockCmd) } + +func isValidHTTPMethod(method string) bool { + switch method { + case http.MethodGet, + http.MethodHead, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + http.MethodOptions, + http.MethodTrace, + http.MethodConnect: + return true + default: + return false + } +} diff --git a/internal/mock/mock.go b/internal/mock/mock.go index 023a9f3..4f3aa5c 100644 --- a/internal/mock/mock.go +++ b/internal/mock/mock.go @@ -46,6 +46,7 @@ package mock import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -348,14 +349,17 @@ func EmitEvent(targetURL string, method string, payload interface{}) (int, error return 0, fmt.Errorf("marshal payload: %w", err) } - req, err := http.NewRequest(strings.ToUpper(strings.TrimSpace(method)), targetURL, bytes.NewReader(b)) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, strings.ToUpper(strings.TrimSpace(method)), targetURL, bytes.NewReader(b)) if err != nil { return 0, fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", "application/json") - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) //nolint:noctx + client := &http.Client{} + resp, err := client.Do(req) if err != nil { return 0, fmt.Errorf("send event: %w", err) } From 0cc00d8fcbb50f417b697ffc04a60061efe5ad34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:59:31 +0000 Subject: [PATCH 06/10] chore: address final review nits for mock event support Agent-Logs-Url: https://github.com/disk0Dancer/climate/sessions/93c959e7-f56b-44a4-bc4d-ab20f65bacf4 Co-authored-by: disk0Dancer <89835485+disk0Dancer@users.noreply.github.com> --- cmd/climate/commands/mock.go | 2 +- internal/mock/mock_test.go | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/cmd/climate/commands/mock.go b/cmd/climate/commands/mock.go index eb668ea..3516887 100644 --- a/cmd/climate/commands/mock.go +++ b/cmd/climate/commands/mock.go @@ -55,7 +55,7 @@ Examples: exitError("Missing required flag --event-path when using --emit-url", nil) } if !isValidHTTPMethod(method) { - exitError("Invalid --event-method value", fmt.Errorf("unsupported HTTP method %q", mockEventMethod)) + exitError("Invalid --event-method value", fmt.Errorf("unsupported HTTP method %q", method)) } } diff --git a/internal/mock/mock_test.go b/internal/mock/mock_test.go index 0734cf7..8aab238 100644 --- a/internal/mock/mock_test.go +++ b/internal/mock/mock_test.go @@ -5,7 +5,6 @@ import ( "io" "net/http" "net/http/httptest" - "reflect" "strings" "testing" "time" @@ -319,8 +318,8 @@ func TestGenerateEventPayload_FallbackToResponse(t *testing.T) { if err != nil { t.Fatalf("GenerateEventPayload error: %v", err) } - if reflect.TypeOf(payload).Kind() != reflect.Slice { - t.Fatalf("payload type = %T, want slice fallback from response schema", payload) + if _, ok := payload.([]interface{}); !ok { + t.Fatalf("payload type = %T, want []interface{} fallback from response schema", payload) } } From e5a961028a7555ae9b54eb02db6481ee6a9e7083 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:01:11 +0000 Subject: [PATCH 07/10] docs: clarify mock event flag errors and deterministic schema fallback note Agent-Logs-Url: https://github.com/disk0Dancer/climate/sessions/93c959e7-f56b-44a4-bc4d-ab20f65bacf4 Co-authored-by: disk0Dancer <89835485+disk0Dancer@users.noreply.github.com> --- cmd/climate/commands/mock.go | 7 +++++-- internal/mock/mock.go | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd/climate/commands/mock.go b/cmd/climate/commands/mock.go index 3516887..ab92032 100644 --- a/cmd/climate/commands/mock.go +++ b/cmd/climate/commands/mock.go @@ -48,14 +48,17 @@ Examples: emitMode := strings.TrimSpace(mockEmitURL) != "" if !emitMode && (cmd.Flags().Changed("event-path") || cmd.Flags().Changed("event-method")) { - exitError("--event-path and --event-method require --emit-url", nil) + exitError("--event-path and --event-method can only be used with --emit-url", nil) } if emitMode { if strings.TrimSpace(mockEventPath) == "" { exitError("Missing required flag --event-path when using --emit-url", nil) } if !isValidHTTPMethod(method) { - exitError("Invalid --event-method value", fmt.Errorf("unsupported HTTP method %q", method)) + exitError( + "Invalid --event-method value", + fmt.Errorf("unsupported HTTP method %q (supported: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE, CONNECT)", method), + ) } } diff --git a/internal/mock/mock.go b/internal/mock/mock.go index 4f3aa5c..b535f31 100644 --- a/internal/mock/mock.go +++ b/internal/mock/mock.go @@ -379,6 +379,7 @@ func requestBodySchema(rb *spec.RequestBody) *spec.Schema { for ct := range rb.Content { contentTypes = append(contentTypes, ct) } + // Keep media-type fallback deterministic; map iteration order is random. sort.Strings(contentTypes) for _, ct := range contentTypes { if mt := rb.Content[ct]; mt.Schema != nil { From 6979abe16c62ee6b5e1204fa5a5b67b7f172b277 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:02:20 +0000 Subject: [PATCH 08/10] fix: tighten mock event method validation and error wording Agent-Logs-Url: https://github.com/disk0Dancer/climate/sessions/93c959e7-f56b-44a4-bc4d-ab20f65bacf4 Co-authored-by: disk0Dancer <89835485+disk0Dancer@users.noreply.github.com> --- cmd/climate/commands/mock.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/cmd/climate/commands/mock.go b/cmd/climate/commands/mock.go index ab92032..797dfd6 100644 --- a/cmd/climate/commands/mock.go +++ b/cmd/climate/commands/mock.go @@ -48,7 +48,7 @@ Examples: emitMode := strings.TrimSpace(mockEmitURL) != "" if !emitMode && (cmd.Flags().Changed("event-path") || cmd.Flags().Changed("event-method")) { - exitError("--event-path and --event-method can only be used with --emit-url", nil) + exitError("--event-path and --event-method require --emit-url", nil) } if emitMode { if strings.TrimSpace(mockEventPath) == "" { @@ -57,7 +57,7 @@ Examples: if !isValidHTTPMethod(method) { exitError( "Invalid --event-method value", - fmt.Errorf("unsupported HTTP method %q (supported: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE, CONNECT)", method), + fmt.Errorf("unsupported HTTP method %q (supported: GET, POST, PUT, PATCH, DELETE)", method), ) } } @@ -114,14 +114,10 @@ func init() { func isValidHTTPMethod(method string) bool { switch method { case http.MethodGet, - http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, - http.MethodDelete, - http.MethodOptions, - http.MethodTrace, - http.MethodConnect: + http.MethodDelete: return true default: return false From d12e958aa9ac5005bb4b7751f64c55abac085f9f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:03:50 +0000 Subject: [PATCH 09/10] chore: finalize mock event validation and timeout constant Agent-Logs-Url: https://github.com/disk0Dancer/climate/sessions/93c959e7-f56b-44a4-bc4d-ab20f65bacf4 Co-authored-by: disk0Dancer <89835485+disk0Dancer@users.noreply.github.com> --- cmd/climate/commands/mock.go | 5 ++++- internal/mock/mock.go | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/cmd/climate/commands/mock.go b/cmd/climate/commands/mock.go index 797dfd6..26323e5 100644 --- a/cmd/climate/commands/mock.go +++ b/cmd/climate/commands/mock.go @@ -45,10 +45,13 @@ Examples: RunE: func(cmd *cobra.Command, args []string) error { specSource := args[0] method := strings.ToUpper(strings.TrimSpace(mockEventMethod)) + if method == "" { + method = http.MethodPost + } emitMode := strings.TrimSpace(mockEmitURL) != "" if !emitMode && (cmd.Flags().Changed("event-path") || cmd.Flags().Changed("event-method")) { - exitError("--event-path and --event-method require --emit-url", nil) + exitError("--event-path or --event-method require --emit-url", nil) } if emitMode { if strings.TrimSpace(mockEventPath) == "" { diff --git a/internal/mock/mock.go b/internal/mock/mock.go index b535f31..ab23ee2 100644 --- a/internal/mock/mock.go +++ b/internal/mock/mock.go @@ -60,6 +60,8 @@ import ( "github.com/disk0Dancer/climate/internal/spec" ) +const defaultEventTimeout = 10 * time.Second + // Server is a local HTTP mock server driven by an OpenAPI specification. type Server struct { openAPI *spec.OpenAPI @@ -349,7 +351,7 @@ func EmitEvent(targetURL string, method string, payload interface{}) (int, error return 0, fmt.Errorf("marshal payload: %w", err) } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), defaultEventTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, strings.ToUpper(strings.TrimSpace(method)), targetURL, bytes.NewReader(b)) From 90737c6dcde106f7908b683bdec9c98e6f205c1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:05:00 +0000 Subject: [PATCH 10/10] perf: reuse default HTTP client for mock event emission Agent-Logs-Url: https://github.com/disk0Dancer/climate/sessions/93c959e7-f56b-44a4-bc4d-ab20f65bacf4 Co-authored-by: disk0Dancer <89835485+disk0Dancer@users.noreply.github.com> --- internal/mock/mock.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/mock/mock.go b/internal/mock/mock.go index ab23ee2..f08309d 100644 --- a/internal/mock/mock.go +++ b/internal/mock/mock.go @@ -360,8 +360,7 @@ func EmitEvent(targetURL string, method string, payload interface{}) (int, error } req.Header.Set("Content-Type", "application/json") - client := &http.Client{} - resp, err := client.Do(req) + resp, err := http.DefaultClient.Do(req) if err != nil { return 0, fmt.Errorf("send event: %w", err) }