From 8b5e572d1c09d072b4ba3d411d0c1ba6c645128b Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 04:42:14 +0000 Subject: [PATCH] fix(api): expose OpenAPI client snapshots Co-Authored-By: Virgil --- client.go | 128 +++++++++++++++++++++++++++++++++++++++++++++++++ client_test.go | 98 +++++++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) diff --git a/client.go b/client.go index fbadb3e..521b80e 100644 --- a/client.go +++ b/client.go @@ -7,13 +7,17 @@ import ( "encoding/json" "fmt" "io" + "iter" "net/http" "net/url" "os" "reflect" + "sort" "strings" "sync" + "slices" + "gopkg.in/yaml.v3" coreerr "dappco.re/go/core/log" @@ -40,6 +44,24 @@ type OpenAPIClient struct { loadErr error } +// OpenAPIOperation snapshots the public metadata for a single loaded OpenAPI +// operation. +type OpenAPIOperation struct { + OperationID string + Method string + PathTemplate string + HasRequestBody bool + Parameters []OpenAPIParameter +} + +// OpenAPIParameter snapshots a single OpenAPI parameter definition. +type OpenAPIParameter struct { + Name string + In string + Required bool + Schema map[string]any +} + type openAPIOperation struct { method string pathTemplate string @@ -140,6 +162,92 @@ func NewOpenAPIClient(opts ...OpenAPIClientOption) *OpenAPIClient { return c } +// Operations returns a snapshot of the operations loaded from the OpenAPI +// document. +// +// Example: +// +// ops, err := client.Operations() +func (c *OpenAPIClient) Operations() ([]OpenAPIOperation, error) { + if err := c.load(); err != nil { + return nil, err + } + + operations := make([]OpenAPIOperation, 0, len(c.operations)) + for operationID, op := range c.operations { + operations = append(operations, snapshotOpenAPIOperation(operationID, op)) + } + sort.SliceStable(operations, func(i, j int) bool { + if operations[i].OperationID == operations[j].OperationID { + if operations[i].Method == operations[j].Method { + return operations[i].PathTemplate < operations[j].PathTemplate + } + return operations[i].Method < operations[j].Method + } + return operations[i].OperationID < operations[j].OperationID + }) + return operations, nil +} + +// OperationsIter returns an iterator over the loaded OpenAPI operations. +// +// Example: +// +// for op := range client.OperationsIter() { +// _ = op +// } +func (c *OpenAPIClient) OperationsIter() (iter.Seq[OpenAPIOperation], error) { + operations, err := c.Operations() + if err != nil { + return nil, err + } + + return func(yield func(OpenAPIOperation) bool) { + for _, op := range operations { + if !yield(op) { + return + } + } + }, nil +} + +// Servers returns a snapshot of the server URLs discovered from the OpenAPI +// document. +// +// Example: +// +// servers, err := client.Servers() +func (c *OpenAPIClient) Servers() ([]string, error) { + if err := c.load(); err != nil { + return nil, err + } + + return slices.Clone(c.servers), nil +} + +// ServersIter returns an iterator over the server URLs discovered from the +// OpenAPI document. +// +// Example: +// +// for server := range client.ServersIter() { +// _ = server +// } +func (c *OpenAPIClient) ServersIter() (iter.Seq[string], error) { + servers, err := c.Servers() + if err != nil { + return nil, err + } + + return func(yield func(string) bool) { + for _, server := range servers { + if !yield(server) { + return + } + } + }, nil +} + // Call invokes the operation with the given operationId. // // The params argument may be a map, struct, or nil. For convenience, a map may @@ -341,6 +449,26 @@ func (c *OpenAPIClient) loadSpec() error { return nil } +func snapshotOpenAPIOperation(operationID string, op openAPIOperation) OpenAPIOperation { + parameters := make([]OpenAPIParameter, len(op.parameters)) + for i, param := range op.parameters { + parameters[i] = OpenAPIParameter{ + Name: param.name, + In: param.in, + Required: param.required, + Schema: cloneOpenAPIObject(param.schema), + } + } + + return OpenAPIOperation{ + OperationID: operationID, + Method: strings.ToUpper(op.method), + PathTemplate: op.pathTemplate, + HasRequestBody: op.hasRequestBody, + Parameters: parameters, + } +} + func (c *OpenAPIClient) buildURL(op openAPIOperation, params map[string]any) (string, error) { base := strings.TrimRight(c.baseURL, "/") if base == "" { diff --git a/client_test.go b/client_test.go index e793f66..284c1d6 100644 --- a/client_test.go +++ b/client_test.go @@ -12,6 +12,8 @@ import ( "strings" "testing" + "slices" + api "dappco.re/go/core/api" ) @@ -174,6 +176,102 @@ paths: } } +func TestOpenAPIClient_Good_ExposesOperationSnapshots(t *testing.T) { + specPath := writeTempSpec(t, `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +servers: + - url: https://api.example.com +paths: + /users/{id}: + post: + operationId: update_user + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object +`) + + client := api.NewOpenAPIClient(api.WithSpec(specPath)) + + operations, err := client.Operations() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(operations) != 1 { + t.Fatalf("expected 1 operation, got %d", len(operations)) + } + + op := operations[0] + if op.OperationID != "update_user" { + t.Fatalf("expected operationId update_user, got %q", op.OperationID) + } + if op.Method != http.MethodPost { + t.Fatalf("expected method POST, got %q", op.Method) + } + if op.PathTemplate != "/users/{id}" { + t.Fatalf("expected path template /users/{id}, got %q", op.PathTemplate) + } + if !op.HasRequestBody { + t.Fatal("expected operation to report a request body") + } + if len(op.Parameters) != 1 || op.Parameters[0].Name != "id" { + t.Fatalf("expected one path parameter snapshot, got %+v", op.Parameters) + } + + op.Parameters[0].Schema["type"] = "integer" + operations[0].PathTemplate = "/mutated" + + again, err := client.Operations() + if err != nil { + t.Fatalf("unexpected error on re-read: %v", err) + } + if again[0].PathTemplate != "/users/{id}" { + t.Fatalf("expected snapshot to remain immutable, got %q", again[0].PathTemplate) + } + if got := again[0].Parameters[0].Schema["type"]; got != "string" { + t.Fatalf("expected cloned parameter schema, got %#v", got) + } +} + +func TestOpenAPIClient_Good_ExposesServerSnapshots(t *testing.T) { + client := api.NewOpenAPIClient(api.WithSpecReader(strings.NewReader(`openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +servers: + - url: https://api.example.com + - url: /relative +paths: {} +`))) + + servers, err := client.Servers() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !slices.Equal(servers, []string{"https://api.example.com", "/relative"}) { + t.Fatalf("expected server snapshot to preserve order, got %v", servers) + } + + servers[0] = "https://mutated.example.com" + again, err := client.Servers() + if err != nil { + t.Fatalf("unexpected error on re-read: %v", err) + } + if !slices.Equal(again, []string{"https://api.example.com", "/relative"}) { + t.Fatalf("expected server snapshot to be cloned, got %v", again) + } +} + func TestOpenAPIClient_Good_CallHeadOperationWithRequestBody(t *testing.T) { errCh := make(chan error, 1) mux := http.NewServeMux()