feat(api): add runtime OpenAPI client

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 14:16:10 +00:00
parent fb6812df09
commit 926a723d9c
2 changed files with 569 additions and 0 deletions

415
client.go Normal file
View file

@ -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
}

154
client_test.go Normal file
View file

@ -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
}