From 1f43f019b1da85c76fa68c457dd51f26742ecf49 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:56:26 +0000 Subject: [PATCH] feat(api): allow openapi specs from readers Co-Authored-By: Virgil --- client.go | 39 ++++++++++++++++++++++++++++++--------- client_test.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/client.go b/client.go index 7ff11ed..7c68afa 100644 --- a/client.go +++ b/client.go @@ -29,6 +29,7 @@ import ( // data, err := client.Call("get_health", nil) type OpenAPIClient struct { specPath string + specReader io.Reader baseURL string bearerToken string httpClient *http.Client @@ -67,6 +68,18 @@ func WithSpec(path string) OpenAPIClientOption { } } +// WithSpecReader sets an in-memory or streamed OpenAPI document source. +// It is read once the first time the client loads its spec. +// +// Example: +// +// client := api.NewOpenAPIClient(api.WithSpecReader(strings.NewReader(spec))) +func WithSpecReader(reader io.Reader) OpenAPIClientOption { + return func(c *OpenAPIClient) { + c.specReader = reader + } +} + // WithBaseURL sets the base URL used for outgoing requests. // // Example: @@ -239,17 +252,25 @@ func (c *OpenAPIClient) load() error { } func (c *OpenAPIClient) loadSpec() error { - if c.specPath == "" { - return coreerr.E("OpenAPIClient.loadSpec", "spec path is required", nil) + var ( + data []byte + err error + ) + + switch { + case c.specReader != nil: + data, err = io.ReadAll(c.specReader) + case c.specPath != "": + f, openErr := os.Open(c.specPath) + if openErr != nil { + return coreerr.E("OpenAPIClient.loadSpec", "read spec", openErr) + } + defer f.Close() + data, err = io.ReadAll(f) + default: + return coreerr.E("OpenAPIClient.loadSpec", "spec path or reader is required", nil) } - f, err := os.Open(c.specPath) - if err != nil { - return coreerr.E("OpenAPIClient.loadSpec", "read spec", err) - } - defer f.Close() - - data, err := io.ReadAll(f) if err != nil { return coreerr.E("OpenAPIClient.loadSpec", "read spec", err) } diff --git a/client_test.go b/client_test.go index 751c209..ed9e2f5 100644 --- a/client_test.go +++ b/client_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" api "dappco.re/go/core/api" @@ -125,6 +126,54 @@ paths: } } +func TestOpenAPIClient_Good_LoadsSpecFromReader(t *testing.T) { + errCh := make(chan error, 1) + mux := http.NewServeMux() + mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + errCh <- fmt.Errorf("expected GET, got %s", r.Method) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"data":{"message":"pong"}}`)) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + client := api.NewOpenAPIClient( + api.WithSpecReader(strings.NewReader(`openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /ping: + get: + operationId: ping +`)), + api.WithBaseURL(srv.URL), + ) + + result, err := client.Call("ping", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + select { + case err := <-errCh: + t.Fatal(err) + default: + } + + ping, ok := result.(map[string]any) + if !ok { + t.Fatalf("expected map result, got %T", result) + } + if ping["message"] != "pong" { + t.Fatalf("expected message=pong, got %#v", ping["message"]) + } +} + func TestOpenAPIClient_Good_CallHeadOperationWithRequestBody(t *testing.T) { errCh := make(chan error, 1) mux := http.NewServeMux()