fix(api): expose OpenAPI client snapshots

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-03 04:42:14 +00:00
parent 76acb4534b
commit 8b5e572d1c
2 changed files with 226 additions and 0 deletions

128
client.go
View file

@ -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 == "" {

View file

@ -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()