From 926a723d9cd45f1d6f3845bcd780bb50a52f1144 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 14:16:10 +0000 Subject: [PATCH] feat(api): add runtime OpenAPI client Co-Authored-By: Virgil --- client.go | 415 +++++++++++++++++++++++++++++++++++++++++++++++++ client_test.go | 154 ++++++++++++++++++ 2 files changed, 569 insertions(+) create mode 100644 client.go create mode 100644 client_test.go diff --git a/client.go b/client.go new file mode 100644 index 0000000..39ab127 --- /dev/null +++ b/client.go @@ -0,0 +1,415 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "sync" + + "gopkg.in/yaml.v3" +) + +// OpenAPIClient is a small runtime client that can call operations by their +// OpenAPI operationId. It loads the spec once, resolves the HTTP method and +// path for each operation, and performs JSON request/response handling. +type OpenAPIClient struct { + specPath string + baseURL string + bearerToken string + httpClient *http.Client + + once sync.Once + operations map[string]openAPIOperation + servers []string + loadErr error +} + +type openAPIOperation struct { + method string + pathTemplate string + hasRequestBody bool +} + +// OpenAPIClientOption configures a runtime OpenAPI client. +type OpenAPIClientOption func(*OpenAPIClient) + +// WithSpec sets the filesystem path to the OpenAPI document. +func WithSpec(path string) OpenAPIClientOption { + return func(c *OpenAPIClient) { + c.specPath = path + } +} + +// WithBaseURL sets the base URL used for outgoing requests. +func WithBaseURL(baseURL string) OpenAPIClientOption { + return func(c *OpenAPIClient) { + c.baseURL = baseURL + } +} + +// WithBearerToken sets the Authorization bearer token used for requests. +func WithBearerToken(token string) OpenAPIClientOption { + return func(c *OpenAPIClient) { + c.bearerToken = token + } +} + +// WithHTTPClient sets the HTTP client used to execute requests. +func WithHTTPClient(client *http.Client) OpenAPIClientOption { + return func(c *OpenAPIClient) { + c.httpClient = client + } +} + +// NewOpenAPIClient constructs a runtime client for calling OpenAPI operations. +func NewOpenAPIClient(opts ...OpenAPIClientOption) *OpenAPIClient { + c := &OpenAPIClient{ + httpClient: http.DefaultClient, + } + for _, opt := range opts { + opt(c) + } + if c.httpClient == nil { + c.httpClient = http.DefaultClient + } + return c +} + +// Call invokes the operation with the given operationId. +// +// The params argument may be a map, struct, or nil. For convenience, a map may +// include "path", "query", and "body" keys to explicitly control where the +// values are sent. When no explicit body is provided, non-GET requests send the +// remaining parameters as JSON. +func (c *OpenAPIClient) Call(operationID string, params any) (any, error) { + if err := c.load(); err != nil { + return nil, err + } + if c.httpClient == nil { + c.httpClient = http.DefaultClient + } + + op, ok := c.operations[operationID] + if !ok { + return nil, fmt.Errorf("operation %q not found in OpenAPI spec", operationID) + } + + merged, err := normaliseParams(params) + if err != nil { + return nil, err + } + + requestURL, err := c.buildURL(op, merged) + if err != nil { + return nil, err + } + + body, err := c.buildBody(op, merged) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(op.method, requestURL, body) + if err != nil { + return nil, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.bearerToken != "" { + req.Header.Set("Authorization", "Bearer "+c.bearerToken) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + payload, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("openapi call %s returned %s: %s", operationID, resp.Status, strings.TrimSpace(string(payload))) + } + + if len(bytes.TrimSpace(payload)) == 0 { + return nil, nil + } + + var decoded any + dec := json.NewDecoder(bytes.NewReader(payload)) + dec.UseNumber() + if err := dec.Decode(&decoded); err != nil { + return string(payload), nil + } + + if envelope, ok := decoded.(map[string]any); ok { + if success, ok := envelope["success"].(bool); ok { + if !success { + if errObj, ok := envelope["error"].(map[string]any); ok { + return nil, fmt.Errorf("openapi call %s failed: %v", operationID, errObj) + } + return nil, fmt.Errorf("openapi call %s failed", operationID) + } + if data, ok := envelope["data"]; ok { + return data, nil + } + } + } + + return decoded, nil +} + +func (c *OpenAPIClient) load() error { + c.once.Do(func() { + c.loadErr = c.loadSpec() + }) + return c.loadErr +} + +func (c *OpenAPIClient) loadSpec() error { + if c.specPath == "" { + return fmt.Errorf("spec path is required") + } + + data, err := os.ReadFile(c.specPath) + if err != nil { + return fmt.Errorf("read spec: %w", err) + } + + var spec map[string]any + if err := yaml.Unmarshal(data, &spec); err != nil { + return fmt.Errorf("parse spec: %w", err) + } + + operations := make(map[string]openAPIOperation) + if paths, ok := spec["paths"].(map[string]any); ok { + for pathTemplate, rawPathItem := range paths { + pathItem, ok := rawPathItem.(map[string]any) + if !ok { + continue + } + for method, rawOperation := range pathItem { + operation, ok := rawOperation.(map[string]any) + if !ok { + continue + } + operationID, _ := operation["operationId"].(string) + if operationID == "" { + continue + } + operations[operationID] = openAPIOperation{ + method: strings.ToUpper(method), + pathTemplate: pathTemplate, + hasRequestBody: operation["requestBody"] != nil, + } + } + } + } + + c.operations = operations + if servers, ok := spec["servers"].([]any); ok { + for _, rawServer := range servers { + server, ok := rawServer.(map[string]any) + if !ok { + continue + } + if u, _ := server["url"].(string); u != "" { + c.servers = append(c.servers, u) + } + } + } + + if c.baseURL == "" && len(c.servers) > 0 { + c.baseURL = c.servers[0] + } + + return nil +} + +func (c *OpenAPIClient) buildURL(op openAPIOperation, params map[string]any) (string, error) { + base := strings.TrimRight(c.baseURL, "/") + if base == "" { + return "", fmt.Errorf("base URL is required") + } + + path := op.pathTemplate + pathKeys := pathParameterNames(path) + pathValues := map[string]any{} + if explicitPath, ok := nestedMap(params, "path"); ok { + pathValues = explicitPath + } else { + pathValues = params + } + for _, key := range pathKeys { + if value, ok := pathValues[key]; ok { + placeholder := "{" + key + "}" + path = strings.ReplaceAll(path, placeholder, url.PathEscape(fmt.Sprint(value))) + } + } + + if strings.Contains(path, "{") { + return "", fmt.Errorf("missing path parameters for %q", op.pathTemplate) + } + + fullURL, err := url.JoinPath(base, path) + if err != nil { + return "", err + } + + query := url.Values{} + if explicitQuery, ok := nestedMap(params, "query"); ok { + for key, value := range explicitQuery { + query.Set(key, fmt.Sprint(value)) + } + } + if op.method == http.MethodGet || op.method == http.MethodHead { + for key, value := range params { + if key == "path" || key == "body" || key == "query" { + continue + } + if containsString(pathKeys, key) { + continue + } + if _, exists := query[key]; exists { + continue + } + query.Set(key, fmt.Sprint(value)) + } + } + + if encoded := query.Encode(); encoded != "" { + fullURL += "?" + encoded + } + + return fullURL, nil +} + +func (c *OpenAPIClient) buildBody(op openAPIOperation, params map[string]any) (io.Reader, error) { + if explicitBody, ok := params["body"]; ok { + return encodeJSONBody(explicitBody) + } + + if op.method == http.MethodGet || op.method == http.MethodHead { + return nil, nil + } + + if len(params) == 0 { + return nil, nil + } + + pathKeys := pathParameterNames(op.pathTemplate) + queryKeys := map[string]struct{}{} + if explicitQuery, ok := nestedMap(params, "query"); ok { + for key := range explicitQuery { + queryKeys[key] = struct{}{} + } + } + + payload := make(map[string]any, len(params)) + for key, value := range params { + if key == "path" || key == "query" || key == "body" { + continue + } + if containsString(pathKeys, key) { + continue + } + if _, exists := queryKeys[key]; exists { + continue + } + payload[key] = value + } + if len(payload) == 0 { + return nil, nil + } + return encodeJSONBody(payload) +} + +func encodeJSONBody(v any) (io.Reader, error) { + data, err := json.Marshal(v) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +func normaliseParams(params any) (map[string]any, error) { + if params == nil { + return map[string]any{}, nil + } + + if m, ok := params.(map[string]any); ok { + return m, nil + } + + data, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("marshal params: %w", err) + } + + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + return nil, fmt.Errorf("decode params: %w", err) + } + + return out, nil +} + +func nestedMap(params map[string]any, key string) (map[string]any, bool) { + raw, ok := params[key] + if !ok { + return nil, false + } + + m, ok := raw.(map[string]any) + if ok { + return m, true + } + + data, err := json.Marshal(raw) + if err != nil { + return nil, false + } + if err := json.Unmarshal(data, &m); err != nil { + return nil, false + } + return m, true +} + +func pathParameterNames(pathTemplate string) []string { + var names []string + for i := 0; i < len(pathTemplate); i++ { + if pathTemplate[i] != '{' { + continue + } + end := strings.IndexByte(pathTemplate[i+1:], '}') + if end < 0 { + break + } + name := pathTemplate[i+1 : i+1+end] + if name != "" { + names = append(names, name) + } + i += end + 1 + } + return names +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..d6409e4 --- /dev/null +++ b/client_test.go @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + api "dappco.re/go/core/api" +) + +func TestOpenAPIClient_Good_CallOperationByID(t *testing.T) { + errCh := make(chan error, 2) + mux := http.NewServeMux() + mux.HandleFunc("/hello", 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 + } + if got := r.URL.Query().Get("name"); got != "Ada" { + errCh <- fmt.Errorf("expected query name=Ada, got %q", got) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"data":{"message":"hello"}}`)) + }) + mux.HandleFunc("/users/123", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + errCh <- fmt.Errorf("expected POST, got %s", r.Method) + w.WriteHeader(http.StatusInternalServerError) + return + } + if got := r.URL.Query().Get("verbose"); got != "true" { + errCh <- fmt.Errorf("expected query verbose=true, got %q", got) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"data":{"id":"123","name":"Ada"}}`)) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + specPath := writeTempSpec(t, `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /hello: + get: + operationId: get_hello + /users/{id}: + post: + operationId: update_user + requestBody: + required: true + content: + application/json: + schema: + type: object +`) + + client := api.NewOpenAPIClient( + api.WithSpec(specPath), + api.WithBaseURL(srv.URL), + ) + + result, err := client.Call("get_hello", map[string]any{ + "name": "Ada", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + select { + case err := <-errCh: + t.Fatal(err) + default: + } + + hello, ok := result.(map[string]any) + if !ok { + t.Fatalf("expected map result, got %T", result) + } + if hello["message"] != "hello" { + t.Fatalf("expected message=hello, got %#v", hello["message"]) + } + + result, err = client.Call("update_user", map[string]any{ + "path": map[string]any{ + "id": "123", + }, + "query": map[string]any{ + "verbose": true, + }, + "body": map[string]any{ + "name": "Ada", + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + select { + case err := <-errCh: + t.Fatal(err) + default: + } + + updated, ok := result.(map[string]any) + if !ok { + t.Fatalf("expected map result, got %T", result) + } + if updated["id"] != "123" { + t.Fatalf("expected id=123, got %#v", updated["id"]) + } + if updated["name"] != "Ada" { + t.Fatalf("expected name=Ada, got %#v", updated["name"]) + } +} + +func TestOpenAPIClient_Bad_MissingOperation(t *testing.T) { + specPath := writeTempSpec(t, `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: {} +`) + + client := api.NewOpenAPIClient( + api.WithSpec(specPath), + api.WithBaseURL("http://example.invalid"), + ) + + if _, err := client.Call("missing", nil); err == nil { + t.Fatal("expected error for missing operation, got nil") + } +} + +func writeTempSpec(t *testing.T, contents string) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "openapi.yaml") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write spec: %v", err) + } + return path +}