// SPDX-License-Identifier: EUPL-1.2 package api_test import ( "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "slices" 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_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_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_IteratorsExposeSnapshots(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 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 `))) operations, err := client.OperationsIter() if err != nil { t.Fatalf("unexpected error: %v", err) } var operationIDs []string for op := range operations { operationIDs = append(operationIDs, op.OperationID) } if !slices.Equal(operationIDs, []string{"update_user"}) { t.Fatalf("expected iterator to preserve operation snapshots, got %v", operationIDs) } servers, err := client.ServersIter() if err != nil { t.Fatalf("unexpected error: %v", err) } var serverURLs []string for server := range servers { serverURLs = append(serverURLs, server) } if !slices.Equal(serverURLs, []string{"https://api.example.com"}) { t.Fatalf("expected iterator to preserve server snapshots, got %v", serverURLs) } } func TestOpenAPIClient_Good_CallHeadOperationWithRequestBody(t *testing.T) { errCh := make(chan error, 1) mux := http.NewServeMux() mux.HandleFunc("/head", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodHead { errCh <- fmt.Errorf("expected HEAD, got %s", r.Method) w.WriteHeader(http.StatusInternalServerError) return } if got := r.URL.RawQuery; got != "" { errCh <- fmt.Errorf("expected no query string, got %q", got) w.WriteHeader(http.StatusInternalServerError) return } body, err := io.ReadAll(r.Body) if err != nil { errCh <- fmt.Errorf("read body: %v", err) w.WriteHeader(http.StatusInternalServerError) return } if string(body) != `{"name":"Ada"}` { errCh <- fmt.Errorf("expected JSON body {\"name\":\"Ada\"}, got %q", string(body)) w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) }) srv := httptest.NewServer(mux) defer srv.Close() specPath := writeTempSpec(t, `openapi: 3.1.0 info: title: Test API version: 1.0.0 paths: /head: head: operationId: head_check requestBody: required: true content: application/json: schema: type: object `) client := api.NewOpenAPIClient( api.WithSpec(specPath), api.WithBaseURL(srv.URL), ) result, err := client.Call("head_check", map[string]any{ "name": "Ada", }) if err != nil { t.Fatalf("unexpected error: %v", err) } select { case err := <-errCh: t.Fatal(err) default: } if result != nil { t.Fatalf("expected nil result for empty HEAD response body, got %T", result) } } func TestOpenAPIClient_Good_CallOperationWithRepeatedQueryValues(t *testing.T) { errCh := make(chan error, 1) mux := http.NewServeMux() mux.HandleFunc("/search", 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()["tag"]; len(got) != 2 || got[0] != "alpha" || got[1] != "beta" { errCh <- fmt.Errorf("expected repeated tag values [alpha beta], got %v", got) w.WriteHeader(http.StatusInternalServerError) return } if got := r.URL.Query().Get("page"); got != "2" { errCh <- fmt.Errorf("expected page=2, got %q", got) w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`)) }) srv := httptest.NewServer(mux) defer srv.Close() specPath := writeTempSpec(t, `openapi: 3.1.0 info: title: Test API version: 1.0.0 paths: /search: get: operationId: search_items `) client := api.NewOpenAPIClient( api.WithSpec(specPath), api.WithBaseURL(srv.URL), ) result, err := client.Call("search_items", map[string]any{ "tag": []string{"alpha", "beta"}, "page": 2, }) if err != nil { t.Fatalf("unexpected error: %v", err) } select { case err := <-errCh: t.Fatal(err) default: } decoded, ok := result.(map[string]any) if !ok { t.Fatalf("expected map result, got %T", result) } if okValue, ok := decoded["ok"].(bool); !ok || !okValue { t.Fatalf("expected ok=true, got %#v", decoded["ok"]) } } func TestOpenAPIClient_Good_UsesTopLevelQueryParametersOnPost(t *testing.T) { errCh := make(chan error, 1) mux := http.NewServeMux() mux.HandleFunc("/submit", 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 } body, err := io.ReadAll(r.Body) if err != nil { errCh <- fmt.Errorf("read body: %v", err) w.WriteHeader(http.StatusInternalServerError) return } if string(body) != `{"name":"Ada"}` { errCh <- fmt.Errorf("expected JSON body {\"name\":\"Ada\"}, got %q", string(body)) w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`)) }) srv := httptest.NewServer(mux) defer srv.Close() specPath := writeTempSpec(t, `openapi: 3.1.0 info: title: Test API version: 1.0.0 paths: /submit: post: operationId: submit_item requestBody: required: true content: application/json: schema: type: object parameters: - name: verbose in: query `) client := api.NewOpenAPIClient( api.WithSpec(specPath), api.WithBaseURL(srv.URL), ) result, err := client.Call("submit_item", map[string]any{ "verbose": true, "name": "Ada", }) if err != nil { t.Fatalf("unexpected error: %v", err) } select { case err := <-errCh: t.Fatal(err) default: } decoded, ok := result.(map[string]any) if !ok { t.Fatalf("expected map result, got %T", result) } if okValue, ok := decoded["ok"].(bool); !ok || !okValue { t.Fatalf("expected ok=true, got %#v", decoded["ok"]) } } func TestOpenAPIClient_Bad_MissingRequiredQueryParameter(t *testing.T) { called := make(chan struct{}, 1) mux := http.NewServeMux() mux.HandleFunc("/submit", func(w http.ResponseWriter, r *http.Request) { called <- struct{}{} w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`)) }) srv := httptest.NewServer(mux) defer srv.Close() specPath := writeTempSpec(t, `openapi: 3.1.0 info: title: Test API version: 1.0.0 paths: /submit: post: operationId: submit_item parameters: - name: verbose in: query required: true `) client := api.NewOpenAPIClient( api.WithSpec(specPath), api.WithBaseURL(srv.URL), ) if _, err := client.Call("submit_item", map[string]any{ "name": "Ada", }); err == nil { t.Fatal("expected required query parameter validation error, got nil") } select { case <-called: t.Fatal("expected validation to fail before the HTTP call") default: } } func TestOpenAPIClient_Bad_ValidatesQueryParameterAgainstSchema(t *testing.T) { called := make(chan struct{}, 1) mux := http.NewServeMux() mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) { called <- struct{}{} w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`)) }) srv := httptest.NewServer(mux) defer srv.Close() specPath := writeTempSpec(t, `openapi: 3.1.0 info: title: Test API version: 1.0.0 paths: /search: get: operationId: search_items parameters: - name: page in: query schema: type: integer `) client := api.NewOpenAPIClient( api.WithSpec(specPath), api.WithBaseURL(srv.URL), ) if _, err := client.Call("search_items", map[string]any{ "page": "two", }); err == nil { t.Fatal("expected query parameter validation error, got nil") } select { case <-called: t.Fatal("expected validation to fail before the HTTP call") default: } } func TestOpenAPIClient_Bad_ValidatesPathParameterAgainstSchema(t *testing.T) { called := make(chan struct{}, 1) mux := http.NewServeMux() mux.HandleFunc("/users/123", func(w http.ResponseWriter, r *http.Request) { called <- struct{}{} w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`)) }) srv := httptest.NewServer(mux) defer srv.Close() specPath := writeTempSpec(t, `openapi: 3.1.0 info: title: Test API version: 1.0.0 paths: /users/{id}: get: operationId: get_user parameters: - name: id in: path required: true schema: type: integer `) client := api.NewOpenAPIClient( api.WithSpec(specPath), api.WithBaseURL(srv.URL), ) if _, err := client.Call("get_user", map[string]any{ "path": map[string]any{ "id": "abc", }, }); err == nil { t.Fatal("expected path parameter validation error, got nil") } select { case <-called: t.Fatal("expected validation to fail before the HTTP call") default: } } func TestOpenAPIClient_Good_UsesHeaderAndCookieParameters(t *testing.T) { errCh := make(chan error, 1) mux := http.NewServeMux() mux.HandleFunc("/inspect", 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.Header.Get("X-Trace-ID"); got != "trace-123" { errCh <- fmt.Errorf("expected X-Trace-ID=trace-123, got %q", got) w.WriteHeader(http.StatusInternalServerError) return } if got := r.Header.Get("X-Custom-Header"); got != "custom-value" { errCh <- fmt.Errorf("expected X-Custom-Header=custom-value, got %q", got) w.WriteHeader(http.StatusInternalServerError) return } session, err := r.Cookie("session_id") if err != nil { errCh <- fmt.Errorf("expected session_id cookie: %v", err) w.WriteHeader(http.StatusInternalServerError) return } if session.Value != "cookie-123" { errCh <- fmt.Errorf("expected session_id=cookie-123, got %q", session.Value) w.WriteHeader(http.StatusInternalServerError) return } pref, err := r.Cookie("pref") if err != nil { errCh <- fmt.Errorf("expected pref cookie: %v", err) w.WriteHeader(http.StatusInternalServerError) return } if pref.Value != "dark" { errCh <- fmt.Errorf("expected pref=dark, got %q", pref.Value) w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`)) }) srv := httptest.NewServer(mux) defer srv.Close() specPath := writeTempSpec(t, `openapi: 3.1.0 info: title: Test API version: 1.0.0 paths: /inspect: get: operationId: inspect_request parameters: - name: X-Trace-ID in: header - name: session_id in: cookie `) client := api.NewOpenAPIClient( api.WithSpec(specPath), api.WithBaseURL(srv.URL), ) result, err := client.Call("inspect_request", map[string]any{ "X-Trace-ID": "trace-123", "session_id": "cookie-123", "header": map[string]any{ "X-Custom-Header": "custom-value", }, "cookie": map[string]any{ "pref": "dark", }, }) if err != nil { t.Fatalf("unexpected error: %v", err) } select { case err := <-errCh: t.Fatal(err) default: } decoded, ok := result.(map[string]any) if !ok { t.Fatalf("expected map result, got %T", result) } if okValue, ok := decoded["ok"].(bool); !ok || !okValue { t.Fatalf("expected ok=true, got %#v", decoded["ok"]) } } func TestOpenAPIClient_Good_UsesFirstAbsoluteServer(t *testing.T) { errCh := make(chan error, 1) 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 } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"success":true,"data":{"message":"hello"}}`)) }) srv := httptest.NewServer(mux) defer srv.Close() specPath := writeTempSpec(t, `openapi: 3.1.0 info: title: Test API version: 1.0.0 servers: - url: " `+srv.URL+` " - url: / - url: " `+srv.URL+` " paths: /hello: get: operationId: get_hello `) client := api.NewOpenAPIClient( api.WithSpec(specPath), ) result, err := client.Call("get_hello", nil) 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"]) } } func TestOpenAPIClient_Bad_ValidatesRequestBodyAgainstSchema(t *testing.T) { called := make(chan struct{}, 1) mux := http.NewServeMux() mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) { called <- struct{}{} w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"success":true,"data":{"id":"123"}}`)) }) srv := httptest.NewServer(mux) defer srv.Close() specPath := writeTempSpec(t, `openapi: 3.1.0 info: title: Test API version: 1.0.0 paths: /users: post: operationId: create_user requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: type: string responses: "200": description: OK content: application/json: schema: type: object properties: success: type: boolean data: type: object properties: id: type: string `) client := api.NewOpenAPIClient( api.WithSpec(specPath), api.WithBaseURL(srv.URL), ) if _, err := client.Call("create_user", map[string]any{ "body": map[string]any{}, }); err == nil { t.Fatal("expected request body validation error, got nil") } select { case <-called: t.Fatal("expected request validation to fail before the HTTP call") default: } } func TestOpenAPIClient_Bad_ValidatesResponseAgainstSchema(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"success":true,"data":{"id":123}}`)) }) srv := httptest.NewServer(mux) defer srv.Close() specPath := writeTempSpec(t, `openapi: 3.1.0 info: title: Test API version: 1.0.0 paths: /users: get: operationId: list_users responses: "200": description: OK content: application/json: schema: type: object required: [success, data] properties: success: type: boolean data: type: object required: [id] properties: id: type: string `) client := api.NewOpenAPIClient( api.WithSpec(specPath), api.WithBaseURL(srv.URL), ) if _, err := client.Call("list_users", nil); err == nil { t.Fatal("expected response validation error, got nil") } } 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 }