diff --git a/cli/src/cmd/gateway/apply.go b/cli/src/cmd/gateway/apply.go index 94d248172a..2989e94faf 100644 --- a/cli/src/cmd/gateway/apply.go +++ b/cli/src/cmd/gateway/apply.go @@ -49,7 +49,7 @@ var ( var applyCmd = &cobra.Command{ Use: ApplyCmdLiteral, Short: "Apply a resource to the gateway", - Long: "Create or update a gateway resource (RestApi, Mcp, LlmProvider, LlmProxy) from a YAML or JSON file.", + Long: "Create or update a gateway resource (RestApi, Mcp, LlmProvider, LlmProxy, GraphQLApi) from a YAML or JSON file.", Example: ApplyCmdExample, Run: func(cmd *cobra.Command, args []string) { if err := runApplyCommand(cmd); err != nil { diff --git a/cli/src/cmd/gateway/graphqlapi/apikey/commands_test.go b/cli/src/cmd/gateway/graphqlapi/apikey/commands_test.go new file mode 100644 index 0000000000..b5c115dbf4 --- /dev/null +++ b/cli/src/cmd/gateway/graphqlapi/apikey/commands_test.go @@ -0,0 +1,442 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package apikey + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/internal/gateway" + "github.com/wso2/api-platform/cli/test/testutil" +) + +// newTestCommand mirrors graphqlapi's own helper: a bare *cobra.Command with +// the --platform/--gateway selection flags registered, which +// gateway.NewClientFromCommand reads to resolve the active gateway. +func newTestCommand() *cobra.Command { + cmd := &cobra.Command{} + gateway.AddSelectionFlags(cmd) + return cmd +} + +func writeGatewayConfig(t *testing.T, serverURL string) { + t.Helper() + testutil.WriteCLIConfig(t, &config.Config{ + CurrentPlatform: "default", + Platforms: map[string]*config.Platform{ + "default": { + Gateways: map[string]*config.Gateway{ + "test-gateway": { + Server: serverURL, + Auth: config.AuthConfig{Type: "none"}, + }, + }, + ActiveGateway: "test-gateway", + }, + }, + }) +} + +func TestRunCreateCommand_PostsToAPIKeysEndpoint(t *testing.T) { + testutil.WithTempHome(t) + + var gotMethod, gotPath string + var gotBody map[string]interface{} + server := testutil.NewGatewayServer(t, func(w http.ResponseWriter, req *http.Request) { + gotMethod = req.Method + gotPath = req.URL.Path + if err := json.NewDecoder(req.Body).Decode(&gotBody); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"status":"success","message":"API key generated successfully","apiKey":{"name":"smoke-key-1","apiKey":"apip_abc123"}}`)) + }) + writeGatewayConfig(t, server.URL) + + createAPIID = "countries-graphql-api" + createName = "smoke-key-1" + createExpiresInDuration = 0 + createExpiresInUnit = "" + + if err := runCreateCommand(newTestCommand()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotMethod != http.MethodPost { + t.Fatalf("expected POST request, got %s", gotMethod) + } + if gotPath != "/graphql-apis/countries-graphql-api/api-keys" { + t.Fatalf("unexpected request path %q", gotPath) + } + if gotBody["name"] != "smoke-key-1" { + t.Fatalf("expected request body name to be the --name flag value, got %v", gotBody["name"]) + } + if _, present := gotBody["expiresIn"]; present { + t.Fatalf("expected no expiresIn field when duration/unit are unset, got %v", gotBody) + } +} + +func TestRunCreateCommand_NameOmitted_NotSentInBody(t *testing.T) { + testutil.WithTempHome(t) + + var gotBody map[string]interface{} + server := testutil.NewGatewayServer(t, func(w http.ResponseWriter, req *http.Request) { + _ = json.NewDecoder(req.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"status":"success","apiKey":{"name":"auto-generated-name"}}`)) + }) + writeGatewayConfig(t, server.URL) + + createAPIID = "countries-graphql-api" + createName = "" + createExpiresInDuration = 0 + createExpiresInUnit = "" + + if err := runCreateCommand(newTestCommand()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, present := gotBody["name"]; present { + t.Fatalf("expected no 'name' field in the request body when --name is omitted, letting the server auto-generate one, got %v", gotBody) + } +} + +func TestRunCreateCommand_WithExpiresIn_SendsDurationAndUnit(t *testing.T) { + testutil.WithTempHome(t) + + var gotBody map[string]interface{} + server := testutil.NewGatewayServer(t, func(w http.ResponseWriter, req *http.Request) { + _ = json.NewDecoder(req.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"status":"success","apiKey":{"name":"smoke-key-1"}}`)) + }) + writeGatewayConfig(t, server.URL) + + createAPIID = "countries-graphql-api" + createName = "smoke-key-1" + createExpiresInDuration = 30 + createExpiresInUnit = "days" + + if err := runCreateCommand(newTestCommand()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + expiresIn, ok := gotBody["expiresIn"].(map[string]interface{}) + if !ok { + t.Fatalf("expected request body to contain an expiresIn object, got %v", gotBody) + } + if expiresIn["duration"] != float64(30) || expiresIn["unit"] != "days" { + t.Fatalf("expected expiresIn {duration: 30, unit: days}, got %v", expiresIn) + } +} + +func TestRunCreateCommand_RequiresID(t *testing.T) { + testutil.WithTempHome(t) + + createAPIID = "" + createName = "" + createExpiresInDuration = 0 + createExpiresInUnit = "" + + err := runCreateCommand(newTestCommand()) + if err == nil { + t.Fatal("expected an --id validation error, got nil") + } +} + +func TestRunCreateCommand_ExpiresInDurationWithoutUnit_Errors(t *testing.T) { + testutil.WithTempHome(t) + + createAPIID = "countries-graphql-api" + createName = "" + createExpiresInDuration = 30 + createExpiresInUnit = "" + + err := runCreateCommand(newTestCommand()) + if err == nil || !strings.Contains(err.Error(), "expires-in-unit") { + t.Fatalf("expected an error about --expires-in-unit being required alongside --expires-in-duration, got %v", err) + } +} + +func TestRunCreateCommand_InvalidExpiresInUnit_Errors(t *testing.T) { + testutil.WithTempHome(t) + + createAPIID = "countries-graphql-api" + createName = "" + createExpiresInDuration = 30 + createExpiresInUnit = "fortnights" + + err := runCreateCommand(newTestCommand()) + if err == nil || !strings.Contains(err.Error(), "fortnights") { + t.Fatalf("expected an invalid-unit error mentioning the bad value, got %v", err) + } +} + +func TestRunListCommand_CallsAPIKeysEndpoint(t *testing.T) { + testutil.WithTempHome(t) + + var gotPath string + server := testutil.NewGatewayServer(t, func(w http.ResponseWriter, req *http.Request) { + gotPath = req.URL.Path + if req.Method != http.MethodGet { + t.Fatalf("expected GET request, got %s", req.Method) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"success","totalCount":1,"apiKeys":[{"name":"smoke-key-1","apiId":"countries-graphql-api","status":"active"}]}`)) + }) + writeGatewayConfig(t, server.URL) + + listAPIID = "countries-graphql-api" + + if err := runListCommand(newTestCommand()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotPath != "/graphql-apis/countries-graphql-api/api-keys" { + t.Fatalf("unexpected request path %q", gotPath) + } +} + +func TestRunListCommand_RequiresID(t *testing.T) { + testutil.WithTempHome(t) + + listAPIID = "" + + err := runListCommand(newTestCommand()) + if err == nil { + t.Fatal("expected an --id validation error, got nil") + } +} + +func TestRunListCommand_NotFound(t *testing.T) { + testutil.WithTempHome(t) + + server := testutil.NewGatewayServer(t, func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + writeGatewayConfig(t, server.URL) + + listAPIID = "nonexistent" + + err := runListCommand(newTestCommand()) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected a not-found error, got %v", err) + } +} + +func TestRunRegenerateCommand_PostsToRegenerateEndpoint(t *testing.T) { + testutil.WithTempHome(t) + + var gotMethod, gotPath string + server := testutil.NewGatewayServer(t, func(w http.ResponseWriter, req *http.Request) { + gotMethod = req.Method + gotPath = req.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"success","apiKey":{"name":"smoke-key-1","apiKey":"apip_newvalue"}}`)) + }) + writeGatewayConfig(t, server.URL) + + regenerateAPIID = "countries-graphql-api" + regenerateKeyName = "smoke-key-1" + + if err := runRegenerateCommand(newTestCommand()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotMethod != http.MethodPost { + t.Fatalf("expected POST request, got %s", gotMethod) + } + if gotPath != "/graphql-apis/countries-graphql-api/api-keys/smoke-key-1/regenerate" { + t.Fatalf("unexpected request path %q", gotPath) + } +} + +func TestRunRegenerateCommand_RequiresIDAndKeyName(t *testing.T) { + testutil.WithTempHome(t) + + regenerateAPIID = "" + regenerateKeyName = "" + + if err := runRegenerateCommand(newTestCommand()); err == nil { + t.Fatal("expected an --id validation error, got nil") + } + + regenerateAPIID = "countries-graphql-api" + regenerateKeyName = "" + if err := runRegenerateCommand(newTestCommand()); err == nil { + t.Fatal("expected a --key-name validation error, got nil") + } +} + +func TestRunRegenerateCommand_NotFound(t *testing.T) { + testutil.WithTempHome(t) + + server := testutil.NewGatewayServer(t, func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + writeGatewayConfig(t, server.URL) + + regenerateAPIID = "nonexistent" + regenerateKeyName = "smoke-key-1" + + err := runRegenerateCommand(newTestCommand()) + if err == nil || !strings.Contains(err.Error(), "404") { + t.Fatalf("expected an error mentioning the 404 status, got %v", err) + } +} + +func TestRunUpdateCommand_PutsToAPIKeyEndpoint(t *testing.T) { + testutil.WithTempHome(t) + + var gotMethod, gotPath string + var gotBody map[string]interface{} + server := testutil.NewGatewayServer(t, func(w http.ResponseWriter, req *http.Request) { + gotMethod = req.Method + gotPath = req.URL.Path + _ = json.NewDecoder(req.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"success","apiKey":{"name":"smoke-key-1"}}`)) + }) + writeGatewayConfig(t, server.URL) + + updateAPIID = "countries-graphql-api" + updateKeyName = "smoke-key-1" + updateNewAPIKey = "external-key-value-that-is-at-least-36-characters-long" + + if err := runUpdateCommand(newTestCommand()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotMethod != http.MethodPut { + t.Fatalf("expected PUT request, got %s", gotMethod) + } + if gotPath != "/graphql-apis/countries-graphql-api/api-keys/smoke-key-1" { + t.Fatalf("unexpected request path %q", gotPath) + } + // The request body field must be "apiKey" - the server's + // APIKeyCreationRequest schema has no "name" field for this endpoint; + // sending "name" here would silently no-op server-side. + if gotBody["apiKey"] != updateNewAPIKey { + t.Fatalf(`expected request body {"apiKey": ...}, got %v`, gotBody) + } + if _, present := gotBody["name"]; present { + t.Fatalf("request body must not contain a 'name' field, got %v", gotBody) + } +} + +func TestRunUpdateCommand_RequiresAllFlags(t *testing.T) { + testutil.WithTempHome(t) + + updateAPIID, updateKeyName, updateNewAPIKey = "", "smoke-key-1", "value-value-value-value-value-value" + if err := runUpdateCommand(newTestCommand()); err == nil { + t.Fatal("expected an --id validation error, got nil") + } + + updateAPIID, updateKeyName, updateNewAPIKey = "countries-graphql-api", "", "value-value-value-value-value-value" + if err := runUpdateCommand(newTestCommand()); err == nil { + t.Fatal("expected a --key-name validation error, got nil") + } + + updateAPIID, updateKeyName, updateNewAPIKey = "countries-graphql-api", "smoke-key-1", "" + if err := runUpdateCommand(newTestCommand()); err == nil { + t.Fatal("expected an --api-key validation error, got nil") + } +} + +// TestRunUpdateCommand_RejectsLocallyGeneratedKey guards the real business rule +// surfaced during manual verification of this feature: the gateway rejects +// updating a locally-generated key (only regenerate is allowed for those) with +// a 400, which the CLI must surface as an error, not silently succeed. +func TestRunUpdateCommand_RejectsLocallyGeneratedKey(t *testing.T) { + testutil.WithTempHome(t) + + server := testutil.NewGatewayServer(t, func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"status":"error","message":"operation not allowed: updates are only allowed for externally generated API keys"}`)) + }) + writeGatewayConfig(t, server.URL) + + updateAPIID = "countries-graphql-api" + updateKeyName = "smoke-key-1" + updateNewAPIKey = "external-key-value-that-is-at-least-36-characters-long" + + err := runUpdateCommand(newTestCommand()) + if err == nil || !strings.Contains(err.Error(), "400") { + t.Fatalf("expected an error mentioning the 400 status, got %v", err) + } +} + +func TestRunRevokeCommand_DeletesAPIKeyEndpoint(t *testing.T) { + testutil.WithTempHome(t) + + var gotMethod, gotPath string + server := testutil.NewGatewayServer(t, func(w http.ResponseWriter, req *http.Request) { + gotMethod = req.Method + gotPath = req.URL.Path + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"success"}`)) + }) + writeGatewayConfig(t, server.URL) + + revokeAPIID = "countries-graphql-api" + revokeKeyName = "smoke-key-1" + + if err := runRevokeCommand(newTestCommand()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotMethod != http.MethodDelete { + t.Fatalf("expected DELETE request, got %s", gotMethod) + } + if gotPath != "/graphql-apis/countries-graphql-api/api-keys/smoke-key-1" { + t.Fatalf("unexpected request path %q", gotPath) + } +} + +func TestRunRevokeCommand_RequiresIDAndKeyName(t *testing.T) { + testutil.WithTempHome(t) + + revokeAPIID, revokeKeyName = "", "smoke-key-1" + if err := runRevokeCommand(newTestCommand()); err == nil { + t.Fatal("expected an --id validation error, got nil") + } + + revokeAPIID, revokeKeyName = "countries-graphql-api", "" + if err := runRevokeCommand(newTestCommand()); err == nil { + t.Fatal("expected a --key-name validation error, got nil") + } +} + +func TestRunRevokeCommand_NotFound(t *testing.T) { + testutil.WithTempHome(t) + + server := testutil.NewGatewayServer(t, func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + writeGatewayConfig(t, server.URL) + + revokeAPIID = "countries-graphql-api" + revokeKeyName = "nonexistent" + + err := runRevokeCommand(newTestCommand()) + if err == nil || !strings.Contains(err.Error(), "404") { + t.Fatalf("expected an error mentioning the 404 status, got %v", err) + } +} diff --git a/cli/src/cmd/gateway/graphqlapi/apikey/create.go b/cli/src/cmd/gateway/graphqlapi/apikey/create.go new file mode 100644 index 0000000000..6697de7ec2 --- /dev/null +++ b/cli/src/cmd/gateway/graphqlapi/apikey/create.go @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package apikey + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/gateway" + "github.com/wso2/api-platform/cli/utils" +) + +// validExpiresInUnits mirrors gateway-controller's +// APIKeyCreationRequestExpiresInUnit enum (pkg/utils/api_key.go) — the only +// units the server accepts for expiresIn.unit. +var validExpiresInUnits = map[string]bool{ + "seconds": true, + "minutes": true, + "hours": true, + "days": true, + "weeks": true, + "months": true, +} + +const ( + CreateCmdLiteral = "create" + CreateCmdExample = `# Generate an API key with an auto-generated name that never expires +ap gateway graphql-api api-key create --id countries-graphql-api + +# Generate a named API key that expires in 30 days +ap gateway graphql-api api-key create --id countries-graphql-api --name my-production-key --expires-in-duration 30 --expires-in-unit days` +) + +var ( + createAPIID string + createName string + createExpiresInDuration int + createExpiresInUnit string +) + +var createCmd = &cobra.Command{ + Use: CreateCmdLiteral, + Short: "Generate an API key for a GraphQL API", + Long: "Generates a new API key for a GraphQL API. --name is optional — if omitted, the server generates a unique name. --expires-in-duration and --expires-in-unit must be supplied together to set an expiry; omit both for a key that never expires. The plaintext key is returned once in the response.", + Example: CreateCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runCreateCommand(cmd); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + gateway.AddSelectionFlags(createCmd) + utils.AddStringFlag(createCmd, utils.FlagID, &createAPIID, "", "GraphQL API ID (required)") + utils.AddStringFlag(createCmd, utils.FlagPropertyName, &createName, "", "Name for the API key. Omit to let the server generate a unique name.") + utils.AddIntFlag(createCmd, utils.FlagExpiresInDuration, &createExpiresInDuration, 0, "Expiry duration; must be paired with --expires-in-unit. Omit both for a key that never expires.") + utils.AddStringFlag(createCmd, utils.FlagExpiresInUnit, &createExpiresInUnit, "", "Expiry duration unit: seconds, minutes, hours, days, weeks, or months. Must be paired with --expires-in-duration.") + createCmd.MarkFlagRequired(utils.FlagID) +} + +func runCreateCommand(cmd *cobra.Command) error { + if strings.TrimSpace(createAPIID) == "" { + return fmt.Errorf("--%s is required", utils.FlagID) + } + + // A duration of 0 / an empty unit both mean "not set" - there is no + // meaningful key that expires in 0 seconds, so treating either as unset + // requires the pair to be supplied together rather than one silently + // defaulting the other. + durationSet := createExpiresInDuration != 0 + unitSet := strings.TrimSpace(createExpiresInUnit) != "" + if durationSet != unitSet { + return fmt.Errorf("--%s and --%s must be provided together", utils.FlagExpiresInDuration, utils.FlagExpiresInUnit) + } + + body := map[string]interface{}{} + if name := strings.TrimSpace(createName); name != "" { + body["name"] = name + } + if durationSet { + unit := strings.ToLower(strings.TrimSpace(createExpiresInUnit)) + if !validExpiresInUnits[unit] { + return fmt.Errorf("invalid --%s %q: must be one of seconds, minutes, hours, days, weeks, months", utils.FlagExpiresInUnit, createExpiresInUnit) + } + body["expiresIn"] = map[string]interface{}{ + "duration": createExpiresInDuration, + "unit": unit, + } + } + + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to build API key payload: %w", err) + } + + client, err := gateway.NewClientFromCommand(cmd) + if err != nil { + return err + } + + // Client.Post already treats any non-2xx status as an error (via + // formatHTTPError) and returns a nil *http.Response in that case, so there + // is no status code left to branch on once err is nil. + endpoint := fmt.Sprintf(utils.GatewayGraphQLAPIKeysPath, url.PathEscape(createAPIID)) + resp, err := client.Post(endpoint, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("failed to create API key: %w", err) + } + + fmt.Println("API key generated successfully.") + return gateway.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/gateway/graphqlapi/apikey/list.go b/cli/src/cmd/gateway/graphqlapi/apikey/list.go new file mode 100644 index 0000000000..40fbd0e441 --- /dev/null +++ b/cli/src/cmd/gateway/graphqlapi/apikey/list.go @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package apikey + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/gateway" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + ListCmdLiteral = "list" + ListCmdExample = `# List all API keys for a GraphQL API +ap gateway graphql-api api-key list --id countries-graphql-api` +) + +var listAPIID string + +var listCmd = &cobra.Command{ + Use: ListCmdLiteral, + Short: "List API keys for a GraphQL API", + Long: "Retrieves and displays all API keys for a GraphQL API on the currently active gateway.", + Example: ListCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runListCommand(cmd); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + gateway.AddSelectionFlags(listCmd) + utils.AddStringFlag(listCmd, utils.FlagID, &listAPIID, "", "GraphQL API ID (required)") + listCmd.MarkFlagRequired(utils.FlagID) +} + +// APIKey is a list-view projection of an API key. The plaintext apiKey value is +// only present on create/regenerate responses, so it is intentionally omitted +// from the list table. +type APIKey struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + APIID string `json:"apiId"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + ExpiresAt string `json:"expiresAt"` +} + +// APIKeyListResponse represents the response from GET /graphql-apis/{id}/api-keys. +type APIKeyListResponse struct { + APIKeys []APIKey `json:"apiKeys"` + TotalCount int `json:"totalCount"` + Status string `json:"status"` +} + +func runListCommand(cmd *cobra.Command) error { + if strings.TrimSpace(listAPIID) == "" { + return fmt.Errorf("--%s is required", utils.FlagID) + } + + client, err := gateway.NewClientFromCommand(cmd) + if err != nil { + return err + } + + endpoint := fmt.Sprintf(utils.GatewayGraphQLAPIKeysPath, url.PathEscape(listAPIID)) + resp, err := client.Get(endpoint) + if err != nil { + return fmt.Errorf("failed to call %s endpoint: %w", endpoint, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("GraphQL API with ID '%s' not found", listAPIID) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to list API keys (status %d): %s", resp.StatusCode, string(body)) + } + + var listResp APIKeyListResponse + if err := json.Unmarshal(body, &listResp); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + if len(listResp.APIKeys) == 0 { + fmt.Printf("No API keys found for GraphQL API '%s'.\n", listAPIID) + return nil + } + + headers := []string{"NAME", "DISPLAY_NAME", "API_ID", "STATUS", "CREATED_AT", "EXPIRES_AT"} + rows := make([][]string, 0, len(listResp.APIKeys)) + for _, k := range listResp.APIKeys { + rows = append(rows, []string{k.Name, k.DisplayName, k.APIID, k.Status, k.CreatedAt, k.ExpiresAt}) + } + utils.PrintTable(headers, rows) + + return nil +} diff --git a/cli/src/cmd/gateway/graphqlapi/apikey/regenerate.go b/cli/src/cmd/gateway/graphqlapi/apikey/regenerate.go new file mode 100644 index 0000000000..4812054a5e --- /dev/null +++ b/cli/src/cmd/gateway/graphqlapi/apikey/regenerate.go @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package apikey + +import ( + "bytes" + "fmt" + "net/url" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/gateway" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + RegenerateCmdLiteral = "regenerate" + RegenerateCmdExample = `# Regenerate an API key, replacing its previous value +ap gateway graphql-api api-key regenerate --id countries-graphql-api --key-name my-production-key` +) + +var ( + regenerateAPIID string + regenerateKeyName string +) + +var regenerateCmd = &cobra.Command{ + Use: RegenerateCmdLiteral, + Short: "Regenerate an API key for a GraphQL API", + Long: "Creates a new API key value replacing the previous one. The new plaintext key is returned once in the response.", + Example: RegenerateCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runRegenerateCommand(cmd); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + gateway.AddSelectionFlags(regenerateCmd) + utils.AddStringFlag(regenerateCmd, utils.FlagID, ®enerateAPIID, "", "GraphQL API ID (required)") + utils.AddStringFlag(regenerateCmd, utils.FlagKeyName, ®enerateKeyName, "", "Name of the API key to regenerate (required)") + regenerateCmd.MarkFlagRequired(utils.FlagID) + regenerateCmd.MarkFlagRequired(utils.FlagKeyName) +} + +func runRegenerateCommand(cmd *cobra.Command) error { + if strings.TrimSpace(regenerateAPIID) == "" { + return fmt.Errorf("--%s is required", utils.FlagID) + } + if strings.TrimSpace(regenerateKeyName) == "" { + return fmt.Errorf("--%s is required", utils.FlagKeyName) + } + + client, err := gateway.NewClientFromCommand(cmd) + if err != nil { + return err + } + + // Client.Post already treats any non-2xx status as an error and returns a + // nil *http.Response in that case, so err == nil here always means success. + endpoint := fmt.Sprintf(utils.GatewayGraphQLAPIKeyRegeneratePath, url.PathEscape(regenerateAPIID), url.PathEscape(regenerateKeyName)) + resp, err := client.Post(endpoint, bytes.NewReader([]byte("{}"))) + if err != nil { + return fmt.Errorf("failed to regenerate API key: %w", err) + } + + fmt.Println("API key regenerated successfully.") + return gateway.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/gateway/graphqlapi/apikey/revoke.go b/cli/src/cmd/gateway/graphqlapi/apikey/revoke.go new file mode 100644 index 0000000000..5435db1f42 --- /dev/null +++ b/cli/src/cmd/gateway/graphqlapi/apikey/revoke.go @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package apikey + +import ( + "fmt" + "net/url" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/gateway" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + RevokeCmdLiteral = "revoke" + RevokeCmdExample = `# Revoke an API key +ap gateway graphql-api api-key revoke --id countries-graphql-api --key-name my-production-key` +) + +var ( + revokeAPIID string + revokeKeyName string +) + +var revokeCmd = &cobra.Command{ + Use: RevokeCmdLiteral, + Short: "Revoke an API key for a GraphQL API", + Long: "Invalidates an API key so it can no longer be used for authentication.", + Example: RevokeCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runRevokeCommand(cmd); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + gateway.AddSelectionFlags(revokeCmd) + utils.AddStringFlag(revokeCmd, utils.FlagID, &revokeAPIID, "", "GraphQL API ID (required)") + utils.AddStringFlag(revokeCmd, utils.FlagKeyName, &revokeKeyName, "", "Name of the API key to revoke (required)") + revokeCmd.MarkFlagRequired(utils.FlagID) + revokeCmd.MarkFlagRequired(utils.FlagKeyName) +} + +func runRevokeCommand(cmd *cobra.Command) error { + if strings.TrimSpace(revokeAPIID) == "" { + return fmt.Errorf("--%s is required", utils.FlagID) + } + if strings.TrimSpace(revokeKeyName) == "" { + return fmt.Errorf("--%s is required", utils.FlagKeyName) + } + + client, err := gateway.NewClientFromCommand(cmd) + if err != nil { + return err + } + + // Client.Delete already treats any non-2xx status as an error and returns a + // nil *http.Response in that case, so err == nil here always means success. + endpoint := fmt.Sprintf(utils.GatewayGraphQLAPIKeyByNamePath, url.PathEscape(revokeAPIID), url.PathEscape(revokeKeyName)) + resp, err := client.Delete(endpoint) + if err != nil { + return fmt.Errorf("failed to revoke API key: %w", err) + } + resp.Body.Close() + + fmt.Println("API key revoked successfully.") + return nil +} diff --git a/cli/src/cmd/gateway/graphqlapi/apikey/root.go b/cli/src/cmd/gateway/graphqlapi/apikey/root.go new file mode 100644 index 0000000000..1f71c28211 --- /dev/null +++ b/cli/src/cmd/gateway/graphqlapi/apikey/root.go @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package apikey + +import ( + "github.com/spf13/cobra" +) + +const ( + APIKeyCmdLiteral = "api-key" + APIKeyCmdExample = `# List API keys for a GraphQL API +ap gateway graphql-api api-key list --id countries-graphql-api + +# Generate a new API key with an auto-generated name +ap gateway graphql-api api-key create --id countries-graphql-api` +) + +// APIKeyCmd represents the gateway GraphQL API api-key command group. API keys +// are scoped to a GraphQL API via the /graphql-apis/{id}/api-keys management +// endpoints. +var APIKeyCmd = &cobra.Command{ + Use: APIKeyCmdLiteral, + Short: "Manage API keys for a GraphQL API on the gateway", + Long: "This command allows you to create, list, regenerate, update, and revoke API keys for a GraphQL API on the WSO2 API Platform Gateway.", + Example: APIKeyCmdExample, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + APIKeyCmd.AddCommand(createCmd) + APIKeyCmd.AddCommand(listCmd) + APIKeyCmd.AddCommand(regenerateCmd) + APIKeyCmd.AddCommand(updateCmd) + APIKeyCmd.AddCommand(revokeCmd) +} diff --git a/cli/src/cmd/gateway/graphqlapi/apikey/update.go b/cli/src/cmd/gateway/graphqlapi/apikey/update.go new file mode 100644 index 0000000000..a3e4d32c0b --- /dev/null +++ b/cli/src/cmd/gateway/graphqlapi/apikey/update.go @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package apikey + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/gateway" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + UpdateCmdLiteral = "update" + UpdateCmdExample = `# Replace an API key's value with a custom, externally generated one +ap gateway graphql-api api-key update --id countries-graphql-api --key-name my-production-key --api-key <36+ character value>` +) + +var ( + updateAPIID string + updateKeyName string + updateNewAPIKey string +) + +var updateCmd = &cobra.Command{ + Use: UpdateCmdLiteral, + Short: "Update an API key for a GraphQL API", + Long: "Replaces an existing API key's value with a custom plain-text value instead of an auto-generated one. The key must be at least 36 characters. It is hashed before storage; the plaintext is not echoed back.", + Example: UpdateCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runUpdateCommand(cmd); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + gateway.AddSelectionFlags(updateCmd) + utils.AddStringFlag(updateCmd, utils.FlagID, &updateAPIID, "", "GraphQL API ID (required)") + utils.AddStringFlag(updateCmd, utils.FlagKeyName, &updateKeyName, "", "Name of the API key to update (required)") + utils.AddStringFlag(updateCmd, utils.FlagAPIKey, &updateNewAPIKey, "", "New plain-text API key value, minimum 36 characters. Deprecated: leave unset to be prompted securely instead of passing the key on the command line.") + updateCmd.MarkFlagRequired(utils.FlagID) + updateCmd.MarkFlagRequired(utils.FlagKeyName) +} + +func runUpdateCommand(cmd *cobra.Command) error { + if strings.TrimSpace(updateAPIID) == "" { + return fmt.Errorf("--%s is required", utils.FlagID) + } + if strings.TrimSpace(updateKeyName) == "" { + return fmt.Errorf("--%s is required", utils.FlagKeyName) + } + if strings.TrimSpace(updateNewAPIKey) == "" { + // Avoid accepting the plaintext key as a CLI argument (visible in shell + // history/process listings) when the operator didn't explicitly opt + // into the deprecated --api-key flag. + prompted, err := utils.PromptPassword("New API key value (min 36 characters): ") + if err != nil { + return fmt.Errorf("failed to read API key: %w", err) + } + updateNewAPIKey = prompted + } + if strings.TrimSpace(updateNewAPIKey) == "" { + return fmt.Errorf("--%s is required", utils.FlagAPIKey) + } + + // The server persists this as the key's hash; the request body field is + // "apiKey" per APIKeyCreationRequest — never "name" (renaming a key is not + // what this endpoint does). + payload := map[string]string{"apiKey": strings.TrimSpace(updateNewAPIKey)} + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to build API key payload: %w", err) + } + + client, err := gateway.NewClientFromCommand(cmd) + if err != nil { + return err + } + + // Client.Put already treats any non-2xx status as an error and returns a + // nil *http.Response in that case, so err == nil here always means success. + endpoint := fmt.Sprintf(utils.GatewayGraphQLAPIKeyByNamePath, url.PathEscape(updateAPIID), url.PathEscape(updateKeyName)) + resp, err := client.Put(endpoint, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("failed to update API key: %w", err) + } + + fmt.Println("API key updated successfully.") + return gateway.PrintJSONResponse(resp) +} diff --git a/cli/src/cmd/gateway/graphqlapi/commands_test.go b/cli/src/cmd/gateway/graphqlapi/commands_test.go new file mode 100644 index 0000000000..28c30f02cd --- /dev/null +++ b/cli/src/cmd/gateway/graphqlapi/commands_test.go @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package graphqlapi + +import ( + "net/http" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/config" + "github.com/wso2/api-platform/cli/internal/gateway" + "github.com/wso2/api-platform/cli/test/testutil" +) + +// newTestCommand builds a bare *cobra.Command with the --platform/--gateway +// selection flags registered, matching what every real graphql-api subcommand +// gets via gateway.AddSelectionFlags in its own init(). NewClientFromCommand +// reads those flags, so a command missing them would resolve against whatever +// is "active" in config regardless of intent - tests leave them unset to +// exercise the same active-gateway fallback real usage relies on. +func newTestCommand() *cobra.Command { + cmd := &cobra.Command{} + gateway.AddSelectionFlags(cmd) + return cmd +} + +// writeGatewayConfig points the active gateway (platform "default") at the +// given test server URL with no authentication, and returns the config path. +func writeGatewayConfig(t *testing.T, serverURL string) { + t.Helper() + testutil.WriteCLIConfig(t, &config.Config{ + CurrentPlatform: "default", + Platforms: map[string]*config.Platform{ + "default": { + Gateways: map[string]*config.Gateway{ + "test-gateway": { + Server: serverURL, + Auth: config.AuthConfig{Type: "none"}, + }, + }, + ActiveGateway: "test-gateway", + }, + }, + }) +} + +func TestRunListCommand_CallsGraphQLAPIsEndpoint(t *testing.T) { + testutil.WithTempHome(t) + + var gotPath string + server := testutil.NewGatewayServer(t, func(w http.ResponseWriter, req *http.Request) { + gotPath = req.URL.Path + if req.Method != http.MethodGet { + t.Fatalf("expected GET request, got %s", req.Method) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"success","count":0,"graphqlApis":[]}`)) + }) + writeGatewayConfig(t, server.URL) + + if err := runListCommand(newTestCommand()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotPath != "/graphql-apis" { + t.Fatalf("unexpected request path %q", gotPath) + } +} + +func TestRunListCommand_NotFoundTreatedAsEmpty(t *testing.T) { + testutil.WithTempHome(t) + + server := testutil.NewGatewayServer(t, func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + writeGatewayConfig(t, server.URL) + + if err := runListCommand(newTestCommand()); err != nil { + t.Fatalf("expected 404 to be treated as an empty list, got error: %v", err) + } +} + +func TestRunGetCommand_ByID(t *testing.T) { + testutil.WithTempHome(t) + + var gotPath string + server := testutil.NewGatewayServer(t, func(w http.ResponseWriter, req *http.Request) { + gotPath = req.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"apiVersion":"gateway.api-platform.wso2.com/v1","kind":"GraphQLApi","metadata":{"name":"countries-graphql-api"},"spec":{"displayName":"Countries","version":"v1","context":"/countries"},"status":{"id":"countries-graphql-api"}}`)) + }) + writeGatewayConfig(t, server.URL) + + getAPIID = "countries-graphql-api" + getAPIName = "" + getAPIVersion = "" + getAPIFormat = "json" + + if err := runGetCommand(newTestCommand()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotPath != "/graphql-apis/countries-graphql-api" { + t.Fatalf("unexpected request path %q", gotPath) + } +} + +func TestRunGetCommand_ByDisplayNameAndVersion(t *testing.T) { + testutil.WithTempHome(t) + + var gotPaths []string + server := testutil.NewGatewayServer(t, func(w http.ResponseWriter, req *http.Request) { + gotPaths = append(gotPaths, req.URL.RequestURI()) + w.Header().Set("Content-Type", "application/json") + if req.URL.Path == "/graphql-apis" { + // The list-by-filter lookup must query displayName, not "name" - + // the server only ever supported a displayName filter (confirmed + // against the generated ListGraphQLAPIsParams struct); a "name" + // query param would silently return everything unfiltered. + if got := req.URL.Query().Get("displayName"); got != "Countries GraphQL API" { + t.Fatalf("expected displayName query param, got query %q", req.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"status":"success","count":1,"graphqlApis":[{"metadata":{"name":"countries-graphql-api"},"spec":{},"status":{"id":"countries-graphql-api"}}]}`)) + return + } + _, _ = w.Write([]byte(`{"apiVersion":"gateway.api-platform.wso2.com/v1","kind":"GraphQLApi","metadata":{"name":"countries-graphql-api"},"spec":{},"status":{"id":"countries-graphql-api"}}`)) + }) + writeGatewayConfig(t, server.URL) + + getAPIID = "" + getAPIName = "Countries GraphQL API" + getAPIVersion = "v1" + getAPIFormat = "json" + + if err := runGetCommand(newTestCommand()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(gotPaths) != 2 { + t.Fatalf("expected a list lookup followed by a get-by-id call, got %v", gotPaths) + } +} + +func TestRunGetCommand_RequiresIDOrName(t *testing.T) { + testutil.WithTempHome(t) + + getAPIID = "" + getAPIName = "" + getAPIVersion = "" + getAPIFormat = "json" + + err := runGetCommand(newTestCommand()) + if err == nil || err.Error() != "either --id or --display-name (with --version) must be specified" { + t.Fatalf("expected id/name validation error, got %v", err) + } +} + +func TestRunGetCommand_RejectsInvalidFormat(t *testing.T) { + testutil.WithTempHome(t) + + getAPIID = "countries-graphql-api" + getAPIName = "" + getAPIVersion = "" + getAPIFormat = "xml" + + err := runGetCommand(newTestCommand()) + if err == nil { + t.Fatal("expected an invalid-format error, got nil") + } +} + +func TestRunDeleteCommand_CallsDeleteByID(t *testing.T) { + testutil.WithTempHome(t) + + var gotMethod, gotPath string + server := testutil.NewGatewayServer(t, func(w http.ResponseWriter, req *http.Request) { + gotMethod = req.Method + gotPath = req.URL.Path + w.WriteHeader(http.StatusNoContent) + }) + writeGatewayConfig(t, server.URL) + + deleteAPIID = "countries-graphql-api" + + if err := runDeleteCommand(newTestCommand()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotMethod != http.MethodDelete { + t.Fatalf("expected DELETE request, got %s", gotMethod) + } + if gotPath != "/graphql-apis/countries-graphql-api" { + t.Fatalf("unexpected request path %q", gotPath) + } +} + +// TestRunDeleteCommand_NotFound guards Client.Delete's actual contract: any +// non-2xx status (including 404) comes back as a non-nil error with resp==nil, +// so the error text is whatever formatHTTPError produces, not a bespoke +// "not found" message built from a status-code check on resp (which would be +// unreachable dead code once err is non-nil). +func TestRunDeleteCommand_NotFound(t *testing.T) { + testutil.WithTempHome(t) + + server := testutil.NewGatewayServer(t, func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + writeGatewayConfig(t, server.URL) + + deleteAPIID = "nonexistent" + + err := runDeleteCommand(newTestCommand()) + if err == nil { + t.Fatal("expected an error for a 404 response, got nil") + } + if !strings.Contains(err.Error(), "404") || !strings.Contains(err.Error(), "nonexistent") { + t.Fatalf("expected error to mention the 404 status and the API ID, got %v", err) + } +} diff --git a/cli/src/cmd/gateway/graphqlapi/delete.go b/cli/src/cmd/gateway/graphqlapi/delete.go new file mode 100644 index 0000000000..48f063e016 --- /dev/null +++ b/cli/src/cmd/gateway/graphqlapi/delete.go @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package graphqlapi + +import ( + "fmt" + "net/url" + "os" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/gateway" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + DeleteCmdLiteral = "delete" + DeleteCmdExample = `# Delete a GraphQL API by ID +ap gateway graphql-api delete --id countries-graphql-api` +) + +var ( + deleteAPIID string +) + +var deleteCmd = &cobra.Command{ + Use: DeleteCmdLiteral, + Short: "Delete a GraphQL API from the gateway", + Long: "Deletes a specific GraphQL API from the gateway by ID.", + Example: DeleteCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runDeleteCommand(cmd); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + gateway.AddSelectionFlags(deleteCmd) + utils.AddStringFlag(deleteCmd, utils.FlagID, &deleteAPIID, "", "GraphQL API ID (handle) to delete") + deleteCmd.MarkFlagRequired(utils.FlagID) +} + +func runDeleteCommand(cmd *cobra.Command) error { + // Proceed with deletion (no confirm flag required) + + // Create a client for the active gateway + client, err := gateway.NewClientFromCommand(cmd) + if err != nil { + return err + } + + // Call the DELETE endpoint. Client.Delete already treats any non-2xx status + // as an error (formatted via formatHTTPError, including the status code and + // response body) and returns a nil *http.Response in that case - so there is + // no status code left to branch on below; a 404 surfaces through err here. + resp, err := client.Delete(fmt.Sprintf(utils.GatewayGraphQLAPIByIDPath, url.PathEscape(deleteAPIID))) + if err != nil { + return fmt.Errorf("failed to delete GraphQL API: %w", err) + } + defer resp.Body.Close() + + fmt.Println("GraphQL API deleted successfully.") + return nil +} diff --git a/cli/src/cmd/gateway/graphqlapi/get.go b/cli/src/cmd/gateway/graphqlapi/get.go new file mode 100644 index 0000000000..4769f8a45c --- /dev/null +++ b/cli/src/cmd/gateway/graphqlapi/get.go @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package graphqlapi + +import ( + "encoding/json" + "fmt" + "io" + "net/url" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/gateway" + "github.com/wso2/api-platform/cli/utils" + "gopkg.in/yaml.v3" +) + +const ( + GetCmdLiteral = "get" + GetCmdExample = `# Get GraphQL API by ID +ap gateway graphql-api get --id countries-graphql-api --format yaml + +# Get GraphQL API by display name and version +ap gateway graphql-api get --display-name "Countries GraphQL API" --version v1.0 --format json` +) + +var ( + getAPIID string + getAPIName string + getAPIVersion string + getAPIFormat string +) + +var getCmd = &cobra.Command{ + Use: GetCmdLiteral, + Short: "Get a specific GraphQL API from the gateway", + Long: "Retrieves a specific GraphQL API by ID or by display name and version, with optional output formatting.", + Example: GetCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runGetCommand(cmd); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + gateway.AddSelectionFlags(getCmd) + utils.AddStringFlag(getCmd, utils.FlagID, &getAPIID, "", "GraphQL API ID (handle)") + utils.AddStringFlag(getCmd, utils.FlagName, &getAPIName, "", "GraphQL API display name") + utils.AddStringFlag(getCmd, utils.FlagVersion, &getAPIVersion, "", "GraphQL API version") + utils.AddStringFlag(getCmd, utils.FlagFormat, &getAPIFormat, "yaml", "Output format (json or yaml)") +} + +// APIGetResponse represents the response from GET /graphql-apis/{id}. +// +// Under the current management API the response body is the k8s-shaped resource +// itself: {apiVersion, kind, metadata, spec, status}. We keep this around as a +// convenience alias so callers can reason about the resource body shape. +type APIGetResponse map[string]interface{} + +func runGetCommand(cmd *cobra.Command) error { + // Validate flags + if getAPIID == "" && getAPIName == "" { + return fmt.Errorf("either --id or --display-name (with --version) must be specified") + } + + if getAPIID != "" && getAPIName != "" { + return fmt.Errorf("cannot specify both --id and --display-name") + } + + if getAPIName != "" && getAPIVersion == "" { + return fmt.Errorf("--version is required when using --display-name") + } + + // Validate format + getAPIFormat = strings.ToLower(getAPIFormat) + if getAPIFormat != "json" && getAPIFormat != "yaml" { + return fmt.Errorf("invalid format: %s (must be 'json' or 'yaml')", getAPIFormat) + } + + // Create a client for the selected (or active) gateway + client, err := gateway.NewClientFromCommand(cmd) + if err != nil { + return err + } + + var apiConfig map[string]interface{} + + if getAPIID != "" { + // Get by ID + apiConfig, err = getAPIByID(client, getAPIID) + if err != nil { + return err + } + } else { + // Get by display name and version + apiConfig, err = getAPIByNameAndVersion(client, getAPIName, getAPIVersion) + if err != nil { + return err + } + } + + // Format and display the output + return displayAPI(apiConfig, getAPIFormat) +} + +func getAPIByID(client *gateway.Client, id string) (map[string]interface{}, error) { + resp, err := client.Get(fmt.Sprintf(utils.GatewayGraphQLAPIByIDPath, url.PathEscape(id))) + if err != nil { + return nil, fmt.Errorf("failed to call %s endpoint: %w", fmt.Sprintf(utils.GatewayGraphQLAPIByIDPath, id), err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode == 404 { + return nil, fmt.Errorf("GraphQL API with ID '%s' not found", id) + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("failed to get GraphQL API (status %d): %s", resp.StatusCode, string(body)) + } + + var getResp APIGetResponse + if err := json.Unmarshal(body, &getResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // The response is the resource body itself. Drop the server-managed status + // block so the display matches the declarative source the user applied. + delete(getResp, "status") + return getResp, nil +} + +func getAPIByNameAndVersion(client *gateway.Client, name, version string) (map[string]interface{}, error) { + // Build query string. The list endpoint filters on displayName/version. + query := url.Values{} + query.Set("displayName", name) + query.Set("version", version) + + resp, err := client.Get(utils.GatewayGraphQLAPIsPath + "?" + query.Encode()) + if err != nil { + return nil, fmt.Errorf("failed to call %s endpoint: %w", utils.GatewayGraphQLAPIsPath, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("failed to get GraphQL API (status %d): %s", resp.StatusCode, string(body)) + } + + var listResp APIListResponse + if err := json.Unmarshal(body, &listResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if listResp.Count == 0 { + return nil, fmt.Errorf("GraphQL API with display name '%s' and version '%s' not found", name, version) + } + + if listResp.Count > 1 { + return nil, fmt.Errorf("multiple GraphQL APIs found with display name '%s' and version '%s' (found %d)", name, version, listResp.Count) + } + + // Get the full API configuration using the ID + return getAPIByID(client, listResp.GraphQLAPIs[0].ID()) +} + +func displayAPI(apiConfig map[string]interface{}, format string) error { + var output []byte + var err error + + switch format { + case "json": + output, err = json.MarshalIndent(apiConfig, "", " ") + if err != nil { + return fmt.Errorf("failed to format as JSON: %w", err) + } + case "yaml": + output, err = yaml.Marshal(apiConfig) + if err != nil { + return fmt.Errorf("failed to format as YAML: %w", err) + } + } + + fmt.Println(string(output)) + return nil +} diff --git a/cli/src/cmd/gateway/graphqlapi/list.go b/cli/src/cmd/gateway/graphqlapi/list.go new file mode 100644 index 0000000000..766d2185b5 --- /dev/null +++ b/cli/src/cmd/gateway/graphqlapi/list.go @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package graphqlapi + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/internal/gateway" + "github.com/wso2/api-platform/cli/utils" +) + +const ( + ListCmdLiteral = "list" + ListCmdExample = `# List all GraphQL APIs +ap gateway graphql-api list` +) + +var listCmd = &cobra.Command{ + Use: ListCmdLiteral, + Short: "List all GraphQL APIs on the gateway", + Long: "Retrieves and displays all GraphQL APIs deployed on the currently active gateway.", + Example: ListCmdExample, + Run: func(cmd *cobra.Command, args []string) { + if err := runListCommand(cmd); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +// APIListItem is a list-view projection of a GraphQLAPI. The management API list +// response returns each item as a full k8s-shaped resource body — we flatten +// the fields we care about out of `metadata`, `spec` and `status` here. +type APIListItem struct { + // Full resource body as returned by the server. Kept for display/debugging. + Metadata map[string]interface{} `json:"metadata"` + Spec map[string]interface{} `json:"spec"` + Status map[string]interface{} `json:"status"` +} + +// ID returns the server-assigned id (status.id) falling back to metadata.name. +func (i APIListItem) ID() string { + if v, ok := i.Status["id"].(string); ok && v != "" { + return v + } + if v, ok := i.Metadata["name"].(string); ok { + return v + } + return "" +} + +// DisplayName returns spec.displayName. +func (i APIListItem) DisplayName() string { + if v, ok := i.Spec["displayName"].(string); ok { + return v + } + return "" +} + +// Version returns spec.version. +func (i APIListItem) Version() string { + if v, ok := i.Spec["version"].(string); ok { + return v + } + return "" +} + +// Context returns spec.context. +func (i APIListItem) Context() string { + if v, ok := i.Spec["context"].(string); ok { + return v + } + return "" +} + +// State returns status.state (the declarative desired state). +func (i APIListItem) State() string { + if v, ok := i.Status["state"].(string); ok { + return v + } + return "" +} + +// CreatedAt returns status.createdAt as a string. +func (i APIListItem) CreatedAt() string { + if v, ok := i.Status["createdAt"].(string); ok { + return v + } + return "" +} + +// APIListResponse represents the response from GET /graphql-apis +type APIListResponse struct { + Status string `json:"status"` + Count int `json:"count"` + GraphQLAPIs []APIListItem `json:"graphqlApis"` +} + +func init() { + gateway.AddSelectionFlags(listCmd) +} + +func runListCommand(cmd *cobra.Command) error { + // Create a client for the active gateway + client, err := gateway.NewClientFromCommand(cmd) + if err != nil { + return err + } + + // Call the /graphql-apis endpoint + resp, err := client.Get(utils.GatewayGraphQLAPIsPath) + if err != nil { + return fmt.Errorf("failed to call %s endpoint: %w", utils.GatewayGraphQLAPIsPath, err) + } + defer resp.Body.Close() + + // Read the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + // If the gateway returned 404, treat as "no APIs" + if resp.StatusCode == http.StatusNotFound { + fmt.Println("No GraphQL APIs found on the gateway.") + return nil + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to list GraphQL APIs (status %d): %s", resp.StatusCode, string(body)) + } + + // Parse the response + var listResp APIListResponse + if err := json.Unmarshal(body, &listResp); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + // Display the APIs as a table when present + if listResp.Count == 0 { + fmt.Println("No GraphQL APIs found on the gateway.") + return nil + } + + headers := []string{"ID", "DISPLAY_NAME", "VERSION", "CONTEXT", "STATE", "CREATED_AT"} + rows := make([][]string, 0, len(listResp.GraphQLAPIs)) + for _, api := range listResp.GraphQLAPIs { + rows = append(rows, []string{api.ID(), api.DisplayName(), api.Version(), api.Context(), api.State(), api.CreatedAt()}) + } + utils.PrintTable(headers, rows) + + return nil +} diff --git a/cli/src/cmd/gateway/graphqlapi/root.go b/cli/src/cmd/gateway/graphqlapi/root.go new file mode 100644 index 0000000000..43465844ff --- /dev/null +++ b/cli/src/cmd/gateway/graphqlapi/root.go @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package graphqlapi + +import ( + "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/cmd/gateway/graphqlapi/apikey" +) + +const ( + APICmdLiteral = "graphql-api" + APICmdExample = `# List all GraphQL APIs +ap gateway graphql-api list` +) + +// APICmd represents the graphql-api command +var APICmd = &cobra.Command{ + Use: APICmdLiteral, + Short: "Manage GraphQL APIs on the gateway", + Long: "This command allows you to manage GraphQL APIs on the WSO2 API Platform Gateway.", + Example: APICmdExample, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + // Register subcommands + APICmd.AddCommand(listCmd) + APICmd.AddCommand(getCmd) + APICmd.AddCommand(deleteCmd) + APICmd.AddCommand(apikey.APIKeyCmd) +} diff --git a/cli/src/cmd/gateway/root.go b/cli/src/cmd/gateway/root.go index a8692caa0c..af64886d71 100644 --- a/cli/src/cmd/gateway/root.go +++ b/cli/src/cmd/gateway/root.go @@ -20,6 +20,7 @@ package gateway import ( "github.com/spf13/cobra" + "github.com/wso2/api-platform/cli/cmd/gateway/graphqlapi" "github.com/wso2/api-platform/cli/cmd/gateway/image" "github.com/wso2/api-platform/cli/cmd/gateway/mcp" "github.com/wso2/api-platform/cli/cmd/gateway/restapi" @@ -58,6 +59,7 @@ func init() { GatewayCmd.AddCommand(applyCmd) GatewayCmd.AddCommand(image.ImageCmd) GatewayCmd.AddCommand(restapi.APICmd) + GatewayCmd.AddCommand(graphqlapi.APICmd) GatewayCmd.AddCommand(mcp.McpCmd) GatewayCmd.AddCommand(subscriptionplan.SubscriptionPlanCmd) GatewayCmd.AddCommand(subscription.SubscriptionCmd) diff --git a/cli/src/internal/gateway/resources.go b/cli/src/internal/gateway/resources.go index 95f94ca4ac..455c05332c 100644 --- a/cli/src/internal/gateway/resources.go +++ b/cli/src/internal/gateway/resources.go @@ -30,6 +30,7 @@ const ( ResourceKindMCP = "Mcp" ResourceKindLLMProvider = "LlmProvider" ResourceKindLLMProxy = "LlmProxy" + ResourceKindGraphQLAPI = "GraphQLApi" ) // Resource represents a parsed gateway resource @@ -111,6 +112,21 @@ func (h *LLMProxyHandler) UpdateEndpoint(handle string) string { return fmt.Sprintf(utils.GatewayLLMProxyByIDPath, handle) } +// GraphQLAPIHandler handles GraphQLApi kind resources +type GraphQLAPIHandler struct{} + +func (h *GraphQLAPIHandler) GetEndpoint(handle string) string { + return fmt.Sprintf(utils.GatewayGraphQLAPIByIDPath, handle) +} + +func (h *GraphQLAPIHandler) CreateEndpoint() string { + return utils.GatewayGraphQLAPIsPath +} + +func (h *GraphQLAPIHandler) UpdateEndpoint(handle string) string { + return fmt.Sprintf(utils.GatewayGraphQLAPIByIDPath, handle) +} + // GetResourceHandler returns the appropriate handler for a resource kind func GetResourceHandler(kind string) ResourceHandler { switch kind { @@ -122,6 +138,8 @@ func GetResourceHandler(kind string) ResourceHandler { return &LLMProviderHandler{} case ResourceKindLLMProxy: return &LLMProxyHandler{} + case ResourceKindGraphQLAPI: + return &GraphQLAPIHandler{} default: return nil } diff --git a/cli/src/internal/gateway/resources_test.go b/cli/src/internal/gateway/resources_test.go new file mode 100644 index 0000000000..68de057454 --- /dev/null +++ b/cli/src/internal/gateway/resources_test.go @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package gateway + +import ( + "fmt" + "testing" + + "github.com/wso2/api-platform/cli/utils" +) + +func TestGetResourceHandler_KnownKinds(t *testing.T) { + tests := []struct { + kind string + wantCreate string + wantGetUpdate string + }{ + {ResourceKindRestAPI, utils.GatewayAPIsPath, utils.GatewayAPIByIDPath}, + {ResourceKindMCP, utils.GatewayMCPProxiesPath, utils.GatewayMCPProxyByIDPath}, + {ResourceKindLLMProvider, utils.GatewayLLMProvidersPath, utils.GatewayLLMProviderByIDPath}, + {ResourceKindLLMProxy, utils.GatewayLLMProxiesPath, utils.GatewayLLMProxyByIDPath}, + {ResourceKindGraphQLAPI, utils.GatewayGraphQLAPIsPath, utils.GatewayGraphQLAPIByIDPath}, + } + + for _, tt := range tests { + t.Run(tt.kind, func(t *testing.T) { + handler := GetResourceHandler(tt.kind) + if handler == nil { + t.Fatalf("GetResourceHandler(%q) = nil, want a handler", tt.kind) + } + if got := handler.CreateEndpoint(); got != tt.wantCreate { + t.Errorf("CreateEndpoint() = %q, want %q", got, tt.wantCreate) + } + wantByID := fmt.Sprintf(tt.wantGetUpdate, "my-handle") + if got := handler.GetEndpoint("my-handle"); got != wantByID { + t.Errorf("GetEndpoint() = %q, want %q", got, wantByID) + } + if got := handler.UpdateEndpoint("my-handle"); got != wantByID { + t.Errorf("UpdateEndpoint() = %q, want %q", got, wantByID) + } + }) + } +} + +func TestGetResourceHandler_UnknownKind(t *testing.T) { + if handler := GetResourceHandler("SomethingUnsupported"); handler != nil { + t.Errorf("GetResourceHandler(unknown) = %v, want nil", handler) + } +} diff --git a/cli/src/test/testutil/gateway.go b/cli/src/test/testutil/gateway.go new file mode 100644 index 0000000000..3218ab546f --- /dev/null +++ b/cli/src/test/testutil/gateway.go @@ -0,0 +1,18 @@ +package testutil + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// NewGatewayServer starts an httptest.Server standing in for a gateway-controller +// management API, for use by gateway CLI command tests (e.g. cmd/gateway/...). +// Mirrors NewDevPortalServer's shape for the gateway-facing command tree. +func NewGatewayServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return server +} diff --git a/cli/src/utils/constants.go b/cli/src/utils/constants.go index 0db1ae0be3..30917511c5 100644 --- a/cli/src/utils/constants.go +++ b/cli/src/utils/constants.go @@ -46,6 +46,8 @@ const ( GatewayLLMProviderByIDPath = "/llm-providers/%s" GatewayLLMProxiesPath = "/llm-proxies" GatewayLLMProxyByIDPath = "/llm-proxies/%s" + GatewayGraphQLAPIsPath = "/graphql-apis" + GatewayGraphQLAPIByIDPath = "/graphql-apis/%s" DevPortalHealthPath = "/health" // API Key Endpoints (scoped to a REST API) @@ -53,6 +55,11 @@ const ( GatewayAPIKeyByNamePath = "/rest-apis/%s/api-keys/%s" // %s = REST API id, %s = api key name GatewayAPIKeyRegeneratePath = "/rest-apis/%s/api-keys/%s/regenerate" + // API Key Endpoints (scoped to a GraphQL API) + GatewayGraphQLAPIKeysPath = "/graphql-apis/%s/api-keys" // %s = GraphQL API id + GatewayGraphQLAPIKeyByNamePath = "/graphql-apis/%s/api-keys/%s" // %s = GraphQL API id, %s = api key name + GatewayGraphQLAPIKeyRegeneratePath = "/graphql-apis/%s/api-keys/%s/regenerate" + // Subscription Plan Endpoints GatewaySubscriptionPlansPath = "/subscription-plans" GatewaySubscriptionPlanByIDPath = "/subscription-plans/%s" diff --git a/cli/src/utils/flags.go b/cli/src/utils/flags.go index 6a675e1998..769f73f4a9 100644 --- a/cli/src/utils/flags.go +++ b/cli/src/utils/flags.go @@ -85,6 +85,8 @@ const ( FlagGatewayType = "gateway-type" FlagProjectID = "project-id" FlagEnvFile = "env-file" + FlagExpiresInDuration = "expires-in-duration" + FlagExpiresInUnit = "expires-in-unit" ) var shortFlags = map[string]string{ diff --git a/event-gateway/gateway-controller/cmd/controller/main.go b/event-gateway/gateway-controller/cmd/controller/main.go index eac8694c2f..2aa207237a 100644 --- a/event-gateway/gateway-controller/cmd/controller/main.go +++ b/event-gateway/gateway-controller/cmd/controller/main.go @@ -407,7 +407,19 @@ func main() { // listener (which dispatches EventTypeAgent), so Agents do reach both the // policy manager and the xDS translator below. agentTransformer := transform.NewAgentTransformer(&cfg.Router, cfg, policyDefinitions) - transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer, agentTransformer) + // GraphQLApi's config validator/deploy parser (pkg/utils/graphql_deployment.go) + // self-register via init() and are therefore already active in this binary too + // (transitively imported via the shared transform/handlers packages) — the + // /graphql-apis CRUD and api-key routes are served here by the shared + // *handlers.APIServer, but (like /rest-apis) they are not listed in this + // binary's generateAuthConfig role map below, so they 403 when auth is + // enabled (see common/authenticators/authz.go's deny-on-unlisted-route + // behavior) — reachability here means routable, not authorized. Without a + // transformer wired in, a created GraphQLApi would accept and store but + // silently fail to ever deploy; build one exactly the way restTransformer + // is built above so it actually can. + graphqlTransformer := transform.NewGraphQLAPITransformer(&cfg.Router, cfg, policyDefinitions) + transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer, agentTransformer, graphqlTransformer) // Derived from the registry rather than hand-listed, for the same reason the // gateway controller derives it: a hand-written map beside the registry's own diff --git a/gateway/examples/blog-graphql-api.yaml b/gateway/examples/blog-graphql-api.yaml new file mode 100644 index 0000000000..5edb36eedd --- /dev/null +++ b/gateway/examples/blog-graphql-api.yaml @@ -0,0 +1,54 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# -------------------------------------------------------------------- + +# Mutation-bearing GraphQL example — debug aid. +# +# There is no separate "mutation support" in the GraphQLApi artifact, and the +# artifact carries no schema field at all: a mutation is just another POST +# body sent to the same single route a query uses — the transformer builds +# exactly one `POST ` route regardless of what the caller sends. +# This example exists to make that explicit: it's identical in shape to +# countries-graphql-api.yaml, but the traffic sent against it (see +# gateway/it/features/graphql_deploy.feature) is a mutation payload rather +# than a query, proving the gateway treats them exactly alike. +# +# Targets `sample-backend` from gateway/docker-compose.yaml, the same +# generic echo upstream sample-echo-api.yaml uses — it echoes back whatever +# body it receives, which is exactly what's needed to prove a mutation +# payload is proxied through unmodified. + +apiVersion: gateway.api-platform.wso2.com/v1 +kind: GraphQLApi +metadata: + name: blog-graphql-v1 + annotations: + gateway.api-platform.wso2.com/project-id: default +spec: + displayName: Blog + version: v1 + context: /blog/graphql + upstream: + main: + url: http://sample-backend:9080/graphql + policies: + - name: jwt-auth + version: v1 + params: + issuers: [PrimaryIdp] + scopes: + anyOf: ["graphql:read", "graphql:write"] diff --git a/gateway/examples/countries-graphql-api.yaml b/gateway/examples/countries-graphql-api.yaml new file mode 100644 index 0000000000..828f532976 --- /dev/null +++ b/gateway/examples/countries-graphql-api.yaml @@ -0,0 +1,38 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# -------------------------------------------------------------------- + +apiVersion: gateway.api-platform.wso2.com/v1 +kind: GraphQLApi +metadata: + name: countries-graphql-v1 + annotations: + gateway.api-platform.wso2.com/project-id: default-project +spec: + displayName: Countries + version: v1 + context: /countries/$version/graphql + upstream: + main: + url: https://countries.trevorblades.com/graphql + policies: + - name: jwt-auth + version: v1 + params: + issuers: [PrimaryIdp] + scopes: + anyOf: ["graphql:read", "graphql:write"] diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index 00634da35f..833198d6b6 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -258,6 +258,248 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /graphql-apis: + post: + summary: Create a new GraphQLApi + description: Add a new GraphQLApi to the Gateway. + operationId: createGraphQLAPI + x-basicauth-roles: [admin, developer] + tags: + - GraphQL API Management + requestBody: + required: true + content: + application/yaml: + schema: + $ref: "#/components/schemas/GraphQLAPIRequest" + application/json: + schema: + $ref: "#/components/schemas/GraphQLAPIRequest" + responses: + "201": + description: GraphQLApi created successfully + content: + application/json: + schema: + $ref: "#/components/schemas/GraphQLAPI" + "400": + description: Invalid configuration (validation failed) + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Conflict - API with same name and version already exists + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + get: + summary: List all GraphQLApis + description: List GraphQLApis registered in the Gateway, optionally filtered by name, version, context, or status. + operationId: listGraphQLAPIs + x-basicauth-roles: [admin, developer] + tags: + - GraphQL API Management + parameters: + - name: displayName + in: query + required: false + description: Filter by API display name + schema: + type: string + example: Countries GraphQL API + - name: version + in: query + required: false + description: Filter by API version + schema: + type: string + example: v1.0 + - name: context + in: query + required: false + description: Filter by API context/path + schema: + type: string + example: /countries/graphql + - name: status + in: query + required: false + description: Filter by deployment status + schema: + type: string + enum: [ deployed, undeployed ] + example: undeployed + responses: + "200": + description: List of GraphQLApis + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: success + count: + type: integer + example: 1 + graphqlApis: + type: array + items: + $ref: "#/components/schemas/GraphQLAPI" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /graphql-apis/{id}: + get: + summary: Get GraphQLApi by id + description: Get a GraphQLApi by its ID. + operationId: getGraphQLAPIById + x-basicauth-roles: [admin, developer] + tags: + - GraphQL API Management + parameters: + - name: id + in: path + required: true + description: | + Unique public identifier for the API. + schema: + type: string + example: countries-graphql-api-v1.0 + responses: + "200": + description: GraphQLApi details + content: + application/json: + schema: + $ref: "#/components/schemas/GraphQLAPI" + application/yaml: + schema: + $ref: "#/components/schemas/GraphQLAPI" + "404": + description: GraphQLApi not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + put: + summary: Update an existing GraphQLApi + description: Update an existing GraphQLApi in the Gateway. + operationId: updateGraphQLAPI + x-basicauth-roles: [admin, developer] + tags: + - GraphQL API Management + parameters: + - name: id + in: path + required: true + description: | + Unique public identifier of the API to update. + schema: + type: string + example: countries-graphql-api-v1.0 + requestBody: + required: true + content: + application/yaml: + schema: + $ref: "#/components/schemas/GraphQLAPIRequest" + application/json: + schema: + $ref: "#/components/schemas/GraphQLAPIRequest" + responses: + "200": + description: GraphQLApi updated successfully + content: + application/json: + schema: + $ref: "#/components/schemas/GraphQLAPI" + "400": + description: Invalid configuration (validation failed) + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: GraphQLApi not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + delete: + summary: Delete a GraphQLApi + description: Delete a GraphQLApi from the Gateway. + operationId: deleteGraphQLAPI + x-basicauth-roles: [admin, developer] + tags: + - GraphQL API Management + parameters: + - name: id + in: path + required: true + description: | + Unique public identifier of the API to delete. + schema: + type: string + example: countries-graphql-api-v1.0 + responses: + "200": + description: GraphQLApi deleted successfully + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: success + message: + type: string + example: GraphQLApi deleted successfully + id: + type: string + example: countries-graphql-api-v1.0 + "404": + description: GraphQLApi not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /rest-apis/{id}/api-keys: post: summary: Create a new API key for an API @@ -271,10 +513,267 @@ paths: in: path required: true description: | - Unique public identifier of the API to generate the key for + Unique public identifier of the API to generate the key for + schema: + type: string + example: reading-list-api-v1.0 + requestBody: + required: true + content: + application/yaml: + schema: + $ref: "#/components/schemas/APIKeyCreationRequest" + application/json: + schema: + $ref: "#/components/schemas/APIKeyCreationRequest" + responses: + '201': + description: API key created successfully + content: + application/json: + schema: + $ref: "#/components/schemas/APIKeyCreationResponse" + "400": + description: Invalid configuration (validation failed) + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: RestAPI not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + get: + summary: Get the list of API keys for an API + description: List all API keys for a RestAPI in the Gateway. + operationId: listAPIKeys + x-basicauth-roles: [admin, consumer] + tags: + - Rest API Management + parameters: + - name: id + in: path + required: true + description: | + Unique public identifier of the API to retrieve the keys for + schema: + type: string + example: reading-list-api-v1.0 + responses: + "200": + description: List of API keys + content: + application/json: + schema: + $ref: "#/components/schemas/APIKeyListResponse" + "404": + description: RestAPI not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /rest-apis/{id}/api-keys/{apiKeyName}/regenerate: + post: + summary: Regenerate API key for an API + description: Regenerate an existing API key for a RestAPI in the Gateway. The previous key is revoked and replaced with a new 32-byte random value encoded in hexadecimal, prefixed with `apip_`. + operationId: regenerateAPIKey + x-basicauth-roles: [admin, consumer] + tags: + - Rest API Management + parameters: + - name: id + in: path + required: true + description: | + Unique public identifier of the API to generate the key for + schema: + type: string + example: reading-list-api-v1.0 + - name: apiKeyName + in: path + required: true + description: | + Name of the API key to regenerate + schema: + type: string + example: reading-list-api-key + requestBody: + required: true + content: + application/yaml: + schema: + $ref: "#/components/schemas/APIKeyRegenerationRequest" + application/json: + schema: + $ref: "#/components/schemas/APIKeyRegenerationRequest" + responses: + '200': + description: API key rotated successfully + content: + application/json: + schema: + $ref: "#/components/schemas/APIKeyCreationResponse" + "400": + description: Invalid configuration (validation failed) + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: RestAPI not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /rest-apis/{id}/api-keys/{apiKeyName}: + put: + summary: Update an API key with a new regenerated value + description: Update an API key with a custom value instead of auto-generating one. + operationId: updateAPIKey + x-basicauth-roles: [admin, consumer] + tags: + - Rest API Management + parameters: + - name: id + in: path + required: true + description: | + Unique public identifier of the API + schema: + type: string + example: reading-list-api-v1.0 + - name: apiKeyName + in: path + required: true + description: | + Name of the API key to update + schema: + type: string + example: reading-list-api-key + requestBody: + required: true + content: + application/yaml: + schema: + $ref: "#/components/schemas/APIKeyUpdateRequest" + application/json: + schema: + $ref: "#/components/schemas/APIKeyUpdateRequest" + responses: + '200': + description: API key updated successfully + content: + application/json: + schema: + $ref: "#/components/schemas/APIKeyCreationResponse" + "400": + description: Invalid request (validation failed) + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: API or API key not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + delete: + summary: Revoke an API key + description: Revoke an API key. Once revoked, it can no longer be used to authenticate requests. + operationId: revokeAPIKey + x-basicauth-roles: [admin, consumer] + tags: + - Rest API Management + parameters: + - name: id + in: path + required: true + description: | + Unique public identifier of the API to revoke the key for schema: type: string example: reading-list-api-v1.0 + - name: apiKeyName + in: path + required: true + description: | + Name of the API key to revoke + schema: + type: string + example: reading-list-api-key + responses: + '200': + description: API key revoked successfully + content: + application/json: + schema: + $ref: "#/components/schemas/APIKeyRevocationResponse" + "400": + description: Invalid configuration (validation failed) + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: RestAPI not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /graphql-apis/{id}/api-keys: + post: + summary: Create a new API key for a GraphQL API + description: Generate a new API key for a GraphQLApi in the Gateway. The key is a 32-byte random value encoded in hexadecimal, prefixed with `apip_`. Use the API Key policy on the API to validate incoming requests with this key. + operationId: createGraphQLAPIKey + x-basicauth-roles: [admin, consumer] + tags: + - GraphQL API Management + parameters: + - name: id + in: path + required: true + description: | + Unique public identifier of the API to generate the key for + schema: + type: string + example: countries-graphql-api requestBody: required: true content: @@ -298,7 +797,7 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" "404": - description: RestAPI not found + description: GraphQLApi not found content: application/json: schema: @@ -311,12 +810,12 @@ paths: $ref: "#/components/schemas/ErrorResponse" get: - summary: Get the list of API keys for an API - description: List all API keys for a RestAPI in the Gateway. - operationId: listAPIKeys + summary: Get the list of API keys for a GraphQL API + description: List all API keys for a GraphQLApi in the Gateway. + operationId: listGraphQLAPIKeys x-basicauth-roles: [admin, consumer] tags: - - Rest API Management + - GraphQL API Management parameters: - name: id in: path @@ -325,7 +824,7 @@ paths: Unique public identifier of the API to retrieve the keys for schema: type: string - example: reading-list-api-v1.0 + example: countries-graphql-api responses: "200": description: List of API keys @@ -334,7 +833,7 @@ paths: schema: $ref: "#/components/schemas/APIKeyListResponse" "404": - description: RestAPI not found + description: GraphQLApi not found content: application/json: schema: @@ -346,14 +845,14 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" - /rest-apis/{id}/api-keys/{apiKeyName}/regenerate: + /graphql-apis/{id}/api-keys/{apiKeyName}/regenerate: post: - summary: Regenerate API key for an API - description: Regenerate an existing API key for a RestAPI in the Gateway. The previous key is revoked and replaced with a new 32-byte random value encoded in hexadecimal, prefixed with `apip_`. - operationId: regenerateAPIKey + summary: Regenerate API key for a GraphQL API + description: Regenerate an existing API key for a GraphQLApi in the Gateway. The previous key is revoked and replaced with a new 32-byte random value encoded in hexadecimal, prefixed with `apip_`. + operationId: regenerateGraphQLAPIKey x-basicauth-roles: [admin, consumer] tags: - - Rest API Management + - GraphQL API Management parameters: - name: id in: path @@ -362,7 +861,7 @@ paths: Unique public identifier of the API to generate the key for schema: type: string - example: reading-list-api-v1.0 + example: countries-graphql-api - name: apiKeyName in: path required: true @@ -370,7 +869,7 @@ paths: Name of the API key to regenerate schema: type: string - example: reading-list-api-key + example: countries-graphql-api-key requestBody: required: true content: @@ -394,7 +893,7 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" "404": - description: RestAPI not found + description: GraphQLApi not found content: application/json: schema: @@ -406,14 +905,14 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" - /rest-apis/{id}/api-keys/{apiKeyName}: + /graphql-apis/{id}/api-keys/{apiKeyName}: put: summary: Update an API key with a new regenerated value description: Update an API key with a custom value instead of auto-generating one. - operationId: updateAPIKey + operationId: updateGraphQLAPIKey x-basicauth-roles: [admin, consumer] tags: - - Rest API Management + - GraphQL API Management parameters: - name: id in: path @@ -422,7 +921,7 @@ paths: Unique public identifier of the API schema: type: string - example: reading-list-api-v1.0 + example: countries-graphql-api - name: apiKeyName in: path required: true @@ -430,7 +929,7 @@ paths: Name of the API key to update schema: type: string - example: reading-list-api-key + example: countries-graphql-api-key requestBody: required: true content: @@ -468,10 +967,10 @@ paths: delete: summary: Revoke an API key description: Revoke an API key. Once revoked, it can no longer be used to authenticate requests. - operationId: revokeAPIKey + operationId: revokeGraphQLAPIKey x-basicauth-roles: [admin, consumer] tags: - - Rest API Management + - GraphQL API Management parameters: - name: id in: path @@ -480,7 +979,7 @@ paths: Unique public identifier of the API to revoke the key for schema: type: string - example: reading-list-api-v1.0 + example: countries-graphql-api - name: apiKeyName in: path required: true @@ -488,7 +987,7 @@ paths: Name of the API key to revoke schema: type: string - example: reading-list-api-key + example: countries-graphql-api-key responses: '200': description: API key revoked successfully @@ -503,7 +1002,7 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" "404": - description: RestAPI not found + description: GraphQLApi not found content: application/json: schema: @@ -3519,6 +4018,157 @@ components: default: deployed example: deployed + # GraphQLApi has exactly one logical endpoint (POST ) — the "operation" + # (query/mutation name) is identified by the request body, not the URL, so unlike + # APIConfigData there is no operations[] list here. + GraphQLAPIConfigData: + type: object + required: + - displayName + - version + - context + - upstream + properties: + displayName: + type: string + description: Human-readable API name (must be URL-friendly - only letters, numbers, spaces, hyphens, underscores, and dots allowed) + minLength: 1 + maxLength: 100 + pattern: '^[a-zA-Z0-9\-_\. ]+$' + example: Countries GraphQL API + version: + type: string + description: Semantic version of the API. Both major-only (v1) and major.minor (v1.0) forms are accepted. + pattern: '^v\d+(\.\d+)?$' + example: v1.0 + context: + type: string + description: > + Base path for the single GraphQL endpoint (must start with /, no trailing + slash). Use $version to embed the version in the path (e.g., /countries/$version + resolves to /countries/v1.0). A GraphQLApi always exposes exactly one POST + route at this path — there is no per-operation path list. Suggested (not + enforced) convention: end the path with /graphql, matching how most + standalone GraphQL servers name their single endpoint (e.g. + /countries/$version/graphql) — this is not validated or required. + pattern: '^\/([a-zA-Z0-9_\-\/]*[^\/])?$' + minLength: 1 + maxLength: 200 + example: /countries/$version/graphql + upstream: + type: object + required: + - main + description: > + API-level upstream configuration. A GraphQLApi has exactly one logical + endpoint (no per-operation paths), so upstream.main.url is the single + GraphQL endpoint to proxy to. Only a direct inline url is supported — + GraphQLAPIConfigData has no upstreamDefinitions list, so upstream.ref + (used by RestApi to reference a predefined upstreamDefinition) cannot + be resolved and is rejected. + properties: + main: + $ref: "#/components/schemas/Upstream" + sandbox: + $ref: "#/components/schemas/Upstream" + subscriptionPlans: + type: array + description: List of subscription plan names available for this API + items: + type: string + example: ["Gold", "Silver"] + policies: + type: array + description: | + List of policies applied to the single GraphQL route. A `cors` + policy applies only to that route's `POST` method — a GraphQLApi + has no operations[] list to add an `OPTIONS` entry to, so a + browser preflight request is not routed at all and a `cors` + policy will not run for it; cross-origin browser clients that + trigger a preflight are not currently supported. + items: + $ref: "#/components/schemas/Policy" + deploymentState: + type: string + description: Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the API is removed from router traffic but configuration, API keys, and policies are preserved for potential redeployment. + enum: [deployed, undeployed] + default: deployed + example: deployed + + # Request body for create/update: user/resource fields only (no server-managed status). + GraphQLAPIRequest: + type: object + required: + - apiVersion + - metadata + - kind + - spec + properties: + apiVersion: + type: string + description: API specification version + example: gateway.api-platform.wso2.com/v1 + enum: + - gateway.api-platform.wso2.com/v1 + kind: + type: string + description: API type + example: GraphQLApi + enum: + - GraphQLApi + metadata: + $ref: "#/components/schemas/Metadata" + spec: + $ref: '#/components/schemas/GraphQLAPIConfigData' + example: + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: countries-graphql-api-v1.0 + spec: + displayName: Countries-GraphQL-API + version: v1.0 + context: /countries/$version/graphql + upstream: + main: + url: https://countries.trevorblades.com/graphql + policies: + - name: jwt-auth + version: v1 + + # Full resource including server-managed status (list/get responses). + GraphQLAPI: + allOf: + - $ref: '#/components/schemas/GraphQLAPIRequest' + - type: object + properties: + status: + readOnly: true + description: Server-managed lifecycle fields. Populated on responses. + allOf: + - $ref: '#/components/schemas/ResourceStatus' + example: + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: countries-graphql-api-v1.0 + spec: + displayName: Countries-GraphQL-API + version: v1.0 + context: /countries/$version/graphql + upstream: + main: + url: https://countries.trevorblades.com/graphql + policies: + - name: jwt-auth + version: v1 + status: + id: countries-graphql-api-v1.0 + state: deployed + createdAt: 2026-08-11T10:00:00Z + updatedAt: 2026-08-11T10:00:00Z + deployedAt: 2026-08-11T10:00:00Z + UpstreamDefinition: type: object required: @@ -6494,6 +7144,8 @@ components: tags: - name: Rest API Management description: CRUD operations for Rest APIs + - name: GraphQL API Management + description: CRUD operations for GraphQL APIs - name: MCP Proxy Management description: CRUD operations for MCPProxies - name: Agent Management diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index 1895860061..0192c30cd2 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -364,6 +364,7 @@ func main() { // Initialize SDS secret manager if custom certificates are configured var sdsSecretManager *xds.SDSSecretManager translator := snapshotManager.GetTranslator() + if translator != nil && translator.GetCertStore() != nil { // Use the same cache and node ID as the main xDS to ensure Envoy can fetch secrets sdsSecretManager = xds.NewSDSSecretManager( @@ -393,7 +394,8 @@ func main() { restTransformer := transform.NewRestAPITransformer(&cfg.Router, cfg, policyDefinitions) llmTransformer := transform.NewLLMTransformer(configStore, db, &cfg.Router, cfg, policyDefinitions, policyVersionResolver) agentTransformer := transform.NewAgentTransformer(&cfg.Router, cfg, policyDefinitions) - transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer, agentTransformer) + graphqlTransformer := transform.NewGraphQLAPITransformer(&cfg.Router, cfg, policyDefinitions) + transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer, agentTransformer, graphqlTransformer) // Wire the transformer into the Envoy xDS translator so Envoy routes are built from the // RuntimeDeployConfig (RDC) path — identical to how the policy engine's RouteConfig/PolicyChain @@ -414,11 +416,13 @@ func main() { // registry learns to transform cannot be silently left off this map — WebSubApi's exclusion // (it keeps the async-specific legacy translation path) is declared alongside the registry's // own kind list instead. - envoyTransformers := make(map[string]models.ConfigTransformer) - for _, kind := range transform.EnvoyTranslatorKinds() { - envoyTransformers[kind] = transformerRegistry + if translator != nil { + envoyTransformers := make(map[string]models.ConfigTransformer) + for _, kind := range transform.EnvoyTranslatorKinds() { + envoyTransformers[kind] = transformerRegistry + } + translator.SetTransformers(envoyTransformers) } - translator.SetTransformers(envoyTransformers) // Generate initial xDS snapshot log.Info("Generating initial xDS snapshot") @@ -983,6 +987,12 @@ func generateAuthConfig(config *config.Config) (commonmodels.AuthConfig, error) "PUT /agents/{id}": {"admin", "developer"}, "DELETE /agents/{id}": {"admin", "developer"}, + "POST /graphql-apis": {"admin", "developer"}, + "GET /graphql-apis": {"admin", "developer"}, + "GET /graphql-apis/{id}": {"admin", "developer"}, + "PUT /graphql-apis/{id}": {"admin", "developer"}, + "DELETE /graphql-apis/{id}": {"admin", "developer"}, + "POST /llm-provider-templates": {"admin"}, "GET /llm-provider-templates": {"admin"}, "GET /llm-provider-templates/{id}": {"admin"}, @@ -1007,6 +1017,12 @@ func generateAuthConfig(config *config.Config) (commonmodels.AuthConfig, error) "POST /rest-apis/{id}/api-keys/{apiKeyName}/regenerate": {"admin", "consumer"}, "DELETE /rest-apis/{id}/api-keys/{apiKeyName}": {"admin", "consumer"}, + "POST /graphql-apis/{id}/api-keys": {"admin", "consumer"}, + "GET /graphql-apis/{id}/api-keys": {"admin", "consumer"}, + "PUT /graphql-apis/{id}/api-keys/{apiKeyName}": {"admin", "consumer"}, + "POST /graphql-apis/{id}/api-keys/{apiKeyName}/regenerate": {"admin", "consumer"}, + "DELETE /graphql-apis/{id}/api-keys/{apiKeyName}": {"admin", "consumer"}, + "POST /llm-providers/{id}/api-keys": {"admin", "consumer"}, "GET /llm-providers/{id}/api-keys": {"admin", "consumer"}, "PUT /llm-providers/{id}/api-keys/{apiKeyName}": {"admin", "consumer"}, diff --git a/gateway/gateway-controller/cmd/controller/main_test.go b/gateway/gateway-controller/cmd/controller/main_test.go index 6c56f82e65..6197d3c1e5 100644 --- a/gateway/gateway-controller/cmd/controller/main_test.go +++ b/gateway/gateway-controller/cmd/controller/main_test.go @@ -737,10 +737,32 @@ func TestGenerateAuthConfig(t *testing.T) { // Check some expected resource roles (keys are prefixed with managementAPIBasePath) assert.Contains(t, authConfig.ResourceRoles, "POST "+managementAPIBasePath+"/rest-apis") assert.Contains(t, authConfig.ResourceRoles, "GET "+managementAPIBasePath+"/rest-apis") + // Regression guard: /graphql-apis routes were missing from this map + // entirely after GraphQL support was added — every request returned 403 + // once basic auth was enabled, since an unlisted route is denied by + // default. GraphQL is a core kind like RestApi/Mcp and must carry the + // exact same [admin, developer] roles. + assert.Contains(t, authConfig.ResourceRoles, "POST "+managementAPIBasePath+"/graphql-apis") + assert.Contains(t, authConfig.ResourceRoles, "GET "+managementAPIBasePath+"/graphql-apis") + assert.Contains(t, authConfig.ResourceRoles, "GET "+managementAPIBasePath+"/graphql-apis/{id}") + assert.Contains(t, authConfig.ResourceRoles, "PUT "+managementAPIBasePath+"/graphql-apis/{id}") + assert.Contains(t, authConfig.ResourceRoles, "DELETE "+managementAPIBasePath+"/graphql-apis/{id}") + assert.Equal(t, []string{"admin", "developer"}, authConfig.ResourceRoles["POST "+managementAPIBasePath+"/graphql-apis"]) + assert.Contains(t, authConfig.ResourceRoles, "POST /graphql-apis") // legacy unprefixed key assert.Contains(t, authConfig.ResourceRoles, "POST "+managementAPIBasePath+"/llm-providers/{id}/api-keys") assert.Contains(t, authConfig.ResourceRoles, "GET "+managementAPIBasePath+"/llm-providers/{id}/api-keys") assert.Contains(t, authConfig.ResourceRoles, "POST "+managementAPIBasePath+"/llm-proxies/{id}/api-keys") assert.Contains(t, authConfig.ResourceRoles, "GET "+managementAPIBasePath+"/llm-proxies/{id}/api-keys") + // Regression guard: /graphql-apis/{id}/api-keys routes, same class of bug + // as the /graphql-apis routes above — a route present in the OpenAPI spec + // and ServerInterface but absent from this map is denied by default (404) + // once basic auth is enabled, never reaching the handler at all. + assert.Contains(t, authConfig.ResourceRoles, "POST "+managementAPIBasePath+"/graphql-apis/{id}/api-keys") + assert.Contains(t, authConfig.ResourceRoles, "GET "+managementAPIBasePath+"/graphql-apis/{id}/api-keys") + assert.Contains(t, authConfig.ResourceRoles, "PUT "+managementAPIBasePath+"/graphql-apis/{id}/api-keys/{apiKeyName}") + assert.Contains(t, authConfig.ResourceRoles, "POST "+managementAPIBasePath+"/graphql-apis/{id}/api-keys/{apiKeyName}/regenerate") + assert.Contains(t, authConfig.ResourceRoles, "DELETE "+managementAPIBasePath+"/graphql-apis/{id}/api-keys/{apiKeyName}") + assert.Equal(t, []string{"admin", "consumer"}, authConfig.ResourceRoles["POST "+managementAPIBasePath+"/graphql-apis/{id}/api-keys"]) assert.Contains(t, authConfig.ResourceRoles, "GET "+managementAPIBasePath+"/policies") // Admin API paths are served separately and must not leak into management auth config. assert.NotContains(t, authConfig.ResourceRoles, "GET "+managementAPIBasePath+"/config_dump") diff --git a/gateway/gateway-controller/cmd/controller/runtime_bootstrap.go b/gateway/gateway-controller/cmd/controller/runtime_bootstrap.go index 2ce822d1f7..05f6aa4c0e 100644 --- a/gateway/gateway-controller/cmd/controller/runtime_bootstrap.go +++ b/gateway/gateway-controller/cmd/controller/runtime_bootstrap.go @@ -166,7 +166,7 @@ func loadRuntimeConfigsFromExistingAPIConfigurations( func supportsRuntimeBootstrapKind(kind string) bool { switch kind { - case models.KindRestApi, models.KindMcp, models.KindLlmProvider, models.KindLlmProxy, models.KindAgent: + case models.KindRestApi, models.KindMcp, models.KindLlmProvider, models.KindLlmProxy, models.KindAgent, models.KindGraphQLApi: return true default: return false diff --git a/gateway/gateway-controller/pkg/api/handlers/api_key_handler.go b/gateway/gateway-controller/pkg/api/handlers/api_key_handler.go index 392672aad4..452caeac93 100644 --- a/gateway/gateway-controller/pkg/api/handlers/api_key_handler.go +++ b/gateway/gateway-controller/pkg/api/handlers/api_key_handler.go @@ -19,6 +19,7 @@ package handlers import ( + "errors" "fmt" "log/slog" "net/http" @@ -89,6 +90,11 @@ func (s *APIServer) CreateAPIKey(w http.ResponseWriter, r *http.Request, id stri Status: "error", Message: err.Error(), }) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) } else { log.Error("Failed to create API key", slog.Any("error", err), @@ -243,6 +249,11 @@ func (s *APIServer) UpdateAPIKey(w http.ResponseWriter, r *http.Request, id stri Status: "error", Message: err.Error(), }) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) } else { log.Error("Failed to update API key", slog.Any("error", err), @@ -317,6 +328,11 @@ func (s *APIServer) RegenerateAPIKey(w http.ResponseWriter, r *http.Request, id Status: "error", Message: err.Error(), }) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) } else { log.Error("Failed to regenerate API key", slog.Any("error", err), diff --git a/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go b/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go new file mode 100644 index 0000000000..3eb5011295 --- /dev/null +++ b/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go @@ -0,0 +1,624 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package handlers + +import ( + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/wso2/api-platform/common/eventhub" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/middleware" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils" + "github.com/wso2/api-platform/httpkit/httputil" +) + +// GraphQLAPI CRUD handlers, implemented directly on *APIServer (mirroring +// mcp_proxy_handler.go's pattern rather than restapi's own service package) since +// GraphQLApi has no operations/upstreamDefinitions/vhosts to warrant a bespoke +// service layer: Create/Update reuse the same generic s.deploymentService that +// RestApi/WebSubApi already share (GraphQLApi is wired into it via +// utils.RegisterKindDeployParser/RegisterKindConfigValidator — see +// pkg/utils/graphql_deployment.go — not a hardcoded case in api_deployment.go). + +// CreateGraphQLAPI implements ServerInterface.CreateGraphQLAPI +// (POST /graphql-apis) +func (s *APIServer) CreateGraphQLAPI(w http.ResponseWriter, r *http.Request) { + log := middleware.GetLogger(r, s.logger) + + body, err := io.ReadAll(r.Body) + if err != nil { + log.Error("Failed to read request body", slog.Any("error", err)) + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: "Failed to read request body", + }) + return + } + + correlationID := middleware.GetCorrelationID(r) + + result, err := s.deploymentService.DeployAPIConfiguration(utils.APIDeploymentParams{ + Data: body, + ContentType: r.Header.Get("Content-Type"), + Kind: string(api.GraphQLAPIKindGraphQLApi), + APIID: "", // empty to generate a new UUID + Origin: models.OriginGatewayAPI, + CorrelationID: correlationID, + Logger: log, + }) + if err != nil { + log.Error("Failed to deploy GraphQL API configuration", slog.Any("error", err)) + if mapRenderError(w, "create", err) { + return + } + if mapValidationError(w, err) { + return + } + if storage.IsConflictError(err) { + httputil.WriteJSON(w, http.StatusConflict, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) + return + } + if isGraphQLAPICreateBadRequest(err) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) + return + } + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{ + Status: "error", + Message: "Failed to create configuration", + }) + return + } + + s.pushDeployableGraphQLArtifact(result, correlationID, log) + + httputil.WriteJSON(w, http.StatusCreated, buildResourceResponseFromStored(result.StoredConfig.SourceConfiguration, result.StoredConfig)) +} + +// ListGraphQLAPIs implements ServerInterface.ListGraphQLAPIs +// (GET /graphql-apis) +func (s *APIServer) ListGraphQLAPIs(w http.ResponseWriter, r *http.Request, params api.ListGraphQLAPIsParams) { + configs, err := s.db.GetAllConfigsByKind(string(api.GraphQLAPIKindGraphQLApi)) + if err != nil { + s.logger.Error("Failed to get GraphQL APIs", slog.Any("error", err)) + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{ + Status: "error", + Message: "Failed to retrieve GraphQL API configurations", + }) + return + } + + items := make([]any, 0, len(configs)) + for _, cfg := range configs { + if params.DisplayName != nil && *params.DisplayName != "" && cfg.DisplayName != *params.DisplayName { + continue + } + if params.Version != nil && *params.Version != "" && cfg.Version != *params.Version { + continue + } + if params.Context != nil && *params.Context != "" { + cfgContext, err := cfg.GetContext() + if err != nil { + s.logger.Error("Failed to get context for GraphQL API config", slog.Any("error", err), slog.String("uuid", cfg.UUID)) + continue + } + if cfgContext != *params.Context { + continue + } + } + if params.Status != nil && *params.Status != "" && string(cfg.DesiredState) != string(*params.Status) { + continue + } + items = append(items, buildResourceResponseFromStored(cfg.SourceConfiguration, cfg)) + } + + httputil.WriteJSON(w, http.StatusOK, map[string]any{ + "status": "success", + "count": len(items), + "graphqlApis": items, + }) +} + +// GetGraphQLAPIById implements ServerInterface.GetGraphQLAPIById +// (GET /graphql-apis/{id}) +func (s *APIServer) GetGraphQLAPIById(w http.ResponseWriter, r *http.Request, id string) { + log := middleware.GetLogger(r, s.logger) + + cfg, err := s.db.GetConfigByKindAndHandle(string(api.GraphQLAPIKindGraphQLApi), id) + if err != nil { + if storage.IsNotFoundError(err) { + log.Warn("GraphQL API configuration not found", slog.String("handle", id)) + httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{ + Status: "error", + Message: fmt.Sprintf("GraphQLApi with handle '%s' not found", id), + }) + return + } + log.Error("Failed to get GraphQL API configuration", slog.Any("error", err), slog.String("handle", id)) + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{ + Status: "error", + Message: "Failed to retrieve configuration", + }) + return + } + + httputil.WriteJSON(w, http.StatusOK, buildResourceResponseFromStored(cfg.SourceConfiguration, cfg)) +} + +// UpdateGraphQLAPI implements ServerInterface.UpdateGraphQLAPI +// (PUT /graphql-apis/{id}) +func (s *APIServer) UpdateGraphQLAPI(w http.ResponseWriter, r *http.Request, id string) { + log := middleware.GetLogger(r, s.logger) + + body, err := io.ReadAll(r.Body) + if err != nil { + log.Error("Failed to read request body", slog.Any("error", err)) + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: "Failed to read request body", + }) + return + } + + existing, err := s.db.GetConfigByKindAndHandle(string(api.GraphQLAPIKindGraphQLApi), id) + if err != nil { + if storage.IsNotFoundError(err) { + log.Warn("GraphQL API configuration not found", slog.String("handle", id)) + httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{ + Status: "error", + Message: fmt.Sprintf("GraphQLApi with handle '%s' not found", id), + }) + return + } + log.Error("Failed to get GraphQL API configuration", slog.Any("error", err), slog.String("handle", id)) + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{ + Status: "error", + Message: "Failed to retrieve configuration", + }) + return + } + + // Validate handle match BEFORE persisting anything — mirrors + // RestAPIService.Update's ordering. Checking this only after + // DeployAPIConfiguration (which upserts immediately) would let a mismatched + // body silently rename the stored config to the body's handle before the + // mismatch is ever reported, orphaning the original path handle even though + // the client receives a 400. + var graphqlConfig api.GraphQLAPI + if err := s.parser.Parse(body, r.Header.Get("Content-Type"), &graphqlConfig); err != nil { + log.Error("Failed to parse GraphQL API configuration", slog.Any("error", err)) + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: fmt.Sprintf("failed to parse configuration: %v", err), + }) + return + } + if graphqlConfig.Metadata.Name != "" && graphqlConfig.Metadata.Name != id { + log.Warn("GraphQL API update handle mismatch", slog.String("pathHandle", id), slog.String("bodyHandle", graphqlConfig.Metadata.Name)) + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: fmt.Sprintf("metadata.name '%s' does not match path id '%s'", graphqlConfig.Metadata.Name, id), + }) + return + } + + correlationID := middleware.GetCorrelationID(r) + + // Ensure the deployment uses the existing UUID so DeployAPIConfiguration performs + // an update (upsert) rather than creating a second artifact. + result, err := s.deploymentService.DeployAPIConfiguration(utils.APIDeploymentParams{ + Data: body, + ContentType: r.Header.Get("Content-Type"), + Kind: string(api.GraphQLAPIKindGraphQLApi), + APIID: existing.UUID, + Origin: existing.Origin, + CorrelationID: correlationID, + Logger: log, + }) + if err != nil { + log.Error("Failed to update GraphQL API configuration", slog.Any("error", err)) + if mapRenderError(w, "update", err) { + return + } + if mapValidationError(w, err) { + return + } + if storage.IsConflictError(err) { + httputil.WriteJSON(w, http.StatusConflict, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) + return + } + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{ + Status: "error", + Message: "Failed to update configuration", + }) + return + } + + s.pushDeployableGraphQLArtifact(result, correlationID, log) + + httputil.WriteJSON(w, http.StatusOK, buildResourceResponseFromStored(result.StoredConfig.SourceConfiguration, result.StoredConfig)) +} + +// DeleteGraphQLAPI implements ServerInterface.DeleteGraphQLAPI +// (DELETE /graphql-apis/{id}) +func (s *APIServer) DeleteGraphQLAPI(w http.ResponseWriter, r *http.Request, id string) { + log := middleware.GetLogger(r, s.logger) + + cfg, err := s.db.GetConfigByKindAndHandle(string(api.GraphQLAPIKindGraphQLApi), id) + if err != nil { + if storage.IsNotFoundError(err) { + log.Warn("GraphQL API configuration not found", slog.String("handle", id)) + httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{ + Status: "error", + Message: fmt.Sprintf("GraphQLApi with handle '%s' not found", id), + }) + return + } + log.Error("Failed to get GraphQL API configuration", slog.Any("error", err), slog.String("handle", id)) + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{ + Status: "error", + Message: "Failed to retrieve configuration", + }) + return + } + + if err := s.db.DeleteConfig(cfg.UUID); err != nil { + log.Error("Failed to delete GraphQL API config from database", slog.Any("error", err)) + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{ + Status: "error", + Message: "Failed to delete configuration", + }) + return + } + + correlationID := middleware.GetCorrelationID(r) + s.publishGraphQLAPIEvent("DELETE", cfg.UUID, correlationID, log) + + // Notify the control plane (DP->CP) that this artifact was deleted via the shared + // handler path; it keeps the artifact and marks it undeployed. + s.pushArtifactUndeploy(cfg, log) + + httputil.WriteJSON(w, http.StatusOK, map[string]any{ + "status": "success", + "message": "GraphQLApi deleted successfully", + "id": id, + }) +} + +// pushDeployableGraphQLArtifact pushes a newly created/updated GraphQL API to the +// control plane, mirroring RestAPIHandler's create/update push behavior. It is a +// no-op (like the other kinds) when push is disabled, disconnected, or the result +// was a stale/no-op deployment. +func (s *APIServer) pushDeployableGraphQLArtifact(result *utils.APIDeploymentResult, correlationID string, log *slog.Logger) { + if result.IsStale { + return + } + if s.controlPlaneClient == nil || !s.controlPlaneClient.IsConnected() || s.controlPlaneClient.IsOnPrem() || + !s.systemConfig.Controller.ControlPlane.DeploymentSyncEnabled { + return + } + cfgID := result.StoredConfig.UUID + deployedAt := result.StoredConfig.DeployedAt + s.controlPlaneClient.SubmitArtifactPush(func() { + s.waitForDeploymentAndPush(cfgID, correlationID, deployedAt, log) + }) +} + +// publishGraphQLAPIEvent publishes a delete event to the event hub so all replicas +// (including self) converge through the event listener sync, mirroring +// RestAPIService.publishEvent/MCPDeploymentService.publishMCPProxyEvent. +func (s *APIServer) publishGraphQLAPIEvent(action, entityID, correlationID string, logger *slog.Logger) { + event := eventhub.Event{ + GatewayID: s.gatewayID, + OriginatedTimestamp: time.Now(), + EventType: eventhub.EventTypeAPI, + Action: action, + EntityID: entityID, + EventID: correlationID, + EventData: eventhub.EmptyEventData, + } + if err := s.eventHub.PublishEvent(s.gatewayID, event); err != nil { + logger.Warn("Failed to publish event to event hub", + slog.String("gateway_id", s.gatewayID), + slog.String("event_type", string(eventhub.EventTypeAPI)), + slog.String("action", action), + slog.String("entity_id", entityID), + slog.Any("error", err)) + } +} + +// CreateGraphQLAPIKey implements ServerInterface.CreateGraphQLAPIKey +// (POST /graphql-apis/{id}/api-keys) +func (s *APIServer) CreateGraphQLAPIKey(w http.ResponseWriter, r *http.Request, id string) { + log := middleware.GetLogger(r, s.logger) + handle := id + correlationID := middleware.GetCorrelationID(r) + + user, ok := s.extractAuthenticatedUser(w, r, "CreateGraphQLAPIKey", correlationID) + if !ok { + return + } + + var request api.APIKeyCreationRequest + if err := s.bindRequestBody(r, &request); err != nil { + log.Error("Failed to parse request body for GraphQL API key creation", + slog.Any("error", err), + slog.String("handle", handle), + slog.String("correlation_id", correlationID)) + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: fmt.Sprintf("Invalid request body: %v", err)}) + return + } + + params := utils.APIKeyCreationParams{ + Kind: models.KindGraphQLApi, + Handle: handle, + Request: request, + User: user, + CorrelationID: correlationID, + Logger: log, + } + + result, err := s.apiKeyService.CreateAPIKey(params) + if err != nil { + if strings.Contains(err.Error(), "not found") { + httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if storage.IsConflictError(err) || strings.Contains(err.Error(), "already exists") { + httputil.WriteJSON(w, http.StatusConflict, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else { + log.Error("Failed to create GraphQL API key", slog.String("handle", handle), slog.Any("error", err)) + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: "Failed to create API key"}) + } + return + } + + httputil.WriteJSON(w, http.StatusCreated, result.Response) +} + +// RevokeGraphQLAPIKey implements ServerInterface.RevokeGraphQLAPIKey +// (DELETE /graphql-apis/{id}/api-keys/{apiKeyName}) +func (s *APIServer) RevokeGraphQLAPIKey(w http.ResponseWriter, r *http.Request, id string, apiKeyName string) { + log := middleware.GetLogger(r, s.logger) + handle := id + correlationID := middleware.GetCorrelationID(r) + + user, ok := s.extractAuthenticatedUser(w, r, "RevokeGraphQLAPIKey", correlationID) + if !ok { + return + } + + params := utils.APIKeyRevocationParams{ + Kind: models.KindGraphQLApi, + Handle: handle, + APIKeyName: apiKeyName, + User: user, + CorrelationID: correlationID, + Logger: log, + } + + result, err := s.apiKeyService.RevokeAPIKey(params) + if err != nil { + if strings.Contains(err.Error(), "not found") { + httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else { + log.Error("Failed to revoke GraphQL API key", slog.String("handle", handle), slog.String("key", apiKeyName), slog.Any("error", err)) + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: "Failed to revoke API key"}) + } + return + } + + httputil.WriteJSON(w, http.StatusOK, result.Response) +} + +// UpdateGraphQLAPIKey implements ServerInterface.UpdateGraphQLAPIKey +// (PUT /graphql-apis/{id}/api-keys/{apiKeyName}) +func (s *APIServer) UpdateGraphQLAPIKey(w http.ResponseWriter, r *http.Request, id string, apiKeyName string) { + log := middleware.GetLogger(r, s.logger) + handle := id + correlationID := middleware.GetCorrelationID(r) + + user, ok := s.extractAuthenticatedUser(w, r, "UpdateGraphQLAPIKey", correlationID) + if !ok { + return + } + + var request api.APIKeyCreationRequest + if err := s.bindRequestBody(r, &request); err != nil { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: fmt.Sprintf("Invalid request body: %v", err)}) + return + } + + if request.ApiKey == nil || strings.TrimSpace(*request.ApiKey) == "" { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: "apiKey is required"}) + return + } + + params := utils.APIKeyUpdateParams{ + Kind: models.KindGraphQLApi, + Handle: handle, + APIKeyName: apiKeyName, + Request: request, + User: user, + CorrelationID: correlationID, + Logger: log, + } + + result, err := s.apiKeyService.UpdateAPIKey(params) + if err != nil { + if storage.IsOperationNotAllowedError(err) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if strings.Contains(err.Error(), "not found") { + httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if storage.IsConflictError(err) || strings.Contains(err.Error(), "already exists") { + httputil.WriteJSON(w, http.StatusConflict, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else { + log.Error("Failed to update GraphQL API key", slog.String("handle", handle), slog.String("key", apiKeyName), slog.Any("error", err)) + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: "Failed to update API key"}) + } + return + } + + httputil.WriteJSON(w, http.StatusOK, result.Response) +} + +// RegenerateGraphQLAPIKey implements ServerInterface.RegenerateGraphQLAPIKey +// (POST /graphql-apis/{id}/api-keys/{apiKeyName}/regenerate) +func (s *APIServer) RegenerateGraphQLAPIKey(w http.ResponseWriter, r *http.Request, id string, apiKeyName string) { + log := middleware.GetLogger(r, s.logger) + handle := id + correlationID := middleware.GetCorrelationID(r) + + user, ok := s.extractAuthenticatedUser(w, r, "RegenerateGraphQLAPIKey", correlationID) + if !ok { + return + } + + var request api.APIKeyRegenerationRequest + if err := s.bindRequestBody(r, &request); err != nil { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: fmt.Sprintf("Invalid request body: %v", err)}) + return + } + + params := utils.APIKeyRegenerationParams{ + Kind: models.KindGraphQLApi, + Handle: handle, + APIKeyName: apiKeyName, + Request: request, + User: user, + CorrelationID: correlationID, + Logger: log, + } + + result, err := s.apiKeyService.RegenerateAPIKey(params) + if err != nil { + if strings.Contains(err.Error(), "not found") { + httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else { + log.Error("Failed to regenerate GraphQL API key", slog.String("handle", handle), slog.String("key", apiKeyName), slog.Any("error", err)) + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: "Failed to regenerate API key"}) + } + return + } + + httputil.WriteJSON(w, http.StatusOK, result.Response) +} + +// ListGraphQLAPIKeys implements ServerInterface.ListGraphQLAPIKeys +// (GET /graphql-apis/{id}/api-keys) +func (s *APIServer) ListGraphQLAPIKeys(w http.ResponseWriter, r *http.Request, id string) { + log := middleware.GetLogger(r, s.logger) + handle := id + correlationID := middleware.GetCorrelationID(r) + + user, ok := s.extractAuthenticatedUser(w, r, "ListGraphQLAPIKeys", correlationID) + if !ok { + return + } + + params := utils.ListAPIKeyParams{ + Kind: models.KindGraphQLApi, + Handle: handle, + User: user, + CorrelationID: correlationID, + Logger: log, + } + + result, err := s.apiKeyService.ListAPIKeys(params) + if err != nil { + if strings.Contains(err.Error(), "not found") { + httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else { + log.Error("Failed to list GraphQL API keys", slog.String("handle", handle), slog.Any("error", err)) + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: "Failed to list API keys"}) + } + return + } + + httputil.WriteJSON(w, http.StatusOK, result.Response) +} + +// mapValidationError maps a *utils.ValidationErrorListError to a 400 response with +// structured field errors, mirroring RestAPIHandler.mapCreateError's handling of the +// same error type. +// isGraphQLAPICreateBadRequest reports whether err is a client-input failure from +// the shared DeployAPIConfiguration path (config-parse failure, kind mismatch, or +// missing origin) that CreateGraphQLAPI's other error mappers (mapRenderError, +// mapValidationError, storage.IsConflictError) don't recognize — without this, +// these fell through to a generic 500 instead of the 400 the caller actually made. +// Mirrors isRestAPICreateBadRequest (rest_api_handler.go); DeployAPIConfiguration +// is the same shared function for every kind, so most error message classes are +// identical. One is GraphQL-specific: this endpoint hardcodes params.Kind = +// "GraphQLApi" so the body is always parsed as a GraphQLAPI regardless of what its +// own kind field says, but DeployAPIConfiguration's validator lookup keys off that +// parsed kind field, not the resolved one — a body with a wrong kind value parses +// fine, then fails validator lookup with "unexpected configuration type" instead +// of a recognized validation error. +func isGraphQLAPICreateBadRequest(err error) bool { + if err == nil { + return false + } + + message := strings.ToLower(err.Error()) + return strings.Contains(message, "failed to parse configuration") || + strings.Contains(message, "resource kind is required") || + strings.Contains(message, "unsupported resource kind") || + strings.Contains(message, "invalid or missing origin") || + strings.Contains(message, "unexpected configuration type") +} + +func mapValidationError(w http.ResponseWriter, err error) bool { + var validationErr *utils.ValidationErrorListError + if !errors.As(err, &validationErr) { + return false + } + apiErrors := make([]api.ValidationError, len(validationErr.Errors)) + for i, e := range validationErr.Errors { + apiErrors[i] = api.ValidationError{ + Field: stringPtr(e.Field), + Message: stringPtr(e.Message), + } + } + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: "Configuration validation failed", + Errors: &apiErrors, + }) + return true +} diff --git a/gateway/gateway-controller/pkg/api/handlers/graphql_apikey_handler_test.go b/gateway/gateway-controller/pkg/api/handlers/graphql_apikey_handler_test.go new file mode 100644 index 0000000000..fd843025ad --- /dev/null +++ b/gateway/gateway-controller/pkg/api/handlers/graphql_apikey_handler_test.go @@ -0,0 +1,580 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/wso2/api-platform/common/apikey" + "github.com/wso2/api-platform/common/eventhub" + commonmodels "github.com/wso2/api-platform/common/models" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" +) + +// seedGraphQLAPIForAPIKeyHandlerTests mirrors seedAPIForAPIKeyHandlerTests but +// stores a GraphQLApi-kind config instead of RestApi, since API key operations +// are dispatched by artifact kind (models.KindGraphQLApi). +func seedGraphQLAPIForAPIKeyHandlerTests(t *testing.T, server *APIServer, handle string) *models.StoredConfig { + t.Helper() + + graphqlConfig := api.GraphQLAPI{ + ApiVersion: api.GraphQLAPIApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.GraphQLAPIKindGraphQLApi, + Metadata: api.Metadata{ + Name: handle, + }, + Spec: api.GraphQLAPIConfigData{ + DisplayName: "Test GraphQL API", + Version: "v1.0.0", + Context: "/test-graphql", + Upstream: struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{ + Url: stringPtr("http://backend.example.com/graphql"), + }, + }, + }, + } + + cfg := &models.StoredConfig{ + UUID: "0000-test-api-id-0000-000000000000", + Kind: string(models.KindGraphQLApi), + Handle: handle, + DisplayName: graphqlConfig.Spec.DisplayName, + Version: graphqlConfig.Spec.Version, + Configuration: graphqlConfig, + SourceConfiguration: graphqlConfig, + DesiredState: models.StateDeployed, + Origin: models.OriginGatewayAPI, + } + + require.NoError(t, server.store.Add(cfg)) + require.NoError(t, server.db.SaveConfig(cfg)) + + return cfg +} + +// --- CreateGraphQLAPIKey --- + +func TestCreateGraphQLAPIKeyNoAuth(t *testing.T) { + server := createTestAPIServer() + + body := []byte(`{"name": "test-key"}`) + w, r := createTestContextWithHeader("POST", "/graphql-apis/test-handle/api-keys", body, map[string]string{ + "Content-Type": "application/json", + }) + server.CreateGraphQLAPIKey(w, r, "0000-test-handle-0000-000000000000") + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestCreateGraphQLAPIKeyInvalidBody(t *testing.T) { + server := createTestAPIServer() + + w, r := createTestContextWithHeader("POST", "/graphql-apis/test-handle/api-keys", []byte("invalid json {{{"), map[string]string{ + "Content-Type": "application/json", + }) + r = withAuthContext(r, commonmodels.AuthContext{ + UserID: "test-user", + Roles: []string{"admin"}, + }) + server.CreateGraphQLAPIKey(w, r, "0000-test-handle-0000-000000000000") + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestCreateGraphQLAPIKeyWithDBAndEventHub(t *testing.T) { + server := createTestAPIServer() + cfg := seedGraphQLAPIForAPIKeyHandlerTests(t, server, "test-handle") + mockDB := server.db.(*MockStorage) + mockHub := &mockEventHub{} + attachTestEventHub(server, mockHub, "test-gateway") + + body := createTestAPIKeyRequestBody(t, "test-key", "Test Key", "external-key-123456789012345678901234567890123456") + w, r := createTestContextWithHeader("POST", "/graphql-apis/test-handle/api-keys", body, map[string]string{ + "Content-Type": "application/json", + }) + r = withAuthContext(r, commonmodels.AuthContext{ + UserID: "test-user", + Roles: []string{"admin"}, + }) + r = withCorrelationID(r, "corr-id-create-graphql-key") + + server.CreateGraphQLAPIKey(w, r, "test-handle") + + assert.Equal(t, http.StatusCreated, w.Code) + require.Len(t, mockHub.publishedEvents, 1) + + createdKey, err := mockDB.GetAPIKeysByAPIAndName(cfg.UUID, "test-key") + require.NoError(t, err) + assert.Equal(t, cfg.UUID, createdKey.ArtifactUUID) + assert.Equal(t, "test-user", createdKey.CreatedBy) + assert.Equal(t, string(api.External), createdKey.Source) + + assert.Equal(t, "test-gateway", mockHub.publishedEvents[0].gatewayID) + assert.Equal(t, eventhub.EventTypeAPIKey, mockHub.publishedEvents[0].event.EventType) + assert.Equal(t, "CREATE", mockHub.publishedEvents[0].event.Action) + assert.Equal(t, apikey.BuildAPIKeyEntityID(cfg.UUID, createdKey.UUID), mockHub.publishedEvents[0].event.EntityID) + assert.Equal(t, "corr-id-create-graphql-key", mockHub.publishedEvents[0].event.EventID) +} + +func TestCreateGraphQLAPIKeyDBError(t *testing.T) { + server := createTestAPIServer() + cfg := seedGraphQLAPIForAPIKeyHandlerTests(t, server, "test-handle") + mockDB := server.db.(*MockStorage) + mockHub := &mockEventHub{} + attachTestEventHub(server, mockHub, "test-gateway") + mockDB.saveErr = errors.New("db save error") + + body := createTestAPIKeyRequestBody(t, "test-key", "Test Key", "external-key-123456789012345678901234567890123456") + w, r := createTestContextWithHeader("POST", "/graphql-apis/test-handle/api-keys", body, map[string]string{ + "Content-Type": "application/json", + }) + r = withAuthContext(r, commonmodels.AuthContext{ + UserID: "test-user", + Roles: []string{"admin"}, + }) + + server.CreateGraphQLAPIKey(w, r, "test-handle") + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Empty(t, mockHub.publishedEvents) + + _, err := mockDB.GetAPIKeysByAPIAndName(cfg.UUID, "test-key") + require.Error(t, err) +} + +// TestCreateGraphQLAPIKeyExpirationInPast_ReturnsBadRequest guards against a regression where +// an expiresIn duration that computes to a past timestamp — a client input error — was mapped +// to a generic 500 instead of 400; see the identical fix applied to REST's CreateAPIKey. +func TestCreateGraphQLAPIKeyExpirationInPast_ReturnsBadRequest(t *testing.T) { + server := createTestAPIServer() + seedGraphQLAPIForAPIKeyHandlerTests(t, server, "test-handle") + + name := "test-key" + request := api.APIKeyCreationRequest{ + Name: &name, + ExpiresIn: &struct { + Duration int `json:"duration" yaml:"duration"` + Unit api.APIKeyCreationRequestExpiresInUnit `json:"unit" yaml:"unit"` + }{Duration: -10, Unit: api.APIKeyCreationRequestExpiresInUnitSeconds}, + } + body, err := json.Marshal(request) + require.NoError(t, err) + + w, r := createTestContextWithHeader("POST", "/graphql-apis/test-handle/api-keys", body, map[string]string{ + "Content-Type": "application/json", + }) + r = withAuthContext(r, commonmodels.AuthContext{UserID: "test-user", Roles: []string{"admin"}}) + + server.CreateGraphQLAPIKey(w, r, "test-handle") + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "must be in the future") +} + +func TestCreateGraphQLAPIKeyAPINotFound(t *testing.T) { + server := createTestAPIServer() + + body := createTestAPIKeyRequestBody(t, "test-key", "Test Key", "external-key-123456789012345678901234567890123456") + w, r := createTestContextWithHeader("POST", "/graphql-apis/nonexistent/api-keys", body, map[string]string{ + "Content-Type": "application/json", + }) + r = withAuthContext(r, commonmodels.AuthContext{ + UserID: "test-user", + Roles: []string{"admin"}, + }) + + server.CreateGraphQLAPIKey(w, r, "nonexistent") + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +// --- RevokeGraphQLAPIKey --- + +func TestRevokeGraphQLAPIKeyNoAuth(t *testing.T) { + server := createTestAPIServer() + + w, r := createTestContext("DELETE", "/graphql-apis/test-handle/api-keys/test-key", nil) + server.RevokeGraphQLAPIKey(w, r, "0000-test-handle-0000-000000000000", "test-key") + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestRevokeGraphQLAPIKeyWithDBAndEventHub(t *testing.T) { + server := createTestAPIServer() + cfg := seedGraphQLAPIForAPIKeyHandlerTests(t, server, "test-handle") + mockDB := server.db.(*MockStorage) + mockHub := &mockEventHub{} + attachTestEventHub(server, mockHub, "test-gateway") + + storeKey := createStoredExternalAPIKey("0000-test-key-id-0000-000000000000", cfg.UUID, "test-key", "Test Key", "test-user", "apip_****old") + dbKey := *storeKey + require.NoError(t, server.store.StoreAPIKey(storeKey)) + require.NoError(t, mockDB.SaveAPIKey(&dbKey)) + + w, r := createTestContext("DELETE", "/graphql-apis/test-handle/api-keys/test-key", nil) + r = withAuthContext(r, commonmodels.AuthContext{ + UserID: "test-user", + Roles: []string{"admin"}, + }) + r = withCorrelationID(r, "corr-id-revoke-graphql-key") + + server.RevokeGraphQLAPIKey(w, r, "test-handle", "test-key") + + assert.Equal(t, http.StatusOK, w.Code) + require.Len(t, mockHub.publishedEvents, 1) + assert.Equal(t, "DELETE", mockHub.publishedEvents[0].event.Action) + assert.Equal(t, apikey.BuildAPIKeyEntityID(cfg.UUID, storeKey.UUID), mockHub.publishedEvents[0].event.EntityID) + + _, err := mockDB.GetAPIKeysByAPIAndName(cfg.UUID, "test-key") + require.Error(t, err) +} + +func TestRevokeGraphQLAPIKeyDBError(t *testing.T) { + server := createTestAPIServer() + cfg := seedGraphQLAPIForAPIKeyHandlerTests(t, server, "test-handle") + mockDB := server.db.(*MockStorage) + mockHub := &mockEventHub{} + attachTestEventHub(server, mockHub, "test-gateway") + mockDB.updateErr = errors.New("db update error") + + storeKey := createStoredExternalAPIKey("0000-test-key-id-0000-000000000000", cfg.UUID, "test-key", "Test Key", "test-user", "apip_****old") + dbKey := *storeKey + require.NoError(t, server.store.StoreAPIKey(storeKey)) + require.NoError(t, mockDB.SaveAPIKey(&dbKey)) + + w, r := createTestContext("DELETE", "/graphql-apis/test-handle/api-keys/test-key", nil) + r = withAuthContext(r, commonmodels.AuthContext{ + UserID: "test-user", + Roles: []string{"admin"}, + }) + + server.RevokeGraphQLAPIKey(w, r, "test-handle", "test-key") + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Empty(t, mockHub.publishedEvents) + + storedKey, err := mockDB.GetAPIKeysByAPIAndName(cfg.UUID, "test-key") + require.NoError(t, err) + assert.Equal(t, models.APIKeyStatusActive, storedKey.Status) +} + +func TestRevokeGraphQLAPIKeyNotFound(t *testing.T) { + server := createTestAPIServer() + + w, r := createTestContext("DELETE", "/graphql-apis/test-handle/api-keys/nonexistent", nil) + r = withAuthContext(r, commonmodels.AuthContext{ + UserID: "test-user", + Roles: []string{"admin"}, + }) + server.RevokeGraphQLAPIKey(w, r, "0000-test-handle-0000-000000000000", "nonexistent") + + assert.Equal(t, http.StatusNotFound, w.Code) + + var response api.ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + assert.Equal(t, "error", response.Status) +} + +// --- RegenerateGraphQLAPIKey --- + +func TestRegenerateGraphQLAPIKeyNoAuth(t *testing.T) { + server := createTestAPIServer() + + body := []byte(`{}`) + w, r := createTestContextWithHeader("POST", "/graphql-apis/test-handle/api-keys/test-key/regenerate", body, map[string]string{ + "Content-Type": "application/json", + }) + server.RegenerateGraphQLAPIKey(w, r, "0000-test-handle-0000-000000000000", "test-key") + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestRegenerateGraphQLAPIKeyInvalidBody(t *testing.T) { + server := createTestAPIServer() + + w, r := createTestContextWithHeader("POST", "/graphql-apis/test-handle/api-keys/test-key/regenerate", []byte("invalid {{{"), map[string]string{ + "Content-Type": "application/json", + }) + r = withAuthContext(r, commonmodels.AuthContext{ + UserID: "test-user", + Roles: []string{"admin"}, + }) + server.RegenerateGraphQLAPIKey(w, r, "0000-test-handle-0000-000000000000", "test-key") + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestRegenerateGraphQLAPIKeyWithDBAndEventHub(t *testing.T) { + server := createTestAPIServer() + cfg := seedGraphQLAPIForAPIKeyHandlerTests(t, server, "test-handle") + mockDB := server.db.(*MockStorage) + mockHub := &mockEventHub{} + attachTestEventHub(server, mockHub, "test-gateway") + + storeKey := createStoredExternalAPIKey("0000-test-key-id-0000-000000000000", cfg.UUID, "test-key", "Test Key", "test-user", "apip_****old") + dbKey := *storeKey + require.NoError(t, server.store.StoreAPIKey(storeKey)) + require.NoError(t, mockDB.SaveAPIKey(&dbKey)) + + w, r := createTestContextWithHeader("POST", "/graphql-apis/test-handle/api-keys/test-key/regenerate", []byte(`{}`), map[string]string{ + "Content-Type": "application/json", + }) + r = withAuthContext(r, commonmodels.AuthContext{ + UserID: "test-user", + Roles: []string{"admin"}, + }) + r = withCorrelationID(r, "corr-id-regenerate-graphql-key") + + server.RegenerateGraphQLAPIKey(w, r, "test-handle", "test-key") + + assert.Equal(t, http.StatusOK, w.Code) + require.Len(t, mockHub.publishedEvents, 1) + assert.Equal(t, "corr-id-regenerate-graphql-key", mockHub.publishedEvents[0].event.EventID) + + regeneratedKey, err := mockDB.GetAPIKeysByAPIAndName(cfg.UUID, "test-key") + require.NoError(t, err) + assert.NotEqual(t, "apip_****old", regeneratedKey.MaskedAPIKey) +} + +func TestRegenerateGraphQLAPIKeyNotFound(t *testing.T) { + server := createTestAPIServer() + + w, r := createTestContextWithHeader("POST", "/graphql-apis/test-handle/api-keys/nonexistent/regenerate", []byte(`{}`), map[string]string{ + "Content-Type": "application/json", + }) + r = withAuthContext(r, commonmodels.AuthContext{ + UserID: "test-user", + Roles: []string{"admin"}, + }) + server.RegenerateGraphQLAPIKey(w, r, "0000-test-handle-0000-000000000000", "nonexistent") + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +// --- UpdateGraphQLAPIKey --- + +func TestUpdateGraphQLAPIKeyNoAuth(t *testing.T) { + server := createTestAPIServer() + + body := []byte(`{"apiKey": "new-key-value"}`) + w, r := createTestContextWithHeader("PUT", "/graphql-apis/test-handle/api-keys/test-key", body, map[string]string{ + "Content-Type": "application/json", + }) + server.UpdateGraphQLAPIKey(w, r, "0000-test-handle-0000-000000000000", "test-key") + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestUpdateGraphQLAPIKeyInvalidBody(t *testing.T) { + server := createTestAPIServer() + + w, r := createTestContextWithHeader("PUT", "/graphql-apis/test-handle/api-keys/test-key", []byte("invalid json {{{"), map[string]string{ + "Content-Type": "application/json", + }) + r = withAuthContext(r, commonmodels.AuthContext{ + UserID: "test-user", + Roles: []string{"admin"}, + }) + server.UpdateGraphQLAPIKey(w, r, "0000-test-handle-0000-000000000000", "test-key") + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUpdateGraphQLAPIKeyMissingAPIKey(t *testing.T) { + server := createTestAPIServer() + + body := []byte(`{"description": "test"}`) + w, r := createTestContextWithHeader("PUT", "/graphql-apis/test-handle/api-keys/test-key", body, map[string]string{ + "Content-Type": "application/json", + }) + r = withAuthContext(r, commonmodels.AuthContext{ + UserID: "test-user", + Roles: []string{"admin"}, + }) + server.UpdateGraphQLAPIKey(w, r, "0000-test-handle-0000-000000000000", "test-key") + + assert.Equal(t, http.StatusBadRequest, w.Code) + + var response api.ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + assert.Equal(t, "apiKey is required", response.Message) +} + +func TestUpdateGraphQLAPIKeyWithDBAndEventHub(t *testing.T) { + server := createTestAPIServer() + cfg := seedGraphQLAPIForAPIKeyHandlerTests(t, server, "test-handle") + mockDB := server.db.(*MockStorage) + mockHub := &mockEventHub{} + attachTestEventHub(server, mockHub, "test-gateway") + + storeKey := createStoredExternalAPIKey("0000-test-key-id-0000-000000000000", cfg.UUID, "test-key", "Old Key", "test-user", "apip_****old") + dbKey := *storeKey + require.NoError(t, server.store.StoreAPIKey(storeKey)) + require.NoError(t, mockDB.SaveAPIKey(&dbKey)) + + body := createTestAPIKeyRequestBody(t, "test-key", "Updated Key", "external-key-abcdef1234567890abcdef1234567890abcdef") + w, r := createTestContextWithHeader("PUT", "/graphql-apis/test-handle/api-keys/test-key", body, map[string]string{ + "Content-Type": "application/json", + }) + r = withAuthContext(r, commonmodels.AuthContext{ + UserID: "test-user", + Roles: []string{"admin"}, + }) + r = withCorrelationID(r, "corr-id-update-graphql-key") + + server.UpdateGraphQLAPIKey(w, r, "test-handle", "test-key") + + assert.Equal(t, http.StatusOK, w.Code) + require.Len(t, mockHub.publishedEvents, 1) + assert.Equal(t, "UPDATE", mockHub.publishedEvents[0].event.Action) + + updatedKey, err := mockDB.GetAPIKeysByAPIAndName(cfg.UUID, "test-key") + require.NoError(t, err) + assert.Equal(t, models.APIKeyStatusActive, updatedKey.Status) + assert.NotEqual(t, "apip_****old", updatedKey.MaskedAPIKey) +} + +func TestUpdateGraphQLAPIKeyDBError(t *testing.T) { + server := createTestAPIServer() + cfg := seedGraphQLAPIForAPIKeyHandlerTests(t, server, "test-handle") + mockDB := server.db.(*MockStorage) + mockHub := &mockEventHub{} + attachTestEventHub(server, mockHub, "test-gateway") + mockDB.updateErr = errors.New("db update error") + + storeKey := createStoredExternalAPIKey("0000-test-key-id-0000-000000000000", cfg.UUID, "test-key", "Old Key", "test-user", "apip_****old") + dbKey := *storeKey + require.NoError(t, server.store.StoreAPIKey(storeKey)) + require.NoError(t, mockDB.SaveAPIKey(&dbKey)) + + body := createTestAPIKeyRequestBody(t, "test-key", "Updated Key", "external-key-abcdef1234567890abcdef1234567890abcdef") + w, r := createTestContextWithHeader("PUT", "/graphql-apis/test-handle/api-keys/test-key", body, map[string]string{ + "Content-Type": "application/json", + }) + r = withAuthContext(r, commonmodels.AuthContext{ + UserID: "test-user", + Roles: []string{"admin"}, + }) + + server.UpdateGraphQLAPIKey(w, r, "test-handle", "test-key") + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Empty(t, mockHub.publishedEvents) + + storedKey, err := mockDB.GetAPIKeysByAPIAndName(cfg.UUID, "test-key") + require.NoError(t, err) + assert.Equal(t, "apip_****old", storedKey.MaskedAPIKey) +} + +// TestUpdateGraphQLAPIKeyRejectsLocalKey guards the business rule surfaced live +// during manual verification: a locally-generated (non-external) key cannot be +// updated with a custom value — only regenerated. Confirms +// storage.IsOperationNotAllowedError is mapped to 400, matching REST's handling. +func TestUpdateGraphQLAPIKeyRejectsLocalKey(t *testing.T) { + server := createTestAPIServer() + cfg := seedGraphQLAPIForAPIKeyHandlerTests(t, server, "test-handle") + mockDB := server.db.(*MockStorage) + mockHub := &mockEventHub{} + attachTestEventHub(server, mockHub, "test-gateway") + + // A locally-generated key has Source == "local", not "external". + localKey := createStoredExternalAPIKey("0000-test-key-id-0000-000000000000", cfg.UUID, "test-key", "Local Key", "test-user", "apip_****local") + localKey.Source = "local" + dbKey := *localKey + require.NoError(t, server.store.StoreAPIKey(localKey)) + require.NoError(t, mockDB.SaveAPIKey(&dbKey)) + + body := createTestAPIKeyRequestBody(t, "test-key", "Updated Key", "external-key-abcdef1234567890abcdef1234567890abcdef") + w, r := createTestContextWithHeader("PUT", "/graphql-apis/test-handle/api-keys/test-key", body, map[string]string{ + "Content-Type": "application/json", + }) + r = withAuthContext(r, commonmodels.AuthContext{ + UserID: "test-user", + Roles: []string{"admin"}, + }) + + server.UpdateGraphQLAPIKey(w, r, "test-handle", "test-key") + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Empty(t, mockHub.publishedEvents) +} + +// --- ListGraphQLAPIKeys --- + +func TestListGraphQLAPIKeysNoAuth(t *testing.T) { + server := createTestAPIServer() + + w, r := createTestContext("GET", "/graphql-apis/test-handle/api-keys", nil) + server.ListGraphQLAPIKeys(w, r, "0000-test-handle-0000-000000000000") + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestListGraphQLAPIKeysSuccess(t *testing.T) { + server := createTestAPIServer() + cfg := seedGraphQLAPIForAPIKeyHandlerTests(t, server, "test-handle") + mockDB := server.db.(*MockStorage) + + key1 := createStoredExternalAPIKey("0000-key1-0000-000000000000", cfg.UUID, "key-1", "Key One", "test-user", "***key-1") + key2 := createStoredExternalAPIKey("0000-key2-0000-000000000000", cfg.UUID, "key-2", "Key Two", "test-user", "***key-2") + mockDB.apiKeys[key1.UUID] = key1 + mockDB.apiKeys[key2.UUID] = key2 + + w, r := createTestContext("GET", "/graphql-apis/test-handle/api-keys", nil) + r = withAuthContext(r, commonmodels.AuthContext{ + UserID: "test-user", + Roles: []string{"admin"}, + }) + + server.ListGraphQLAPIKeys(w, r, "test-handle") + + assert.Equal(t, http.StatusOK, w.Code) + + var response map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + assert.Equal(t, "success", response["status"]) +} + +func TestListGraphQLAPIKeysAPINotFound(t *testing.T) { + server := createTestAPIServer() + + w, r := createTestContext("GET", "/graphql-apis/nonexistent/api-keys", nil) + r = withAuthContext(r, commonmodels.AuthContext{ + UserID: "test-user", + Roles: []string{"admin"}, + }) + + server.ListGraphQLAPIKeys(w, r, "nonexistent") + + assert.Equal(t, http.StatusNotFound, w.Code) + + var response api.ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + assert.Equal(t, "error", response.Status) +} diff --git a/gateway/gateway-controller/pkg/api/handlers/handlers_test.go b/gateway/gateway-controller/pkg/api/handlers/handlers_test.go index fbfe07a66c..7a88f8b62c 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers_test.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers_test.go @@ -2349,6 +2349,36 @@ func TestCreateAPIKeyDBError(t *testing.T) { require.Error(t, err) } +// TestCreateAPIKeyExpirationInPast_ReturnsBadRequest guards against a regression where a client +// input error (an expiresIn duration that computes to a past timestamp) was mapped to a generic +// 500 instead of 400 — the same fix applied identically to the GraphQL, LLM provider, and LLM +// proxy API-key handlers, which all share this same createAPIKeyFromRequest/CreateAPIKey path. +func TestCreateAPIKeyExpirationInPast_ReturnsBadRequest(t *testing.T) { + server := createTestAPIServer() + seedAPIForAPIKeyHandlerTests(t, server, "test-handle") + + name := "test-key" + request := api.APIKeyCreationRequest{ + Name: &name, + ExpiresIn: &struct { + Duration int `json:"duration" yaml:"duration"` + Unit api.APIKeyCreationRequestExpiresInUnit `json:"unit" yaml:"unit"` + }{Duration: -10, Unit: api.APIKeyCreationRequestExpiresInUnitSeconds}, + } + body, err := json.Marshal(request) + require.NoError(t, err) + + w, r := createTestContextWithHeader("POST", "/rest-apis/test-handle/api-keys", body, map[string]string{ + "Content-Type": "application/json", + }) + r = withAuthContext(r, commonmodels.AuthContext{UserID: "test-user", Roles: []string{"admin"}}) + + server.CreateAPIKey(w, r, "test-handle") + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "must be in the future") +} + // TestRevokeAPIKeyNoAuth tests RevokeAPIKey without authentication func TestRevokeAPIKeyNoAuth(t *testing.T) { server := createTestAPIServer() diff --git a/gateway/gateway-controller/pkg/api/handlers/llm_provider_handler.go b/gateway/gateway-controller/pkg/api/handlers/llm_provider_handler.go index 1081c91f41..a497583c6a 100644 --- a/gateway/gateway-controller/pkg/api/handlers/llm_provider_handler.go +++ b/gateway/gateway-controller/pkg/api/handlers/llm_provider_handler.go @@ -20,6 +20,7 @@ package handlers import ( "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -292,6 +293,8 @@ func (s *APIServer) CreateLLMProviderAPIKey(w http.ResponseWriter, r *http.Reque httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) } else if storage.IsConflictError(err) || strings.Contains(err.Error(), "already exists") { httputil.WriteJSON(w, http.StatusConflict, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: err.Error()}) } @@ -376,6 +379,8 @@ func (s *APIServer) UpdateLLMProviderAPIKey(w http.ResponseWriter, r *http.Reque httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) } else if storage.IsConflictError(err) || strings.Contains(err.Error(), "already exists") { httputil.WriteJSON(w, http.StatusConflict, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: err.Error()}) } @@ -417,6 +422,8 @@ func (s *APIServer) RegenerateLLMProviderAPIKey(w http.ResponseWriter, r *http.R if err != nil { if strings.Contains(err.Error(), "not found") { httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: err.Error()}) } diff --git a/gateway/gateway-controller/pkg/api/handlers/llm_proxy_handler.go b/gateway/gateway-controller/pkg/api/handlers/llm_proxy_handler.go index 59bac53c5a..b5eaa57ac4 100644 --- a/gateway/gateway-controller/pkg/api/handlers/llm_proxy_handler.go +++ b/gateway/gateway-controller/pkg/api/handlers/llm_proxy_handler.go @@ -307,6 +307,8 @@ func (s *APIServer) CreateLLMProxyAPIKey(w http.ResponseWriter, r *http.Request, httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: fmt.Sprintf("LLM proxy '%s' not found", handle)}) } else if storage.IsConflictError(err) { httputil.WriteJSON(w, http.StatusConflict, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { log.Error("Failed to create LLM proxy API key", slog.String("handle", handle), slog.Any("error", err)) httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: "Failed to create API key"}) @@ -393,6 +395,8 @@ func (s *APIServer) UpdateLLMProxyAPIKey(w http.ResponseWriter, r *http.Request, httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: fmt.Sprintf("LLM proxy or API key '%s' not found", apiKeyName)}) } else if storage.IsConflictError(err) { httputil.WriteJSON(w, http.StatusConflict, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { log.Error("Failed to update LLM proxy API key", slog.String("handle", handle), slog.String("key", apiKeyName), slog.Any("error", err)) httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: "Failed to update API key"}) @@ -435,6 +439,8 @@ func (s *APIServer) RegenerateLLMProxyAPIKey(w http.ResponseWriter, r *http.Requ if err != nil { if storage.IsNotFoundError(err) { httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: fmt.Sprintf("LLM proxy or API key '%s' not found", apiKeyName)}) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { log.Error("Failed to regenerate LLM proxy API key", slog.String("handle", handle), slog.String("key", apiKeyName), slog.Any("error", err)) httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: "Failed to regenerate API key"}) diff --git a/gateway/gateway-controller/pkg/api/handlers/resource_response.go b/gateway/gateway-controller/pkg/api/handlers/resource_response.go index a41c7b7d73..e3171d8d72 100644 --- a/gateway/gateway-controller/pkg/api/handlers/resource_response.go +++ b/gateway/gateway-controller/pkg/api/handlers/resource_response.go @@ -68,6 +68,16 @@ func buildResourceResponse(cfg any, status api.ResourceStatus) any { cp := *v cp.Status = &status return cp + case api.GraphQLAPI: + v.Status = &status + return v + case *api.GraphQLAPI: + if v == nil { + return nil + } + cp := *v + cp.Status = &status + return cp case api.MCPProxyConfiguration: v.Status = &status return v diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index 9d8e9d5542..b3cbe183f9 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -157,6 +157,32 @@ const ( QueryParam ExtractionIdentifierLocation = "queryParam" ) +// Defines values for GraphQLAPIApiVersion. +const ( + GraphQLAPIApiVersionGatewayApiPlatformWso2Comv1 GraphQLAPIApiVersion = "gateway.api-platform.wso2.com/v1" +) + +// Defines values for GraphQLAPIKind. +const ( + GraphQLAPIKindGraphQLApi GraphQLAPIKind = "GraphQLApi" +) + +// Defines values for GraphQLAPIConfigDataDeploymentState. +const ( + GraphQLAPIConfigDataDeploymentStateDeployed GraphQLAPIConfigDataDeploymentState = "deployed" + GraphQLAPIConfigDataDeploymentStateUndeployed GraphQLAPIConfigDataDeploymentState = "undeployed" +) + +// Defines values for GraphQLAPIRequestApiVersion. +const ( + GraphQLAPIRequestApiVersionGatewayApiPlatformWso2Comv1 GraphQLAPIRequestApiVersion = "gateway.api-platform.wso2.com/v1" +) + +// Defines values for GraphQLAPIRequestKind. +const ( + GraphQLAPIRequestKindGraphQLApi GraphQLAPIRequestKind = "GraphQLApi" +) + // Defines values for LLMAccessControlMode. const ( AllowAll LLMAccessControlMode = "allow_all" @@ -494,6 +520,12 @@ const ( ListAgentsParamsStatusUndeployed ListAgentsParamsStatus = "undeployed" ) +// Defines values for ListGraphQLAPIsParamsStatus. +const ( + ListGraphQLAPIsParamsStatusDeployed ListGraphQLAPIsParamsStatus = "deployed" + ListGraphQLAPIsParamsStatusUndeployed ListGraphQLAPIsParamsStatus = "undeployed" +) + // Defines values for ListLLMProvidersParamsStatus. const ( ListLLMProvidersParamsStatusDeployed ListLLMProvidersParamsStatus = "deployed" @@ -1043,6 +1075,81 @@ type ExtractionIdentifier struct { // ExtractionIdentifierLocation Where to find the token information type ExtractionIdentifierLocation string +// GraphQLAPI defines model for GraphQLAPI. +type GraphQLAPI struct { + // ApiVersion API specification version + ApiVersion GraphQLAPIApiVersion `json:"apiVersion" yaml:"apiVersion"` + + // Kind API type + Kind GraphQLAPIKind `json:"kind" yaml:"kind"` + Metadata Metadata `json:"metadata" yaml:"metadata"` + Spec GraphQLAPIConfigData `json:"spec" yaml:"spec"` + + // Status Server-managed lifecycle fields. Populated on responses. + Status *ResourceStatus `json:"status,omitempty" yaml:"status,omitempty"` +} + +// GraphQLAPIApiVersion API specification version +type GraphQLAPIApiVersion string + +// GraphQLAPIKind API type +type GraphQLAPIKind string + +// GraphQLAPIConfigData defines model for GraphQLAPIConfigData. +type GraphQLAPIConfigData struct { + // Context Base path for the single GraphQL endpoint (must start with /, no trailing slash). Use $version to embed the version in the path (e.g., /countries/$version resolves to /countries/v1.0). A GraphQLApi always exposes exactly one POST route at this path — there is no per-operation path list. Suggested (not enforced) convention: end the path with /graphql, matching how most standalone GraphQL servers name their single endpoint (e.g. /countries/$version/graphql) — this is not validated or required. + Context string `json:"context" yaml:"context"` + + // DeploymentState Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the API is removed from router traffic but configuration, API keys, and policies are preserved for potential redeployment. + DeploymentState *GraphQLAPIConfigDataDeploymentState `json:"deploymentState,omitempty" yaml:"deploymentState,omitempty"` + + // DisplayName Human-readable API name (must be URL-friendly - only letters, numbers, spaces, hyphens, underscores, and dots allowed) + DisplayName string `json:"displayName" yaml:"displayName"` + + // Policies List of policies applied to the single GraphQL route. A `cors` + // policy applies only to that route's `POST` method — a GraphQLApi + // has no operations[] list to add an `OPTIONS` entry to, so a + // browser preflight request is not routed at all and a `cors` + // policy will not run for it; cross-origin browser clients that + // trigger a preflight are not currently supported. + Policies *[]Policy `json:"policies,omitempty" yaml:"policies,omitempty"` + + // SubscriptionPlans List of subscription plan names available for this API + SubscriptionPlans *[]string `json:"subscriptionPlans,omitempty" yaml:"subscriptionPlans,omitempty"` + + // Upstream API-level upstream configuration. A GraphQLApi has exactly one logical endpoint (no per-operation paths), so upstream.main.url is the single GraphQL endpoint to proxy to. Only a direct inline url is supported — GraphQLAPIConfigData has no upstreamDefinitions list, so upstream.ref (used by RestApi to reference a predefined upstreamDefinition) cannot be resolved and is rejected. + Upstream struct { + // Main Upstream backend configuration (single target or reference) + Main Upstream `json:"main" yaml:"main"` + + // Sandbox Upstream backend configuration (single target or reference) + Sandbox *Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + } `json:"upstream" yaml:"upstream"` + + // Version Semantic version of the API. Both major-only (v1) and major.minor (v1.0) forms are accepted. + Version string `json:"version" yaml:"version"` +} + +// GraphQLAPIConfigDataDeploymentState Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the API is removed from router traffic but configuration, API keys, and policies are preserved for potential redeployment. +type GraphQLAPIConfigDataDeploymentState string + +// GraphQLAPIRequest defines model for GraphQLAPIRequest. +type GraphQLAPIRequest struct { + // ApiVersion API specification version + ApiVersion GraphQLAPIRequestApiVersion `json:"apiVersion" yaml:"apiVersion"` + + // Kind API type + Kind GraphQLAPIRequestKind `json:"kind" yaml:"kind"` + Metadata Metadata `json:"metadata" yaml:"metadata"` + Spec GraphQLAPIConfigData `json:"spec" yaml:"spec"` +} + +// GraphQLAPIRequestApiVersion API specification version +type GraphQLAPIRequestApiVersion string + +// GraphQLAPIRequestKind API type +type GraphQLAPIRequestKind string + // LLMAccessControl defines model for LLMAccessControl. type LLMAccessControl struct { // Exceptions Path exceptions to the access control mode @@ -2194,6 +2301,24 @@ type ListAgentsParams struct { // ListAgentsParamsStatus defines parameters for ListAgents. type ListAgentsParamsStatus string +// ListGraphQLAPIsParams defines parameters for ListGraphQLAPIs. +type ListGraphQLAPIsParams struct { + // DisplayName Filter by API display name + DisplayName *string `form:"displayName,omitempty" json:"displayName,omitempty" yaml:"displayName,omitempty"` + + // Version Filter by API version + Version *string `form:"version,omitempty" json:"version,omitempty" yaml:"version,omitempty"` + + // Context Filter by API context/path + Context *string `form:"context,omitempty" json:"context,omitempty" yaml:"context,omitempty"` + + // Status Filter by deployment status + Status *ListGraphQLAPIsParamsStatus `form:"status,omitempty" json:"status,omitempty" yaml:"status,omitempty"` +} + +// ListGraphQLAPIsParamsStatus defines parameters for ListGraphQLAPIs. +type ListGraphQLAPIsParamsStatus string + // ListLLMProviderTemplatesParams defines parameters for ListLLMProviderTemplates. type ListLLMProviderTemplatesParams struct { // DisplayName Filter by template display name @@ -2307,6 +2432,21 @@ type RegenerateAgentAPIKeyJSONRequestBody = APIKeyRegenerationRequest // UploadCertificateJSONRequestBody defines body for UploadCertificate for application/json ContentType. type UploadCertificateJSONRequestBody = CertificateUploadRequest +// CreateGraphQLAPIJSONRequestBody defines body for CreateGraphQLAPI for application/json ContentType. +type CreateGraphQLAPIJSONRequestBody = GraphQLAPIRequest + +// UpdateGraphQLAPIJSONRequestBody defines body for UpdateGraphQLAPI for application/json ContentType. +type UpdateGraphQLAPIJSONRequestBody = GraphQLAPIRequest + +// CreateGraphQLAPIKeyJSONRequestBody defines body for CreateGraphQLAPIKey for application/json ContentType. +type CreateGraphQLAPIKeyJSONRequestBody = APIKeyCreationRequest + +// UpdateGraphQLAPIKeyJSONRequestBody defines body for UpdateGraphQLAPIKey for application/json ContentType. +type UpdateGraphQLAPIKeyJSONRequestBody = APIKeyUpdateRequest + +// RegenerateGraphQLAPIKeyJSONRequestBody defines body for RegenerateGraphQLAPIKey for application/json ContentType. +type RegenerateGraphQLAPIKeyJSONRequestBody = APIKeyRegenerationRequest + // CreateLLMProviderTemplateJSONRequestBody defines body for CreateLLMProviderTemplate for application/json ContentType. type CreateLLMProviderTemplateJSONRequestBody = LLMProviderTemplateRequest @@ -2964,6 +3104,36 @@ type ServerInterface interface { // Delete a certificate // (DELETE /certificates/{id}) DeleteCertificate(w http.ResponseWriter, r *http.Request, id string) + // List all GraphQLApis + // (GET /graphql-apis) + ListGraphQLAPIs(w http.ResponseWriter, r *http.Request, params ListGraphQLAPIsParams) + // Create a new GraphQLApi + // (POST /graphql-apis) + CreateGraphQLAPI(w http.ResponseWriter, r *http.Request) + // Delete a GraphQLApi + // (DELETE /graphql-apis/{id}) + DeleteGraphQLAPI(w http.ResponseWriter, r *http.Request, id string) + // Get GraphQLApi by id + // (GET /graphql-apis/{id}) + GetGraphQLAPIById(w http.ResponseWriter, r *http.Request, id string) + // Update an existing GraphQLApi + // (PUT /graphql-apis/{id}) + UpdateGraphQLAPI(w http.ResponseWriter, r *http.Request, id string) + // Get the list of API keys for a GraphQL API + // (GET /graphql-apis/{id}/api-keys) + ListGraphQLAPIKeys(w http.ResponseWriter, r *http.Request, id string) + // Create a new API key for a GraphQL API + // (POST /graphql-apis/{id}/api-keys) + CreateGraphQLAPIKey(w http.ResponseWriter, r *http.Request, id string) + // Revoke an API key + // (DELETE /graphql-apis/{id}/api-keys/{apiKeyName}) + RevokeGraphQLAPIKey(w http.ResponseWriter, r *http.Request, id string, apiKeyName string) + // Update an API key with a new regenerated value + // (PUT /graphql-apis/{id}/api-keys/{apiKeyName}) + UpdateGraphQLAPIKey(w http.ResponseWriter, r *http.Request, id string, apiKeyName string) + // Regenerate API key for a GraphQL API + // (POST /graphql-apis/{id}/api-keys/{apiKeyName}/regenerate) + RegenerateGraphQLAPIKey(w http.ResponseWriter, r *http.Request, id string, apiKeyName string) // List all LLM provider templates // (GET /llm-provider-templates) ListLLMProviderTemplates(w http.ResponseWriter, r *http.Request, params ListLLMProviderTemplatesParams) @@ -3583,6 +3753,358 @@ func (siw *ServerInterfaceWrapper) DeleteCertificate(w http.ResponseWriter, r *h handler.ServeHTTP(w, r) } +// ListGraphQLAPIs operation middleware +func (siw *ServerInterfaceWrapper) ListGraphQLAPIs(w http.ResponseWriter, r *http.Request) { + + var err error + + ctx := r.Context() + + ctx = context.WithValue(ctx, BasicAuthScopes, []string{}) + + r = r.WithContext(ctx) + + // Parameter object where we will unmarshal all parameters from the context + var params ListGraphQLAPIsParams + + // ------------- Optional query parameter "displayName" ------------- + + err = runtime.BindQueryParameter("form", true, false, "displayName", r.URL.Query(), ¶ms.DisplayName) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "displayName", Err: err}) + return + } + + // ------------- Optional query parameter "version" ------------- + + err = runtime.BindQueryParameter("form", true, false, "version", r.URL.Query(), ¶ms.Version) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "version", Err: err}) + return + } + + // ------------- Optional query parameter "context" ------------- + + err = runtime.BindQueryParameter("form", true, false, "context", r.URL.Query(), ¶ms.Context) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "context", Err: err}) + return + } + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameter("form", true, false, "status", r.URL.Query(), ¶ms.Status) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "status", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListGraphQLAPIs(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// CreateGraphQLAPI operation middleware +func (siw *ServerInterfaceWrapper) CreateGraphQLAPI(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BasicAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.CreateGraphQLAPI(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// DeleteGraphQLAPI operation middleware +func (siw *ServerInterfaceWrapper) DeleteGraphQLAPI(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BasicAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DeleteGraphQLAPI(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetGraphQLAPIById operation middleware +func (siw *ServerInterfaceWrapper) GetGraphQLAPIById(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BasicAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetGraphQLAPIById(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UpdateGraphQLAPI operation middleware +func (siw *ServerInterfaceWrapper) UpdateGraphQLAPI(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BasicAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpdateGraphQLAPI(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ListGraphQLAPIKeys operation middleware +func (siw *ServerInterfaceWrapper) ListGraphQLAPIKeys(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BasicAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListGraphQLAPIKeys(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// CreateGraphQLAPIKey operation middleware +func (siw *ServerInterfaceWrapper) CreateGraphQLAPIKey(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BasicAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.CreateGraphQLAPIKey(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RevokeGraphQLAPIKey operation middleware +func (siw *ServerInterfaceWrapper) RevokeGraphQLAPIKey(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "apiKeyName" ------------- + var apiKeyName string + + err = runtime.BindStyledParameterWithOptions("simple", "apiKeyName", r.PathValue("apiKeyName"), &apiKeyName, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "apiKeyName", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BasicAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RevokeGraphQLAPIKey(w, r, id, apiKeyName) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UpdateGraphQLAPIKey operation middleware +func (siw *ServerInterfaceWrapper) UpdateGraphQLAPIKey(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "apiKeyName" ------------- + var apiKeyName string + + err = runtime.BindStyledParameterWithOptions("simple", "apiKeyName", r.PathValue("apiKeyName"), &apiKeyName, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "apiKeyName", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BasicAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpdateGraphQLAPIKey(w, r, id, apiKeyName) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RegenerateGraphQLAPIKey operation middleware +func (siw *ServerInterfaceWrapper) RegenerateGraphQLAPIKey(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "apiKeyName" ------------- + var apiKeyName string + + err = runtime.BindStyledParameterWithOptions("simple", "apiKeyName", r.PathValue("apiKeyName"), &apiKeyName, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "apiKeyName", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BasicAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RegenerateGraphQLAPIKey(w, r, id, apiKeyName) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListLLMProviderTemplates operation middleware func (siw *ServerInterfaceWrapper) ListLLMProviderTemplates(w http.ResponseWriter, r *http.Request) { @@ -5533,6 +6055,16 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc("POST "+options.BaseURL+"/certificates", wrapper.UploadCertificate) m.HandleFunc("POST "+options.BaseURL+"/certificates/reload", wrapper.ReloadCertificates) m.HandleFunc("DELETE "+options.BaseURL+"/certificates/{id}", wrapper.DeleteCertificate) + m.HandleFunc("GET "+options.BaseURL+"/graphql-apis", wrapper.ListGraphQLAPIs) + m.HandleFunc("POST "+options.BaseURL+"/graphql-apis", wrapper.CreateGraphQLAPI) + m.HandleFunc("DELETE "+options.BaseURL+"/graphql-apis/{id}", wrapper.DeleteGraphQLAPI) + m.HandleFunc("GET "+options.BaseURL+"/graphql-apis/{id}", wrapper.GetGraphQLAPIById) + m.HandleFunc("PUT "+options.BaseURL+"/graphql-apis/{id}", wrapper.UpdateGraphQLAPI) + m.HandleFunc("GET "+options.BaseURL+"/graphql-apis/{id}/api-keys", wrapper.ListGraphQLAPIKeys) + m.HandleFunc("POST "+options.BaseURL+"/graphql-apis/{id}/api-keys", wrapper.CreateGraphQLAPIKey) + m.HandleFunc("DELETE "+options.BaseURL+"/graphql-apis/{id}/api-keys/{apiKeyName}", wrapper.RevokeGraphQLAPIKey) + m.HandleFunc("PUT "+options.BaseURL+"/graphql-apis/{id}/api-keys/{apiKeyName}", wrapper.UpdateGraphQLAPIKey) + m.HandleFunc("POST "+options.BaseURL+"/graphql-apis/{id}/api-keys/{apiKeyName}/regenerate", wrapper.RegenerateGraphQLAPIKey) m.HandleFunc("GET "+options.BaseURL+"/llm-provider-templates", wrapper.ListLLMProviderTemplates) m.HandleFunc("POST "+options.BaseURL+"/llm-provider-templates", wrapper.CreateLLMProviderTemplate) m.HandleFunc("DELETE "+options.BaseURL+"/llm-provider-templates/{id}", wrapper.DeleteLLMProviderTemplate) @@ -5595,380 +6127,401 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "X/3/AwAgasrCZXsCAA==", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/gateway-controller/pkg/controlplane/client.go b/gateway/gateway-controller/pkg/controlplane/client.go index d16c5864c9..09014b2ac8 100644 --- a/gateway/gateway-controller/pkg/controlplane/client.go +++ b/gateway/gateway-controller/pkg/controlplane/client.go @@ -1057,9 +1057,10 @@ var onPremSupportedAPIKeyKinds = map[string]bool{ } // syncAPIKeysForExistingArtifacts performs a one-time bulk sync of API keys for all -// currently known RestApi, WebSubApi, LlmProvider, and LlmProxy artifacts after the WebSocket connection -// is established. Upserts fetched keys into the DB, reconciles deletions per artifact, -// then reloads the in-memory store and refreshes the xDS snapshot once. +// currently known RestApi, WebSubApi, LlmProvider, LlmProxy, and GraphQLApi artifacts +// after the WebSocket connection is established. Upserts fetched keys into the DB, +// reconciles deletions per artifact, then reloads the in-memory store and refreshes +// the xDS snapshot once. // For on-prem control planes only KindRestApi is synced; other kinds are skipped because // the corresponding backfill endpoints do not exist in carbon-apimgt for now. func (c *Client) syncAPIKeysForExistingArtifacts(gatewayID string) { @@ -1090,7 +1091,8 @@ func (c *Client) syncAPIKeysForExistingArtifacts(gatewayID string) { continue } if cfg.Kind != models.KindLlmProvider && cfg.Kind != models.KindLlmProxy && - cfg.Kind != models.KindRestApi && cfg.Kind != models.KindWebSubApi && cfg.Kind != models.KindWebBrokerApi { + cfg.Kind != models.KindRestApi && cfg.Kind != models.KindWebSubApi && + cfg.Kind != models.KindWebBrokerApi && cfg.Kind != models.KindGraphQLApi { continue } artifactUUIDsByKind[cfg.Kind] = append(artifactUUIDsByKind[cfg.Kind], cfg.UUID) @@ -1109,7 +1111,7 @@ func (c *Client) syncAPIKeysForExistingArtifacts(gatewayID string) { localArtifactIDs[cfg.CPArtifactID] = cfg.UUID } - for _, kind := range []string{models.KindRestApi, models.KindWebSubApi, models.KindWebBrokerApi, models.KindLlmProvider, models.KindLlmProxy} { + for _, kind := range []string{models.KindRestApi, models.KindWebSubApi, models.KindWebBrokerApi, models.KindLlmProvider, models.KindLlmProxy, models.KindGraphQLApi} { // On-prem APIM only exposes backfill endpoints for RestApi keys. if c.isOnPrem() && !onPremSupportedAPIKeyKinds[kind] { c.logger.Debug("Skipping API key bulk sync for kind: not supported by on-prem control plane", @@ -1484,6 +1486,12 @@ func (c *Client) handleMessage(messageType int, message []byte) { c.handleAgentUndeployedEvent(event) case "agent.deleted": c.handleAgentDeletedEvent(event) + case "graphqlapi.deployed": + c.handleGraphQLAPIDeployedEvent(event) + case "graphqlapi.undeployed": + c.handleGraphQLAPIUndeployedEvent(event) + case "graphqlapi.deleted": + c.handleGraphQLAPIDeletedEvent(event) case "websub.deployed": c.dispatchEventGatewayHook(event["type"], func(h ControlPlaneEventGatewayHooks) { h.HandleWebSubAPIDeployed(c, event) }) case "websub.undeployed": @@ -1559,6 +1567,286 @@ func (c *Client) fetchAndDeployAPI(apiID, deploymentID string, deployedAt *time. return result, nil } +// fetchAndDeployGraphQLAPI fetches a GraphQL API definition and deploys it. +// GraphQLApi has no dedicated deployment service — it self-registers into +// APIDeploymentService's generic kindDeployParsers extension point (see +// pkg/utils/graphql_deployment.go's init()), so it is deployed through the +// same generic deploymentService RestApi uses via fetchAndDeployAPI above, +// keyed off the YAML's own "kind: GraphQLApi" field. +func (c *Client) fetchAndDeployGraphQLAPI(apiID, deploymentID string, deployedAt *time.Time, correlationID string) (*utils.APIDeploymentResult, error) { + zipData, err := c.apiUtilsService.FetchGraphQLAPIDefinition(apiID) + if err != nil { + c.logger.Error("Failed to fetch GraphQL API definition", + slog.String("api_id", apiID), + slog.Any("error", err), + ) + return nil, fmt.Errorf("failed to fetch GraphQL API definition: %w", err) + } + + yamlData, err := c.apiUtilsService.ExtractYAMLFromZip(zipData) + if err != nil { + c.logger.Error("Failed to extract YAML from zip", + slog.String("api_id", apiID), + slog.Any("error", err), + ) + return nil, fmt.Errorf("failed to extract YAML from zip: %w", err) + } + + c.syncSecretRefsFromYAML(yamlData, correlationID) + + result, err := c.apiUtilsService.CreateAPIFromYAML(yamlData, c.resolveLocalArtifactID(apiID), deploymentID, deployedAt, correlationID, c.deploymentService) + if err != nil { + c.logger.Error("Failed to create GraphQL API from YAML", + slog.String("api_id", apiID), + slog.Any("error", err), + ) + return nil, fmt.Errorf("failed to create GraphQL API from YAML: %w", err) + } + + return result, nil +} + +// handleGraphQLAPIDeployedEvent handles GraphQL API deployment events +func (c *Client) handleGraphQLAPIDeployedEvent(event map[string]interface{}) { + c.logger.Info("GraphQL API Deployment Event", + slog.Any("payload", event["payload"]), + slog.Any("timestamp", event["timestamp"]), + slog.Any("correlationId", event["correlationId"]), + ) + + eventBytes, err := json.Marshal(event) + if err != nil { + c.logger.Error("Failed to marshal event for parsing", + slog.Any("error", err), + ) + return + } + + var deployedEvent GraphQLAPIDeployedEvent + if err := json.Unmarshal(eventBytes, &deployedEvent); err != nil { + c.logger.Error("Failed to parse GraphQL API deployment event", + slog.Any("error", err), + ) + return + } + + apiID := deployedEvent.Payload.ApiId + if apiID == "" { + c.logger.Error("GraphQL API ID is empty in deployment event") + return + } + + c.logger.Info("Processing GraphQL API deployment", + slog.String("api_id", apiID), + slog.String("deployment_id", deployedEvent.Payload.DeploymentID), + slog.String("correlation_id", deployedEvent.CorrelationID), + ) + + performedAt := deployedEvent.Payload.PerformedAt.Truncate(time.Millisecond) + if performedAt.IsZero() { + performedAt = time.Now().Truncate(time.Millisecond) + } + result, err := c.fetchAndDeployGraphQLAPI(apiID, deployedEvent.Payload.DeploymentID, &performedAt, deployedEvent.CorrelationID) + if err != nil { + c.sendDeploymentAck(deployedEvent.Payload.DeploymentID, apiID, "graphqlapi", "deploy", "failed", + deployedEvent.Payload.PerformedAt, "GATEWAY_PROCESSING_ERROR") + return + } + + if result.IsStale { + // Stale event — DB was not modified. Do not send ack; in HA mode the + // controller that actually processed the event will ack. If all controllers + // see stale, platform-API will timeout and handle accordingly. + c.logger.Debug("Skipped stale GraphQL API deploy event (newer version exists in DB)", + slog.String("api_id", apiID), + slog.String("deployment_id", deployedEvent.Payload.DeploymentID), + ) + return + } + + c.sendDeploymentAck(deployedEvent.Payload.DeploymentID, apiID, "graphqlapi", "deploy", "success", + deployedEvent.Payload.PerformedAt, "") + + c.logger.Info("Successfully processed GraphQL API deployment event", + slog.String("api_id", apiID), + slog.String("correlation_id", deployedEvent.CorrelationID), + ) +} + +// handleGraphQLAPIUndeployedEvent handles GraphQL API undeployment events +func (c *Client) handleGraphQLAPIUndeployedEvent(event map[string]interface{}) { + c.logger.Info("GraphQL API Undeployment Event", + slog.Any("payload", event["payload"]), + slog.Any("timestamp", event["timestamp"]), + slog.Any("correlationId", event["correlationId"]), + ) + + eventBytes, err := json.Marshal(event) + if err != nil { + c.logger.Error("Failed to marshal event for parsing", + slog.Any("error", err), + ) + return + } + + var undeployedEvent GraphQLAPIUndeployedEvent + if err := json.Unmarshal(eventBytes, &undeployedEvent); err != nil { + c.logger.Error("Failed to parse GraphQL API undeployment event", + slog.Any("error", err), + ) + return + } + + apiID := undeployedEvent.Payload.ApiId + if apiID == "" { + c.logger.Error("GraphQL API ID is empty in undeployment event") + return + } + + c.logger.Info("Processing GraphQL API undeployment", + slog.String("api_id", apiID), + slog.String("correlation_id", undeployedEvent.CorrelationID), + ) + + apiConfig, err := c.findAPIConfig(apiID) + if err != nil { + if storage.IsNotFoundError(err) { + c.logger.Warn("GraphQL API configuration not found for undeployment", + slog.String("api_id", apiID), + ) + // Still send success ack - the API is already undeployed + c.sendDeploymentAck(undeployedEvent.Payload.DeploymentID, apiID, "graphqlapi", "undeploy", "success", + undeployedEvent.Payload.PerformedAt, "") + return + } + c.logger.Error("Failed to fetch GraphQL API configuration for undeployment", + slog.String("api_id", apiID), + slog.String("correlation_id", undeployedEvent.CorrelationID), + slog.Any("error", err), + ) + c.sendDeploymentAck(undeployedEvent.Payload.DeploymentID, apiID, "graphqlapi", "undeploy", "failed", + undeployedEvent.Payload.PerformedAt, "GATEWAY_PROCESSING_ERROR") + return + } + + // Only process undeploy if the event's DeploymentID matches the current one. + // This prevents stale undeploy events from affecting a newer deployment. + if apiConfig.DeploymentID != "" && undeployedEvent.Payload.DeploymentID != "" && + apiConfig.DeploymentID != undeployedEvent.Payload.DeploymentID { + c.logger.Warn("Ignoring stale GraphQL API undeploy event: deployment ID mismatch", + slog.String("api_id", apiID), + slog.String("event_deployment_id", undeployedEvent.Payload.DeploymentID), + slog.String("current_deployment_id", apiConfig.DeploymentID), + ) + c.sendDeploymentAck(undeployedEvent.Payload.DeploymentID, apiID, "graphqlapi", "undeploy", "failed", + undeployedEvent.Payload.PerformedAt, "DEPLOYMENT_ID_MISMATCH") + return + } + + graphqlUndeployPerformedAt := undeployedEvent.Payload.PerformedAt.Truncate(time.Millisecond) + if graphqlUndeployPerformedAt.IsZero() { + graphqlUndeployPerformedAt = time.Now().Truncate(time.Millisecond) + } + apiConfig.DesiredState = models.StateUndeployed + apiConfig.DeploymentID = undeployedEvent.Payload.DeploymentID + apiConfig.DeployedAt = &graphqlUndeployPerformedAt + apiConfig.UpdatedAt = time.Now() + + // Timestamp-guarded upsert: only writes if deployed_at is newer than what's in DB. + // This prevents stale undeploy events from overwriting newer state. + affected, err := c.db.UpsertConfig(apiConfig) + if err != nil { + c.logger.Error("Failed to upsert config for undeployment", + slog.String("api_id", apiID), + slog.Any("error", err), + ) + c.sendDeploymentAck(undeployedEvent.Payload.DeploymentID, apiID, "graphqlapi", "undeploy", "failed", + undeployedEvent.Payload.PerformedAt, "GATEWAY_PROCESSING_ERROR") + return + } + if !affected { + c.logger.Debug("Skipped stale GraphQL API undeploy event (newer version exists in DB)", + slog.String("api_id", apiID), + slog.String("deployment_id", undeployedEvent.Payload.DeploymentID), + ) + return + } + + evt := eventhub.Event{ + EventType: eventhub.EventTypeAPI, + Action: "UPDATE", + EntityID: apiID, + EventID: undeployedEvent.CorrelationID, + } + if err := c.eventHub.PublishEvent(c.gatewayID, evt); err != nil { + c.logger.Error("Failed to publish GraphQL API undeployment event", slog.Any("error", err)) + } + + c.sendDeploymentAck(undeployedEvent.Payload.DeploymentID, apiID, "graphqlapi", "undeploy", "success", + undeployedEvent.Payload.PerformedAt, "") + + c.logger.Info("Successfully processed GraphQL API undeployment event", + slog.String("api_id", apiID), + slog.String("correlation_id", undeployedEvent.CorrelationID), + ) +} + +// handleGraphQLAPIDeletedEvent handles GraphQL API deletion events +func (c *Client) handleGraphQLAPIDeletedEvent(event map[string]interface{}) { + c.logger.Info("GraphQL API Deletion Event", + slog.Any("payload", event["payload"]), + slog.Any("timestamp", event["timestamp"]), + slog.Any("correlationId", event["correlationId"]), + ) + + eventBytes, err := json.Marshal(event) + if err != nil { + c.logger.Error("Failed to marshal event for parsing", + slog.Any("error", err), + ) + return + } + + var deletedEvent GraphQLAPIDeletedEvent + if err := json.Unmarshal(eventBytes, &deletedEvent); err != nil { + c.logger.Error("Failed to parse GraphQL API deletion event", + slog.Any("error", err), + ) + return + } + + apiID := deletedEvent.Payload.ApiId + if apiID == "" { + c.logger.Error("GraphQL API ID is empty in deletion event") + return + } + + c.logger.Info("Processing GraphQL API deletion", + slog.String("api_id", apiID), + slog.String("correlation_id", deletedEvent.CorrelationID), + ) + + apiConfig, err := c.findAPIConfig(apiID) + if err != nil { + if storage.IsNotFoundError(err) { + // Config not found - proceed with orphan cleanup + c.cleanupOrphanedResources(apiID, deletedEvent.CorrelationID) + return + } + // Real storage error (DB failure, etc.) - log and abort + // Do NOT proceed with orphan cleanup as the config might actually exist + c.logger.Error("Failed to fetch GraphQL API configuration for deletion, aborting", + slog.String("api_id", apiID), + slog.String("correlation_id", deletedEvent.CorrelationID), + slog.Any("error", err), + ) + return + } + + // Config found - perform full deletion + c.performFullAPIDeletion(apiID, apiConfig, deletedEvent.CorrelationID) +} + // updatePolicyForDeployment updates policy engine for API deployment func (c *Client) updatePolicyForDeployment(apiID, correlationID string, result *utils.APIDeploymentResult) error { if c.policyManager == nil { diff --git a/gateway/gateway-controller/pkg/controlplane/events.go b/gateway/gateway-controller/pkg/controlplane/events.go index 908c83c5a4..1a48d05f2b 100644 --- a/gateway/gateway-controller/pkg/controlplane/events.go +++ b/gateway/gateway-controller/pkg/controlplane/events.go @@ -328,6 +328,49 @@ type AgentDeletedEvent struct { CorrelationID string `json:"correlationId"` } +// GraphQLAPIDeployedEventPayload represents the payload of a GraphQL API deployment event +type GraphQLAPIDeployedEventPayload struct { + ApiId string `json:"apiId"` + DeploymentID string `json:"deploymentId"` + PerformedAt time.Time `json:"performedAt"` +} + +// GraphQLAPIDeployedEvent represents the complete GraphQL API deployment event +type GraphQLAPIDeployedEvent struct { + Type string `json:"type"` + Payload GraphQLAPIDeployedEventPayload `json:"payload"` + Timestamp string `json:"timestamp"` + CorrelationID string `json:"correlationId"` +} + +// GraphQLAPIUndeployedEventPayload represents the payload of a GraphQL API undeployment event +type GraphQLAPIUndeployedEventPayload struct { + ApiId string `json:"apiId"` + DeploymentID string `json:"deploymentId"` + PerformedAt time.Time `json:"performedAt"` +} + +// GraphQLAPIUndeployedEvent represents the complete GraphQL API undeployment event +type GraphQLAPIUndeployedEvent struct { + Type string `json:"type"` + Payload GraphQLAPIUndeployedEventPayload `json:"payload"` + Timestamp string `json:"timestamp"` + CorrelationID string `json:"correlationId"` +} + +// GraphQLAPIDeletedEventPayload represents the payload of a GraphQL API deletion event +type GraphQLAPIDeletedEventPayload struct { + ApiId string `json:"apiId"` +} + +// GraphQLAPIDeletedEvent represents the complete GraphQL API deletion event +type GraphQLAPIDeletedEvent struct { + Type string `json:"type"` + Payload GraphQLAPIDeletedEventPayload `json:"payload"` + Timestamp string `json:"timestamp"` + CorrelationID string `json:"correlationId"` +} + // Note: WebSub/WebBroker deploy/undeploy/delete event payload types // (WebSubAPIDeployedEvent, WebBrokerAPIDeployedEvent, etc.) are NOT defined // here. They are event-gateway-specific and owned by the diff --git a/gateway/gateway-controller/pkg/controlplane/sync.go b/gateway/gateway-controller/pkg/controlplane/sync.go index 9b83cc137d..f76f49af28 100644 --- a/gateway/gateway-controller/pkg/controlplane/sync.go +++ b/gateway/gateway-controller/pkg/controlplane/sync.go @@ -224,8 +224,8 @@ func computeSyncDiff(remote []models.ControlPlaneDeployment, local []*models.Sto // processSyncFetches fetches deployment artifacts in chunked batches, ordered by // dependency: LLM Providers first, then LLM Proxies, then REST APIs. func (c *Client) processSyncFetches(deployments []models.ControlPlaneDeployment, gatewayID string) { - // Sort by dependency order: providers → proxies → REST APIs/MCP proxies/agents - var providers, proxies, restAPIs, mcpProxies, agents []models.ControlPlaneDeployment + // Sort by dependency order: providers → proxies → REST APIs/MCP proxies/agents/GraphQL APIs + var providers, proxies, restAPIs, mcpProxies, agents, graphqlAPIs []models.ControlPlaneDeployment for _, dep := range deployments { switch dep.Kind { case models.KindLlmProvider: @@ -238,6 +238,8 @@ func (c *Client) processSyncFetches(deployments []models.ControlPlaneDeployment, mcpProxies = append(mcpProxies, dep) case models.KindAgent: agents = append(agents, dep) + case models.KindGraphQLApi: + graphqlAPIs = append(graphqlAPIs, dep) } } @@ -248,6 +250,7 @@ func (c *Client) processSyncFetches(deployments []models.ControlPlaneDeployment, ordered = append(ordered, restAPIs...) ordered = append(ordered, mcpProxies...) ordered = append(ordered, agents...) + ordered = append(ordered, graphqlAPIs...) batchSize := c.config.SyncBatchSize if batchSize <= 0 { @@ -358,6 +361,14 @@ func (c *Client) processSyncFetchBatch(batch []models.ControlPlaneDeployment, ga // pkg/utils. Same call shape either way. _, err = c.agentService.CreateFromYAML(yamlData, dep.ArtifactID, dep.DeploymentID, &deployedAt, correlationID, c.logger) + case models.KindGraphQLApi: + // GraphQLApi has no dedicated deployment service — it self-registers into + // APIDeploymentService's generic kindDeployParsers extension point (see + // pkg/utils/graphql_deployment.go's init()), so it is deployed through the + // same generic deploymentService RestApi uses, keyed off the YAML's own + // "kind: GraphQLApi" field rather than a per-kind service reference. + _, err = c.apiUtilsService.CreateAPIFromYAML(yamlData, dep.ArtifactID, + dep.DeploymentID, &deployedAt, correlationID, c.deploymentService) } if err != nil { @@ -468,7 +479,7 @@ func (c *Client) processSyncDeletions(artifactIDs []string, gatewayID string) { kind string } - var restAPIs, proxies, providers, mcpProxies, agents, unknown []deletionEntry + var restAPIs, proxies, providers, mcpProxies, agents, graphqlAPIs, unknown []deletionEntry for _, id := range artifactIDs { cfg, err := c.db.GetConfig(id) @@ -495,14 +506,17 @@ func (c *Client) processSyncDeletions(artifactIDs []string, gatewayID string) { mcpProxies = append(mcpProxies, entry) case models.KindAgent: agents = append(agents, entry) + case models.KindGraphQLApi: + graphqlAPIs = append(graphqlAPIs, entry) } } - // Reverse dependency order: agents/MCP proxies/REST APIs → proxies → providers + // Reverse dependency order: agents/MCP proxies/REST APIs/GraphQL APIs → proxies → providers ordered := make([]deletionEntry, 0, len(artifactIDs)) ordered = append(ordered, agents...) ordered = append(ordered, mcpProxies...) ordered = append(ordered, restAPIs...) + ordered = append(ordered, graphqlAPIs...) ordered = append(ordered, unknown...) ordered = append(ordered, proxies...) ordered = append(ordered, providers...) @@ -562,8 +576,12 @@ func (c *Client) processSyncDeletion(artifactID, kind, gatewayID string) { } } - case models.KindRestApi: - // REST API / WebSub — follow the performFullAPIDeletion pattern + case models.KindRestApi, models.KindGraphQLApi: + // REST API / WebSub / GraphQL API — follow the performFullAPIDeletion pattern. + // GraphQLApi is stored as a generic StoredConfig artifact (no dedicated + // deployment/deletion service — see the KindGraphQLApi case in + // processSyncFetchBatch), so the same generic deletion path REST/WebSub use + // applies unmodified. apiConfig, err := c.findAPIConfig(artifactID) if err != nil { if storage.IsNotFoundError(err) { diff --git a/gateway/gateway-controller/pkg/eventlistener/agent_processor_test.go b/gateway/gateway-controller/pkg/eventlistener/agent_processor_test.go index b6019ec7dd..1f599dd2d0 100644 --- a/gateway/gateway-controller/pkg/eventlistener/agent_processor_test.go +++ b/gateway/gateway-controller/pkg/eventlistener/agent_processor_test.go @@ -223,6 +223,7 @@ func newAgentReplica(t *testing.T, db storage.Storage) *agentReplica { registry := transform.NewRegistry( nil, nil, transform.NewAgentTransformer(routerConfig, systemConfig, policyDefinitions), + nil, ) policySnapshotManager := policyxds.NewSnapshotManager(logger) diff --git a/gateway/gateway-controller/pkg/models/data_version.go b/gateway/gateway-controller/pkg/models/data_version.go index 8f37ee1512..a67dcb09c4 100644 --- a/gateway/gateway-controller/pkg/models/data_version.go +++ b/gateway/gateway-controller/pkg/models/data_version.go @@ -42,6 +42,7 @@ var dataMinorVersions = map[ArtifactKind]int{ KindWebSubApi: 0, KindWebBrokerApi: 0, KindMcp: 0, + KindGraphQLApi: 0, KindLlmProxy: 0, KindLlmProvider: 0, KindAgent: 0, diff --git a/gateway/gateway-controller/pkg/models/data_version_test.go b/gateway/gateway-controller/pkg/models/data_version_test.go index fd0ca58d6a..2113b2e1a7 100644 --- a/gateway/gateway-controller/pkg/models/data_version_test.go +++ b/gateway/gateway-controller/pkg/models/data_version_test.go @@ -69,6 +69,7 @@ func TestDataMinorVersionsExhaustive(t *testing.T) { KindWebSubApi, KindWebBrokerApi, KindMcp, + KindGraphQLApi, KindLlmProxy, KindLlmProvider, KindAgent, diff --git a/gateway/gateway-controller/pkg/models/stored_config.go b/gateway/gateway-controller/pkg/models/stored_config.go index bf1ffb688b..71eda28b3b 100644 --- a/gateway/gateway-controller/pkg/models/stored_config.go +++ b/gateway/gateway-controller/pkg/models/stored_config.go @@ -40,6 +40,7 @@ const ( KindLlmProvider ArtifactKind = "LlmProvider" KindLlmProviderTemplate ArtifactKind = "LlmProviderTemplate" KindAgent ArtifactKind = "Agent" + KindGraphQLApi ArtifactKind = "GraphQLApi" ) // DesiredState represents the intended deployment state of an API configuration. @@ -194,6 +195,8 @@ func apiVersionOf(cfg any) string { return string(sc.ApiVersion) case api.AgentConfiguration: return string(sc.ApiVersion) + case api.GraphQLAPI: + return string(sc.ApiVersion) } return "" } @@ -223,12 +226,17 @@ func (c *StoredConfig) GetContext() (string, error) { return strings.ReplaceAll(*sc.Spec.Context, "$version", c.Version), nil } return "", nil + case api.GraphQLAPI: + return strings.ReplaceAll(sc.Spec.Context, "$version", c.Version), nil } return "", fmt.Errorf("unsupported source configuration type: %T", c.SourceConfiguration) } func (c *StoredConfig) GetPolicies() *[]api.Policy { - if sc, ok := c.Configuration.(api.RestAPI); ok { + switch sc := c.Configuration.(type) { + case api.RestAPI: + return sc.Spec.Policies + case api.GraphQLAPI: return sc.Spec.Policies } // Agent is deliberately absent: an Agent has no single spec-level policy @@ -247,6 +255,8 @@ func (c *StoredConfig) GetMetadata() *api.Metadata { return &cfg.Metadata case api.AgentConfiguration: return &cfg.Metadata + case api.GraphQLAPI: + return &cfg.Metadata } return nil } @@ -258,6 +268,8 @@ func (c *StoredConfig) GetLabels() *map[string]string { return cfg.Metadata.Labels case api.AgentConfiguration: return cfg.Metadata.Labels + case api.GraphQLAPI: + return cfg.Metadata.Labels } return nil } @@ -269,6 +281,8 @@ func (c *StoredConfig) GetAnnotations() *map[string]string { return cfg.Metadata.Annotations case api.AgentConfiguration: return cfg.Metadata.Annotations + case api.GraphQLAPI: + return cfg.Metadata.Annotations } return nil } diff --git a/gateway/gateway-controller/pkg/storage/gateway-controller-db.postgres.sql b/gateway/gateway-controller/pkg/storage/gateway-controller-db.postgres.sql index ded871c419..7f50419b10 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.postgres.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.postgres.sql @@ -87,6 +87,18 @@ CREATE TABLE IF NOT EXISTS agents ( FOREIGN KEY(gateway_id, uuid) REFERENCES artifacts(gateway_id, uuid) ON DELETE CASCADE ); +-- GraphQL is not a separate product the way event-gateway is (see the websub_apis/ +-- webbroker_apis note above), so graphql_apis is defined directly here as a +-- one-column-identical clone of rest_apis, instead of being owned by a separate +-- supplemental-DDL module. +CREATE TABLE IF NOT EXISTS graphql_apis ( + uuid TEXT NOT NULL, + gateway_id TEXT NOT NULL, + configuration TEXT NOT NULL, + PRIMARY KEY (gateway_id, uuid), + FOREIGN KEY(gateway_id, uuid) REFERENCES artifacts(gateway_id, uuid) ON DELETE CASCADE +); + -- Table for custom TLS certificates CREATE TABLE IF NOT EXISTS certificates ( uuid TEXT NOT NULL, diff --git a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql index 6b426cb6bf..7c337765e2 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql @@ -91,6 +91,18 @@ CREATE TABLE IF NOT EXISTS agents ( FOREIGN KEY(gateway_id, uuid) REFERENCES artifacts(gateway_id, uuid) ON DELETE CASCADE ); +-- GraphQL is not a separate product the way event-gateway is (see the websub_apis/ +-- webbroker_apis note above), so graphql_apis is defined directly here as a +-- one-column-identical clone of rest_apis, instead of being owned by a separate +-- supplemental-DDL module. +CREATE TABLE IF NOT EXISTS graphql_apis ( + uuid TEXT NOT NULL, + gateway_id TEXT NOT NULL, + configuration TEXT NOT NULL, + PRIMARY KEY (gateway_id, uuid), + FOREIGN KEY(gateway_id, uuid) REFERENCES artifacts(gateway_id, uuid) ON DELETE CASCADE +); + -- Note: Policy definitions are no longer stored in the database. -- They are loaded from files at controller startup (see policies/ directory). -- The policy_definitions table has been removed as of schema version 3. diff --git a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql index 39ab21f2fb..a4f9c01ea6 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql @@ -120,6 +120,19 @@ CREATE TABLE dbo.agents ( FOREIGN KEY(gateway_id, uuid) REFERENCES dbo.artifacts(gateway_id, uuid) ON DELETE CASCADE ); +-- GraphQL is not a separate product the way event-gateway is (see the websub_apis/ +-- webbroker_apis note above), so graphql_apis is defined directly here as a +-- one-column-identical clone of rest_apis, instead of being owned by a separate +-- supplemental-DDL module. +IF OBJECT_ID(N'dbo.graphql_apis', N'U') IS NULL +CREATE TABLE dbo.graphql_apis ( + uuid NVARCHAR(64) NOT NULL, + gateway_id NVARCHAR(64) NOT NULL, + configuration NVARCHAR(MAX) NOT NULL, + PRIMARY KEY (gateway_id, uuid), + FOREIGN KEY(gateway_id, uuid) REFERENCES dbo.artifacts(gateway_id, uuid) ON DELETE CASCADE +); + -- Table for custom TLS certificates IF OBJECT_ID(N'dbo.certificates', N'U') IS NULL CREATE TABLE dbo.certificates ( diff --git a/gateway/gateway-controller/pkg/storage/interface.go b/gateway/gateway-controller/pkg/storage/interface.go index ac0e90f5f2..1cfdea245a 100644 --- a/gateway/gateway-controller/pkg/storage/interface.go +++ b/gateway/gateway-controller/pkg/storage/interface.go @@ -281,6 +281,9 @@ type Storage interface { // ListAPIKeysForArtifactsNotIn returns the minimal key info (uuid + artifact_uuid) for // keys whose artifact_uuid is in artifactUUIDs but whose own UUID is not in keyUUIDs. // Used to collect identifiers before deletion so callers can publish EventHub events. + // Only considers source='external' (control-plane-issued) keys — a source='local' key + // was never reported to the control plane, so its absence from keyUUIDs never makes it + // stale. ListAPIKeysForArtifactsNotIn(artifactUUIDs []string, keyUUIDs []string) ([]*models.APIKey, error) // DeleteAPIKeysByUUIDs removes API keys by their UUIDs. Used after ListAPIKeysForArtifactsNotIn diff --git a/gateway/gateway-controller/pkg/storage/sql_store.go b/gateway/gateway-controller/pkg/storage/sql_store.go index 19c2595116..4b55f399a4 100644 --- a/gateway/gateway-controller/pkg/storage/sql_store.go +++ b/gateway/gateway-controller/pkg/storage/sql_store.go @@ -289,6 +289,8 @@ func kindToResourceTable(kind string) (string, error) { return "mcp_proxies", nil case "Agent": return agentsResourceTable, nil + case "GraphQLApi": + return "graphql_apis", nil default: if table, ok := extraResourceTables[kind]; ok { return table, nil @@ -308,7 +310,7 @@ var extraResourceTables = map[string]string{} // builtinResourceTables lists the per-kind tables core defines natively. // GetAllConfigs unions these with every table in extraResourceTables so // cross-kind listing also covers kinds registered by an external module. -var builtinResourceTables = []string{"rest_apis", "llm_providers", "llm_proxies", "mcp_proxies", agentsResourceTable} +var builtinResourceTables = []string{"rest_apis", "llm_providers", "llm_proxies", "mcp_proxies", agentsResourceTable, "graphql_apis"} // agentsResourceTable holds Agent artifacts. Like llm_proxies, it carries // columns beyond (uuid, gateway_id, configuration) — here the signed Agent Card @@ -391,6 +393,16 @@ func unmarshalSourceConfig(cfg *models.StoredConfig, jsonData string) error { } cfg.SourceConfiguration = config cfg.Configuration = config + case "GraphQLApi": + // GraphQLApi rows can populate Configuration directly, same as RestApi: the + // stored payload is already the deployable shape (see graphql.go's Transform, + // which type-asserts cfg.Configuration.(api.GraphQLAPI) directly). + var config api.GraphQLAPI + if err := json.Unmarshal([]byte(jsonData), &config); err != nil { + return fmt.Errorf("failed to unmarshal configuration: %w", err) + } + cfg.SourceConfiguration = config + cfg.Configuration = config default: if fn, ok := kindUnmarshalers[cfg.Kind]; ok { return fn(cfg, jsonData) @@ -3637,6 +3649,13 @@ func (s *sqlStore) SecretExists(handle string) (bool, error) { // ListAPIKeysForArtifactsNotIn returns uuid + artifact_uuid for keys that would be removed // by DeleteAPIKeysForArtifactsNotIn. Call this before the delete to collect identifiers // needed for publishing EventHub events. +// +// Only source='external' (control-plane-issued) keys are considered: this powers the CP +// bulk-sync reconciliation, whose whole premise is "delete whatever the control plane no +// longer reports for this artifact." A source='local' key was generated on the gateway +// itself and was never reported to (or known by) the control plane in the first place, so +// its absence from a CP fetch is expected, not a sign it was revoked — treating it as stale +// deleted every locally-generated key on the very next reconnect/restart. func (s *sqlStore) ListAPIKeysForArtifactsNotIn(artifactUUIDs []string, keyUUIDs []string) ([]*models.APIKey, error) { if len(artifactUUIDs) == 0 { return nil, nil @@ -3651,7 +3670,7 @@ func (s *sqlStore) ListAPIKeysForArtifactsNotIn(artifactUUIDs []string, keyUUIDs var query string if len(keyUUIDs) == 0 { query = fmt.Sprintf( - `SELECT uuid, artifact_uuid, name FROM api_keys WHERE gateway_id = ? AND artifact_uuid IN (%s)`, + `SELECT uuid, artifact_uuid, name FROM api_keys WHERE gateway_id = ? AND artifact_uuid IN (%s) AND source = 'external'`, strings.Join(artifactPlaceholders, ","), ) } else { @@ -3661,7 +3680,7 @@ func (s *sqlStore) ListAPIKeysForArtifactsNotIn(artifactUUIDs []string, keyUUIDs args = append(args, id) } query = fmt.Sprintf( - `SELECT uuid, artifact_uuid, name FROM api_keys WHERE gateway_id = ? AND artifact_uuid IN (%s) AND uuid NOT IN (%s)`, + `SELECT uuid, artifact_uuid, name FROM api_keys WHERE gateway_id = ? AND artifact_uuid IN (%s) AND uuid NOT IN (%s) AND source = 'external'`, strings.Join(artifactPlaceholders, ","), strings.Join(keyPlaceholders, ","), ) diff --git a/gateway/gateway-controller/pkg/storage/sqlite_test.go b/gateway/gateway-controller/pkg/storage/sqlite_test.go index 2073dfa5d3..10b16cf35c 100644 --- a/gateway/gateway-controller/pkg/storage/sqlite_test.go +++ b/gateway/gateway-controller/pkg/storage/sqlite_test.go @@ -87,6 +87,7 @@ func TestSQLiteStorage_SchemaInitialization(t *testing.T) { "llm_providers", "llm_proxies", "mcp_proxies", + "graphql_apis", "certificates", "llm_provider_templates", "agents", @@ -842,6 +843,37 @@ func TestSQLiteStorage_GetAPIKeysByAPI_Success(t *testing.T) { assert.Assert(t, keyIDs["0000-key2-0000-000000000000"]) } +// TestSQLiteStorage_ListAPIKeysForArtifactsNotIn_ExcludesLocalKeys guards against +// regressing the CP bulk-sync reconciliation into treating every locally-generated key as +// stale. A source="local" key was generated on the gateway itself and was never reported +// to the control plane, so its absence from a CP fetch (keyUUIDs) must never make it a +// deletion candidate — only source="external" keys the control plane once knew about and +// has since stopped reporting are genuinely stale. +func TestSQLiteStorage_ListAPIKeysForArtifactsNotIn_ExcludesLocalKeys(t *testing.T) { + storage := setupTestStorage(t) + defer storage.db.Close() + + config := createTestStoredConfig() + err := storage.SaveConfig(config) + assert.NilError(t, err) + + localKey := createTestAPIKey() + localKey.ArtifactUUID = config.UUID + localKey.Source = "local" + assert.NilError(t, storage.SaveAPIKey(localKey)) + + externalKey := createTestAPIKey() + externalKey.ArtifactUUID = config.UUID + externalKey.Source = "external" + assert.NilError(t, storage.SaveAPIKey(externalKey)) + + // Simulate a CP bulk-sync round that reported zero keys for this artifact's kind. + stale, err := storage.ListAPIKeysForArtifactsNotIn([]string{config.UUID}, []string{}) + assert.NilError(t, err) + assert.Equal(t, len(stale), 1, "only the control-plane-issued key should be reported stale") + assert.Equal(t, stale[0].UUID, externalKey.UUID) +} + func TestLoadAPIKeysFromDatabase_Success(t *testing.T) { storage := setupTestStorage(t) defer storage.db.Close() diff --git a/gateway/gateway-controller/pkg/transform/agent_routing_test.go b/gateway/gateway-controller/pkg/transform/agent_routing_test.go index 9892a3033b..6e4d28384a 100644 --- a/gateway/gateway-controller/pkg/transform/agent_routing_test.go +++ b/gateway/gateway-controller/pkg/transform/agent_routing_test.go @@ -141,6 +141,7 @@ func agentEnvoyRoutes(t *testing.T, stored *models.StoredConfig) []*route.Route transform.NewRestAPITransformer(routerCfg, systemCfg, map[string]models.PolicyDefinition{}), nil, transform.NewAgentTransformer(routerCfg, systemCfg, routingTestPolicyDefinitions()), + nil, ) translator.SetTransformers(map[string]models.ConfigTransformer{models.KindAgent: registry}) diff --git a/gateway/gateway-controller/pkg/transform/agent_test.go b/gateway/gateway-controller/pkg/transform/agent_test.go index 1f4b74da69..8d070342ff 100644 --- a/gateway/gateway-controller/pkg/transform/agent_test.go +++ b/gateway/gateway-controller/pkg/transform/agent_test.go @@ -1703,6 +1703,7 @@ func TestRegistryKindsMatchDispatch(t *testing.T) { NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}), nil, agentTransformer(), + NewGraphQLAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}), ) for _, kind := range Kinds() { @@ -1722,6 +1723,7 @@ func TestAgentRegistryDispatch(t *testing.T) { NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}), nil, agentTransformer(), + nil, ) viaRegistry, err := registry.Transform(testAgent()) diff --git a/gateway/gateway-controller/pkg/transform/graphql.go b/gateway/gateway-controller/pkg/transform/graphql.go new file mode 100644 index 0000000000..6d5414c11f --- /dev/null +++ b/gateway/gateway-controller/pkg/transform/graphql.go @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package transform + +import ( + "fmt" + "log/slog" + "strings" + + versionutil "github.com/wso2/api-platform/common/version" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/xds" + policyv1alpha "github.com/wso2/api-platform/sdk/core/policy/v1alpha2" + policyenginev1 "github.com/wso2/api-platform/sdk/core/policyengine" +) + +// GraphQLAPITransformer transforms a StoredConfig (GraphQLApi kind) into a +// RuntimeDeployConfig. Unlike RestAPITransformer, it never loops over operations: a +// GraphQL API exposes exactly one logical endpoint (the "operation" — query/mutation +// name — is identified by the POST body, not the URL), so Transform builds exactly one +// models.Route per configured upstream slot (main, and sandbox if present), not one per +// operation. +type GraphQLAPITransformer struct { + routerConfig *config.RouterConfig + systemConfig *config.Config + policyDefinitions map[string]models.PolicyDefinition + latestVersions map[string]string // pre-computed policyName -> latest full semver +} + +// NewGraphQLAPITransformer creates a new GraphQLAPITransformer. +func NewGraphQLAPITransformer( + routerConfig *config.RouterConfig, + systemConfig *config.Config, + policyDefinitions map[string]models.PolicyDefinition, +) *GraphQLAPITransformer { + return &GraphQLAPITransformer{ + routerConfig: routerConfig, + systemConfig: systemConfig, + policyDefinitions: policyDefinitions, + latestVersions: config.BuildLatestVersionIndex(policyDefinitions), + } +} + +// Transform converts a StoredConfig with GraphQLApi configuration into a +// RuntimeDeployConfig containing exactly one route per active upstream slot. +func (t *GraphQLAPITransformer) Transform(cfg *models.StoredConfig) (*models.RuntimeDeployConfig, error) { + graphqlCfg, ok := cfg.Configuration.(api.GraphQLAPI) + if !ok { + return nil, fmt.Errorf("configuration is not a GraphQLAPI") + } + apiData := graphqlCfg.Spec + + projectID := extractProjectID(cfg) + + rdc := &models.RuntimeDeployConfig{ + Metadata: models.Metadata{ + UUID: cfg.UUID, + Kind: cfg.Kind, + Handle: cfg.Handle, + Version: apiData.Version, + DisplayName: apiData.DisplayName, + ProjectID: projectID, + }, + Context: strings.ReplaceAll(apiData.Context, "$version", apiData.Version), + PolicyChainResolver: "route-key", + Routes: make(map[string]*models.Route), + PolicyChains: make(map[string]*models.PolicyChain), + UpstreamClusters: make(map[string]*models.UpstreamCluster), + SensitiveValues: cfg.SensitiveValues, + } + + // Collect and resolve the API-level policy chain once — a GraphQLApi has no + // operation-level policies (there are no operations), so the API-level chain IS + // the route's whole chain (plus injected system policies). + apiPolicies := t.collectAPIPolicies(apiData.Policies) + chain := t.buildPolicyChain(apiPolicies) + injected := utils.InjectSystemPolicies(chain, t.systemConfig, nil) + + // fullPath has no operation-path suffix: a GraphQLApi's whole route match is the + // resolved context (ConstructFullPath(context, version, "") == context+version, + // since appending "" is a no-op). + fullPath := xds.ConstructFullPath(apiData.Context, apiData.Version, "") + mainVhost := t.routerConfig.VHosts.Main.Default + + // Build main upstream cluster and its single route. The route KEY must follow the + // "METHOD|PATH|VHOST" convention (xds.GenerateRouteName) — translator.go's + // TranslateConfigs groups Envoy routes into virtual hosts by splitting the route's + // Name (which is set to this map key) on "|" and reading index 2 as the vhost; an + // ad-hoc key would silently vanish from every virtual host. + mainUpstream, err := addUpstreamCluster(rdc, "main", &apiData.Upstream.Main, nil) + if err != nil { + return nil, fmt.Errorf("failed to resolve main upstream: %w", err) + } + mainUpstreamInfo := mainUpstream.UpstreamInfo() + + mainAutoHostRewrite := true + if apiData.Upstream.Main.HostRewrite != nil && *apiData.Upstream.Main.HostRewrite == api.UpstreamHostRewriteManual { + mainAutoHostRewrite = false + } + + mainRouteKey := xds.GenerateRouteName("POST", apiData.Context, apiData.Version, "", mainVhost) + rdc.Routes[mainRouteKey] = &models.Route{ + Method: "POST", + Path: fullPath, + // A GraphQLApi has no operations to derive a per-route path from (see the + // package doc above), but leaving this "" makes every request look + // operation-less to a request-scoped policy. Some policies — api-key-auth + // v1.2.1 among them — treat an empty OperationPath as "missing API details" + // and fail closed, instead of "not applicable" as SharedContext.OperationPath's + // own doc comment (sdk/core/policy/v1alpha2/context.go) says an empty value + // must be read. + // + // This must NOT be "/": xds/translator.go's createRouteFromRDC/setMatchPathSpecifier + // special-case operationPath=="/" as a literal REST root-path operation (matching both + // "/ctx" and "/ctx/", rewriting the upstream to end in a trailing "/") — semantics that + // don't apply here and that broke the sandbox-upstream IT scenario by appending an + // unwanted "/" to the upstream path. Any value that isn't "/", doesn't end in "/*", and + // doesn't contain "{" falls through those translators' plain default case instead, + // which is a byte-for-byte pass-through of the configured upstream path — exactly what + // "" used to produce before this field started being read. "graphql" is never rendered + // into a route or a rewritten path; it only has to be non-empty and non-special. + OperationPath: "graphql", + PathMatchType: "Exact", + Vhost: mainVhost, + AutoHostRewrite: mainAutoHostRewrite, + Upstream: models.RouteUpstream{ + ClusterKey: mainUpstream.ClusterKey, + Default: &mainUpstreamInfo, + }, + } + rdc.PolicyChains[mainRouteKey] = sdkChainToModel(injected) + + // Sandbox is active when a sandbox upstream is configured (GraphQLApi only + // supports a direct url — see validateGraphQLUpstream — never a ref). + hasSandbox := apiData.Upstream.Sandbox != nil && + apiData.Upstream.Sandbox.Url != nil && strings.TrimSpace(*apiData.Upstream.Sandbox.Url) != "" + + if hasSandbox { + sandboxVhost := t.routerConfig.VHosts.Sandbox.Default + if sandboxVhost == mainVhost { + return nil, fmt.Errorf("sandbox upstream is configured but resolves to the same vhost %q as the main upstream; configure distinct vhosts to avoid route conflicts", sandboxVhost) + } + + sbUpstream, err := addUpstreamCluster(rdc, "sandbox", apiData.Upstream.Sandbox, nil) + if err != nil { + return nil, fmt.Errorf("failed to resolve sandbox upstream: %w", err) + } + sbUpstreamInfo := sbUpstream.UpstreamInfo() + + sbAutoHostRewrite := true + if apiData.Upstream.Sandbox.HostRewrite != nil && *apiData.Upstream.Sandbox.HostRewrite == api.UpstreamHostRewriteManual { + sbAutoHostRewrite = false + } + + sandboxRouteKey := xds.GenerateRouteName("POST", apiData.Context, apiData.Version, "", sandboxVhost) + rdc.Routes[sandboxRouteKey] = &models.Route{ + Method: "POST", + Path: fullPath, + // See the main route's OperationPath comment above. + OperationPath: "graphql", + PathMatchType: "Exact", + Vhost: sandboxVhost, + AutoHostRewrite: sbAutoHostRewrite, + Upstream: models.RouteUpstream{ + ClusterKey: sbUpstream.ClusterKey, + Default: &sbUpstreamInfo, + }, + } + rdc.PolicyChains[sandboxRouteKey] = sdkChainToModel(injected) + } + + return rdc, nil +} + +// collectAPIPolicies returns the resolved API-level policies as a slice in spec +// order, mirroring RestAPITransformer.collectAPIPolicies exactly (duplicated rather +// than extracted to a shared function because it is only a few lines and — unlike +// addUpstreamCluster, which is a large self-contained block with no transformer +// state — depends on t.policyDefinitions/t.latestVersions, so sharing it would mean +// plumbing those through a standalone helper for a single call site on each side). +func (t *GraphQLAPITransformer) collectAPIPolicies(policies *[]api.Policy) []policyenginev1.PolicyInstance { + var result []policyenginev1.PolicyInstance + if policies == nil { + return result + } + for _, p := range *policies { + resolved, err := config.ResolvePolicyVersion(t.policyDefinitions, t.latestVersions, p.Name, p.Version) + if err != nil { + slog.Error("Failed to resolve policy version for GraphQL API-level policy", "policy_name", p.Name, "error", err) + continue + } + result = append(result, convertAPIPolicyToSDK(p, policyv1alpha.LevelAPI, versionutil.MajorVersion(resolved))) + } + return result +} + +// buildPolicyChain returns the API-level policy chain. A GraphQLApi has no +// operation-level policies to merge in (there are no operations), unlike +// RestAPITransformer.buildPolicyChain. +func (t *GraphQLAPITransformer) buildPolicyChain(apiPolicies []policyenginev1.PolicyInstance) []policyenginev1.PolicyInstance { + result := make([]policyenginev1.PolicyInstance, 0, len(apiPolicies)) + result = append(result, apiPolicies...) + return result +} diff --git a/gateway/gateway-controller/pkg/transform/graphql_test.go b/gateway/gateway-controller/pkg/transform/graphql_test.go new file mode 100644 index 0000000000..9ba66922d4 --- /dev/null +++ b/gateway/gateway-controller/pkg/transform/graphql_test.go @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package transform + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/xds" +) + +// graphqlUpstream builds the anonymous upstream struct api.GraphQLAPIConfigData embeds. +func graphqlUpstream(mainURL string, sandboxURL *string) struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` +} { + u := struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: ptrStr(mainURL)}, + } + if sandboxURL != nil { + u.Sandbox = &api.Upstream{Url: sandboxURL} + } + return u +} + +// makeGraphQLAPIStoredConfig builds a minimal GraphQLApi StoredConfig for transformer +// tests. GraphQLAPIConfigData carries no schema field at all — the artifact never +// describes its own schema, so transformer behavior can only ever depend on +// context/upstream/policies, never on anything schema-shaped. +func makeGraphQLAPIStoredConfig(sandboxURL *string, policies []api.Policy) *models.StoredConfig { + var specPolicies *[]api.Policy + if policies != nil { + specPolicies = &policies + } + + spec := api.GraphQLAPIConfigData{ + DisplayName: "Countries GraphQL API", + Context: "/countries/$version", + Version: "v1.0", + Upstream: graphqlUpstream("http://backend:8080/graphql", sandboxURL), + Policies: specPolicies, + } + + graphqlAPI := api.GraphQLAPI{ + Kind: api.GraphQLAPIKindGraphQLApi, + Metadata: api.Metadata{Name: "countries-graphql-api"}, + Spec: spec, + } + + return &models.StoredConfig{ + UUID: "countries-graphql-api", + Kind: "GraphQLApi", + Configuration: graphqlAPI, + } +} + +func TestGraphQLAPITransformer_SingleRoute(t *testing.T) { + transformer := NewGraphQLAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + + cfg := makeGraphQLAPIStoredConfig(nil, nil) + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + assert.Len(t, rdc.Routes, 1) +} + +func TestGraphQLAPITransformer_RouteShape(t *testing.T) { + transformer := NewGraphQLAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeGraphQLAPIStoredConfig(nil, nil) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + routeKey := xds.GenerateRouteName("POST", "/countries/$version", "v1.0", "", "main.local") + route, ok := rdc.Routes[routeKey] + require.True(t, ok, "expected route keyed %q, got keys %v", routeKey, keysOf(rdc.Routes)) + + assert.Equal(t, "POST", route.Method) + assert.Equal(t, "/countries/v1.0", route.Path) + assert.Equal(t, "Exact", route.PathMatchType) + assert.Equal(t, "main.local", route.Vhost) + assert.NotEmpty(t, route.Upstream.ClusterKey) + require.NotNil(t, route.Upstream.Default) + assert.Equal(t, "http://backend:8080", route.Upstream.Default.URL) +} + +func TestGraphQLAPITransformer_PolicyChainResolver(t *testing.T) { + transformer := NewGraphQLAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeGraphQLAPIStoredConfig(nil, nil) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + assert.Equal(t, "route-key", rdc.PolicyChainResolver) +} + +func TestGraphQLAPITransformer_SandboxProducesSecondRoute(t *testing.T) { + transformer := NewGraphQLAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeGraphQLAPIStoredConfig(ptrStr("http://sandbox-backend:8080/graphql"), nil) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + assert.Len(t, rdc.Routes, 2) + + mainRouteKey := xds.GenerateRouteName("POST", "/countries/$version", "v1.0", "", "main.local") + sandboxRouteKey := xds.GenerateRouteName("POST", "/countries/$version", "v1.0", "", "sandbox.local") + + mainRoute, ok := rdc.Routes[mainRouteKey] + require.True(t, ok) + sandboxRoute, ok := rdc.Routes[sandboxRouteKey] + require.True(t, ok) + + assert.NotEqual(t, mainRoute.Upstream.ClusterKey, sandboxRoute.Upstream.ClusterKey) + require.NotNil(t, sandboxRoute.Upstream.Default) + assert.Equal(t, "http://sandbox-backend:8080", sandboxRoute.Upstream.Default.URL) + + // Both routes get the same (API-level) policy chain. + require.Contains(t, rdc.PolicyChains, mainRouteKey) + require.Contains(t, rdc.PolicyChains, sandboxRouteKey) +} + +func TestGraphQLAPITransformer_NoOperationsLoop(t *testing.T) { + // A GraphQLAPIConfigData has no Operations field at all (unlike api.APIConfigData) — + // this test documents that expectation by confirming route count tracks upstream + // slots (1 or 2), never anything resembling an operation count. + transformer := NewGraphQLAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeGraphQLAPIStoredConfig(nil, nil) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + assert.Len(t, rdc.Routes, 1) +} + +func TestGraphQLAPITransformer_WrongConfigurationType(t *testing.T) { + transformer := NewGraphQLAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := &models.StoredConfig{ + UUID: "bad-config", + Kind: "GraphQLApi", + Configuration: api.RestAPI{}, // wrong type on purpose + } + + _, err := transformer.Transform(cfg) + assert.Error(t, err) +} + +func keysOf(m map[string]*models.Route) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/gateway/gateway-controller/pkg/transform/registry.go b/gateway/gateway-controller/pkg/transform/registry.go index 29b7763fb7..d2ea4fb959 100644 --- a/gateway/gateway-controller/pkg/transform/registry.go +++ b/gateway/gateway-controller/pkg/transform/registry.go @@ -41,6 +41,7 @@ var registryKinds = []string{ models.KindLlmProvider, models.KindLlmProxy, models.KindAgent, + models.KindGraphQLApi, } // envoyTranslatorExcludedKinds are the kinds that must NOT be wired into the @@ -93,6 +94,11 @@ func (r *Registry) Transform(cfg *models.StoredConfig) (*models.RuntimeDeployCon return nil, fmt.Errorf("%w: %s", ErrUnsupportedKind, cfg.Kind) } return r.agentT.Transform(cfg) + case models.KindGraphQLApi: + if r.graphqlT == nil { + return nil, fmt.Errorf("%w: %s", ErrUnsupportedKind, cfg.Kind) + } + return r.graphqlT.Transform(cfg) default: return nil, fmt.Errorf("%w: %s", ErrUnsupportedKind, cfg.Kind) } @@ -100,12 +106,13 @@ func (r *Registry) Transform(cfg *models.StoredConfig) (*models.RuntimeDeployCon // Registry dispatches StoredConfig → RuntimeDeployConfig by API kind. type Registry struct { - restT *RestAPITransformer - llmT *LLMTransformer - agentT *AgentTransformer + restT *RestAPITransformer + llmT *LLMTransformer + agentT *AgentTransformer + graphqlT *GraphQLAPITransformer } // NewRegistry creates a new transformer Registry. -func NewRegistry(restT *RestAPITransformer, llmT *LLMTransformer, agentT *AgentTransformer) *Registry { - return &Registry{restT: restT, llmT: llmT, agentT: agentT} +func NewRegistry(restT *RestAPITransformer, llmT *LLMTransformer, agentT *AgentTransformer, graphqlT *GraphQLAPITransformer) *Registry { + return &Registry{restT: restT, llmT: llmT, agentT: agentT, graphqlT: graphqlT} } diff --git a/gateway/gateway-controller/pkg/transform/restapi.go b/gateway/gateway-controller/pkg/transform/restapi.go index 3457b6625b..ad43d2bfaf 100644 --- a/gateway/gateway-controller/pkg/transform/restapi.go +++ b/gateway/gateway-controller/pkg/transform/restapi.go @@ -501,7 +501,10 @@ func (r *upstreamClusterResult) UpstreamInfo() policyenginev1.UpstreamInfo { // transformer calls it directly so that an Agent's cluster key, base path, Envoy // cluster name and TLS flag are derived by the identical code path as a REST API's // — those four values are what the route rewrite and the policy engine's -// default-upstream both key off. +// default-upstream both key off. GraphQLAPITransformer calls it directly for the +// same reason, to resolve its own main/sandbox upstream clusters without +// duplicating the resolution logic (URL/ref lookup, port defaulting, TLS +// detection, connect-timeout resolution). func addUpstreamCluster( rdc *models.RuntimeDeployConfig, upstreamName string, diff --git a/gateway/gateway-controller/pkg/utils/api_key.go b/gateway/gateway-controller/pkg/utils/api_key.go index 24deeb5571..d986d32ca9 100644 --- a/gateway/gateway-controller/pkg/utils/api_key.go +++ b/gateway/gateway-controller/pkg/utils/api_key.go @@ -43,6 +43,16 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" ) +// ErrAPIKeyExpirationInPast and ErrUnsupportedAPIKeyExpirationUnit are sentinel errors for the +// two ways an API key expiry request can be invalid — a client input problem, not a server +// fault. createAPIKeyFromRequest, updateAPIKeyFromRequest, and regenerateAPIKey wrap one of +// these into every expiry-validation error they return (via fmt.Errorf's %w), so handlers can +// map them to 400 with errors.Is instead of falling through to a generic 500. +var ( + ErrAPIKeyExpirationInPast = errors.New("API key expiration time must be in the future") + ErrUnsupportedAPIKeyExpirationUnit = errors.New("unsupported expiration unit") +) + // APIKeyCreationParams contains parameters for API key creation operations. // Handles both local key generation and external key injection. type APIKeyCreationParams struct { @@ -1067,7 +1077,7 @@ func (s *APIKeyService) createAPIKeyFromRequest(handle string, request *api.APIK case api.APIKeyCreationRequestExpiresInUnitMonths: timeDuration *= 30 * 24 * time.Hour // Approximate month as 30 days default: - return nil, fmt.Errorf("unsupported expiration unit: %s", request.ExpiresIn.Unit) + return nil, fmt.Errorf("%w: %s", ErrUnsupportedAPIKeyExpirationUnit, request.ExpiresIn.Unit) } expiry := now.Add(timeDuration) expiresAt = &expiry @@ -1075,8 +1085,8 @@ func (s *APIKeyService) createAPIKeyFromRequest(handle string, request *api.APIK // Validate that expiresAt is in the future if expiresAt != nil && expiresAt.Before(now) { - return nil, fmt.Errorf("API key expiration time must be in the future, got: %s (current time: %s)", - expiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) + return nil, fmt.Errorf("%w, got: %s (current time: %s)", + ErrAPIKeyExpirationInPast, expiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) } keyCreatedAt := now @@ -1223,8 +1233,8 @@ func (s *APIKeyService) updateAPIKeyFromRequest(existingKey *models.APIKey, requ if request.ExpiresAt != nil { if request.ExpiresAt.Before(now) { - return nil, fmt.Errorf("API key expiration time must be in the future, got: %s (current time: %s)", - request.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) + return nil, fmt.Errorf("%w, got: %s (current time: %s)", + ErrAPIKeyExpirationInPast, request.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) } expiresAt = request.ExpiresAt logger.Info("Using provided expires_at for update", slog.Time("expires_at", *expiresAt)) @@ -1244,7 +1254,7 @@ func (s *APIKeyService) updateAPIKeyFromRequest(existingKey *models.APIKey, requ case api.APIKeyCreationRequestExpiresInUnitMonths: timeDuration *= 30 * 24 * time.Hour default: - return nil, fmt.Errorf("unsupported expiration unit: %s", request.ExpiresIn.Unit) + return nil, fmt.Errorf("%w: %s", ErrUnsupportedAPIKeyExpirationUnit, request.ExpiresIn.Unit) } expiry := now.Add(timeDuration) expiresAt = &expiry @@ -1256,8 +1266,8 @@ func (s *APIKeyService) updateAPIKeyFromRequest(existingKey *models.APIKey, requ // Validate that expiresAt is in the future (if set) if expiresAt != nil && expiresAt.Before(now) { - return nil, fmt.Errorf("API key expiration time must be in the future, got: %s (current time: %s)", - expiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) + return nil, fmt.Errorf("%w, got: %s (current time: %s)", + ErrAPIKeyExpirationInPast, expiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) } keyUpdatedAt := now @@ -1307,8 +1317,8 @@ func (s *APIKeyService) regenerateAPIKey(existingKey *models.APIKey, request api if request.ExpiresAt != nil { if request.ExpiresAt.Before(now) { - return nil, fmt.Errorf("API key expiration time must be in the future, got: %s (current time: %s)", - request.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) + return nil, fmt.Errorf("%w, got: %s (current time: %s)", + ErrAPIKeyExpirationInPast, request.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) } expiresAt = request.ExpiresAt logger.Info("Using provided expires_at for regeneration", slog.Time("expires_at", *expiresAt)) @@ -1328,7 +1338,7 @@ func (s *APIKeyService) regenerateAPIKey(existingKey *models.APIKey, request api case api.APIKeyRegenerationRequestExpiresInUnitMonths: timeDuration *= 30 * 24 * time.Hour default: - return nil, fmt.Errorf("unsupported expiration unit: %s", request.ExpiresIn.Unit) + return nil, fmt.Errorf("%w: %s", ErrUnsupportedAPIKeyExpirationUnit, request.ExpiresIn.Unit) } expiry := now.Add(timeDuration) expiresAt = &expiry @@ -1346,8 +1356,8 @@ func (s *APIKeyService) regenerateAPIKey(existingKey *models.APIKey, request api // Validate that expiresAt is in the future (if set) if expiresAt != nil && expiresAt.Before(now) { - return nil, fmt.Errorf("API key expiration time must be in the future, got: %s (current time: %s)", - expiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) + return nil, fmt.Errorf("%w, got: %s (current time: %s)", + ErrAPIKeyExpirationInPast, expiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) } // Create the regenerated API key diff --git a/gateway/gateway-controller/pkg/utils/api_utils.go b/gateway/gateway-controller/pkg/utils/api_utils.go index 698a9555b1..9a98b03d2e 100644 --- a/gateway/gateway-controller/pkg/utils/api_utils.go +++ b/gateway/gateway-controller/pkg/utils/api_utils.go @@ -340,6 +340,8 @@ func (s *APIUtilsService) FetchAPIKeysByKind(artifactKind, issuer string) ([]mod path = "/websub-apis/api-keys" case models.KindWebBrokerApi: path = "/webbroker-apis/api-keys" + case models.KindGraphQLApi: + path = "/graphql-apis/api-keys" default: return nil, fmt.Errorf("unsupported artifact kind for API key fetch: %s", artifactKind) } @@ -466,6 +468,17 @@ func (s *APIUtilsService) FetchSubscriptionPlans() ([]models.SubscriptionPlan, e return plans, nil } +// maxZipEntries caps how many entries an inbound API definition archive may +// contain, before any entry is opened — a bound independent of the +// decompressed-size guard below, since a zip bomb can also be built from many +// tiny entries rather than one large one. +const maxZipEntries = 1000 + +// maxZipDecompressionRatio bounds how much larger a single entry's +// decompressed content may be than its compressed size, so a small malicious +// archive can't expand into an unbounded read (file-access.md directive 7). +const maxZipDecompressionRatio = 100 + // ExtractYAMLFromZip extracts the API definition YAML from the zip file func (s *APIUtilsService) ExtractYAMLFromZip(zipData []byte) ([]byte, error) { // Create a reader from the zip data @@ -473,6 +486,9 @@ func (s *APIUtilsService) ExtractYAMLFromZip(zipData []byte) ([]byte, error) { if err != nil { return nil, fmt.Errorf("failed to create zip reader: %w", err) } + if len(zipReader.File) > maxZipEntries { + return nil, fmt.Errorf("archive contains too many entries") + } // Look for YAML files in the zip for _, file := range zipReader.File { @@ -489,11 +505,22 @@ func (s *APIUtilsService) ExtractYAMLFromZip(zipData []byte) ([]byte, error) { } defer rc.Close() - // Read the content - yamlData, err := io.ReadAll(rc) + // Bound the decompressed read by both the shared response-size ceiling + // and a ratio guard on this entry's own compressed size, so neither a + // single huge entry nor a small, highly-compressed one can force an + // unbounded read into memory. + maxDecompressed := s.config.MaxResponseBytes + if ratioCap := int64(file.CompressedSize64) * maxZipDecompressionRatio; ratioCap > 0 && ratioCap < maxDecompressed { + maxDecompressed = ratioCap + } + + yamlData, err := io.ReadAll(io.LimitReader(rc, maxDecompressed+1)) if err != nil { return nil, fmt.Errorf("failed to read file %s: %w", file.Name, err) } + if int64(len(yamlData)) > maxDecompressed { + return nil, fmt.Errorf("archive entry exceeds the maximum allowed size") + } return yamlData, nil } @@ -629,6 +656,16 @@ func (s *APIUtilsService) FetchMCPProxyDefinition(proxyID string) ([]byte, error return bodyBytes, nil } +// FetchGraphQLAPIDefinition downloads the GraphQL API definition as a zip file +// from the control plane. GraphQLApi is compiled directly into this binary +// (not a separate module), so unlike WebSub/WebBroker it doesn't need +// FetchResourceZip's cross-module reuse — the wrapper is kept anyway to avoid +// duplicating the HTTP/auth/size-limit boilerplate FetchMCPProxyDefinition +// above still carries inline. +func (s *APIUtilsService) FetchGraphQLAPIDefinition(apiID string) ([]byte, error) { + return s.FetchResourceZip("/graphql-apis/"+apiID, "GraphQL API definition") +} + // FetchResourceZip performs a generic authenticated GET against // {baseURL}{resourcePath}, expecting a zip response, and returns the raw // bytes. resourceLabel is used only for log/error messages (e.g. "WebSub API diff --git a/gateway/gateway-controller/pkg/utils/graphql_deployment.go b/gateway/gateway-controller/pkg/utils/graphql_deployment.go new file mode 100644 index 0000000000..29dd3c46d9 --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/graphql_deployment.go @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package utils + +import ( + "fmt" + "net/url" + "strings" + + commonconstants "github.com/wso2/api-platform/common/constants" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" +) + +// GraphQLApi is not known to api_deployment.go's core switch (it is only handled there +// for "RestApi") — it is wired in generically via the RegisterKindDeployParser / +// RegisterKindConfigValidator extension points that api_deployment.go already exposes +// for kinds not known to core (the same mechanism an event-gateway-controller binary +// uses for WebSubApi/WebBrokerApi). Unlike those, GraphQLApi is compiled directly into +// this binary (not gated behind a build tag), so it self-registers here via init() +// rather than from a separate module's Init(). +func init() { + RegisterKindDeployParser(graphQLApiKind, parseGraphQLAPIDeployment) + RegisterKindConfigValidator(graphQLApiKind, validateGraphQLAPIConfig) +} + +const graphQLApiKind = string(api.GraphQLAPIKindGraphQLApi) + +// parseGraphQLAPIDeployment is the KindDeployParser for GraphQLApi. It mirrors the +// "RestApi" case in DeployAPIConfiguration's own switch: the whole request body is +// parsed directly into api.GraphQLAPI (the deployable shape), and identifiers that +// live outside the spec block (kind, metadata.name, artifact-id annotation) are +// extracted for the caller. +func parseGraphQLAPIDeployment(parser *config.Parser, data []byte, contentType string) (any, string, string, string, error) { + var graphqlConfig api.GraphQLAPI + if err := parser.Parse(data, contentType, &graphqlConfig); err != nil { + return nil, "", "", "", fmt.Errorf("failed to unmarshal GraphQL API configuration: %w", err) + } + handle := graphqlConfig.Metadata.Name + kind := string(graphqlConfig.Kind) + annotationArtifactID := annotationValue(graphqlConfig.Metadata.Annotations, commonconstants.AnnotationArtifactID) + return graphqlConfig, handle, kind, annotationArtifactID, nil +} + +// validateGraphQLAPIConfig is the KindConfigValidator for GraphQLApi. It performs the +// same class of structural validation config.APIValidator applies to RestAPI (kind, +// metadata, upstream url/ref) — duplicated here in miniature rather than extending +// APIValidator's private RestAPI-specific methods, since a GraphQLApi has no +// operations/upstreamDefinitions to validate against. config.ValidateMetadata is +// reused as-is since it is already kind-agnostic (operates on *api.Metadata alone). +func validateGraphQLAPIConfig(cfg any) (apiName, apiVersion string, validationErrors []config.ValidationError) { + graphqlConfig, ok := cfg.(api.GraphQLAPI) + if !ok { + return "", "", []config.ValidationError{{ + Field: "config", + Message: fmt.Sprintf("unexpected configuration type %T for GraphQLApi", cfg), + }} + } + + var errors []config.ValidationError + + if graphqlConfig.Kind != api.GraphQLAPIKindGraphQLApi { + errors = append(errors, config.ValidationError{ + Field: "kind", + Message: "Unsupported kind (must be 'GraphQLApi')", + }) + } + + errors = append(errors, config.ValidateMetadata(&graphqlConfig.Metadata)...) + + spec := graphqlConfig.Spec + if strings.TrimSpace(spec.DisplayName) == "" { + errors = append(errors, config.ValidationError{Field: "spec.displayName", Message: "displayName is required"}) + } + if strings.TrimSpace(spec.Version) == "" { + errors = append(errors, config.ValidationError{Field: "spec.version", Message: "version is required"}) + } + if strings.TrimSpace(spec.Context) == "" { + errors = append(errors, config.ValidationError{Field: "spec.context", Message: "context is required"}) + } else if !strings.HasPrefix(spec.Context, "/") { + errors = append(errors, config.ValidationError{Field: "spec.context", Message: "context must start with '/'"}) + } else if strings.HasSuffix(spec.Context, "/") && spec.Context != "/" { + errors = append(errors, config.ValidationError{Field: "spec.context", Message: "Context cannot end with / (except for root context)"}) + } + + errors = append(errors, validateGraphQLUpstream("main", &spec.Upstream.Main)...) + if spec.Upstream.Sandbox != nil { + errors = append(errors, validateGraphQLUpstream("sandbox", spec.Upstream.Sandbox)...) + } + + return spec.DisplayName, spec.Version, errors +} + +// validateGraphQLUpstream validates a single upstream slot (main or sandbox). A +// GraphQLApi's upstream shape is identical to RestAPI's (reused unmodified from the +// same generated api.Upstream type), so this intentionally mirrors +// config.APIValidator's private validateUpstreamUrl in miniature: GraphQLApi does not +// support upstreamDefinitions references in this pass, so only a direct url is valid. +func validateGraphQLUpstream(label string, up *api.Upstream) []config.ValidationError { + var errors []config.ValidationError + if up == nil { + return errors + } + + if up.Ref != nil && strings.TrimSpace(*up.Ref) != "" { + errors = append(errors, config.ValidationError{ + Field: "spec.upstream." + label + ".ref", + Message: "Upstream ref is not supported for GraphQLApi (no upstreamDefinitions list); use a direct url", + }) + } + + if up.Url == nil || strings.TrimSpace(*up.Url) == "" { + errors = append(errors, config.ValidationError{ + Field: "spec.upstream." + label + ".url", + Message: "Upstream URL is required", + }) + return errors + } + + parsedURL, err := url.Parse(*up.Url) + if err != nil { + errors = append(errors, config.ValidationError{ + Field: "spec.upstream." + label + ".url", + Message: fmt.Sprintf("Invalid URL format: %v", err), + }) + return errors + } + + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + errors = append(errors, config.ValidationError{ + Field: "spec.upstream." + label + ".url", + Message: "Upstream URL must use http or https scheme", + }) + } + if parsedURL.Host == "" { + errors = append(errors, config.ValidationError{ + Field: "spec.upstream." + label + ".url", + Message: "Upstream URL must include a host", + }) + } + + return errors +} diff --git a/gateway/gateway-controller/pkg/utils/graphql_deployment_test.go b/gateway/gateway-controller/pkg/utils/graphql_deployment_test.go new file mode 100644 index 0000000000..8602226a92 --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/graphql_deployment_test.go @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package utils + +import ( + "testing" + + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" +) + +func graphqlUpstreamStrPtr(s string) *string { return &s } + +// containsFieldError reports whether errs has a ValidationError for the given field. +func containsFieldError(errs []config.ValidationError, field string) bool { + for _, e := range errs { + if e.Field == field { + return true + } + } + return false +} + +// TestValidateGraphQLUpstream_RefOnly pins the fix for the gap where a +// ref-only upstream produced only the misleading "Upstream URL is required" +// error, with nothing telling the caller that ref itself is unsupported for +// GraphQLApi (it has no upstreamDefinitions list to resolve it against). +func TestValidateGraphQLUpstream_RefOnly(t *testing.T) { + up := &api.Upstream{Ref: graphqlUpstreamStrPtr("some-def")} + + errs := validateGraphQLUpstream("main", up) + + if !containsFieldError(errs, "spec.upstream.main.ref") { + t.Errorf("expected a spec.upstream.main.ref error for a ref-only upstream, got: %+v", errs) + } + if !containsFieldError(errs, "spec.upstream.main.url") { + t.Errorf("expected a spec.upstream.main.url error (missing) for a ref-only upstream, got: %+v", errs) + } +} + +// TestValidateGraphQLUpstream_URLAndRef_RefRejected pins the other half of the +// gap: url+ref together used to be accepted outright, with ref silently +// ignored downstream by resolveUpstreamURL's early return on a non-empty Url +// (transform/restapi.go). A ref alongside a valid url must still be rejected. +func TestValidateGraphQLUpstream_URLAndRef_RefRejected(t *testing.T) { + up := &api.Upstream{ + Url: graphqlUpstreamStrPtr("http://backend.example.com:8080/graphql"), + Ref: graphqlUpstreamStrPtr("some-def"), + } + + errs := validateGraphQLUpstream("main", up) + + if !containsFieldError(errs, "spec.upstream.main.ref") { + t.Errorf("expected a spec.upstream.main.ref error when ref is set alongside a valid url, got: %+v", errs) + } + if containsFieldError(errs, "spec.upstream.main.url") { + t.Errorf("did not expect a url error when url is valid, got: %+v", errs) + } +} + +// TestValidateGraphQLUpstream_URLOnly_NoRefError is the control case: a +// direct url with no ref must produce no errors at all. +func TestValidateGraphQLUpstream_URLOnly_NoRefError(t *testing.T) { + up := &api.Upstream{Url: graphqlUpstreamStrPtr("https://backend.example.com/graphql")} + + errs := validateGraphQLUpstream("main", up) + + if len(errs) != 0 { + t.Errorf("expected no validation errors for a valid direct url, got: %+v", errs) + } +} diff --git a/gateway/gateway-controller/pkg/xds/translator.go b/gateway/gateway-controller/pkg/xds/translator.go index f47daf885a..7decb336a2 100644 --- a/gateway/gateway-controller/pkg/xds/translator.go +++ b/gateway/gateway-controller/pkg/xds/translator.go @@ -391,7 +391,20 @@ func (t *Translator) createRouteFromRDC(routeKey string, rdcRoute *models.Route, // For a wildcard operation path like "/foo/*", strip only the context so the matched literal // prefix ("/foo") is PRESERVED on the upstream — consistent with exact paths. The bare "/*" // catch-all (empty literal prefix) and "/" root are unaffected. See issue #2071. - contextWithVersion := strings.TrimSuffix(fullPath, operationPath) + // + // That "fullPath = context + operationPath" invariant holds for every RestAPITransformer + // route by construction, but GraphQLApi's single fixed route builds Path from context+version + // alone and never appends OperationPath — OperationPath there is a synthetic non-empty + // placeholder some policies require (see GraphQLAPITransformer), unrelated to fullPath's + // actual content. A string-based guard here (only trim when operationPath "is" a suffix) is + // not safe either: an API context an operator names e.g. "/sandbox-graphql" genuinely ends + // with the literal placeholder text, so the trim would fire and corrupt the rewrite anyway. + // Route the two cases on the one thing that's unambiguous — the route's own kind — rather + // than on any string relationship between fullPath and operationPath. + contextWithVersion := fullPath + if rdc.Metadata.Kind != string(models.KindGraphQLApi) { + contextWithVersion = strings.TrimSuffix(fullPath, operationPath) + } escapedContext := regexp.QuoteMeta(contextWithVersion) if rdcRoute.UpstreamPathOverride != "" { diff --git a/gateway/gateway-controller/pkg/xds/translator_test.go b/gateway/gateway-controller/pkg/xds/translator_test.go index ce5e5dbd3e..ef768d20d7 100644 --- a/gateway/gateway-controller/pkg/xds/translator_test.go +++ b/gateway/gateway-controller/pkg/xds/translator_test.go @@ -1059,6 +1059,54 @@ func TestTranslator_ExactPathUsesNativeMatcher(t *testing.T) { "exact route must rank as Exact for SortRoutesByPriority") } +// applyEnvoyRegexRewrite is applyEnvoyRewrite without the SafeRegex match-specifier +// precondition, for routes matched via Envoy's native exact matcher (RouteMatch_Path) +// instead of a safe_regex — the RegexRewrite route action applies independently of how +// the route was matched. +func applyEnvoyRegexRewrite(t *testing.T, r *route.Route, requestPath string) string { + t.Helper() + rw := r.GetRoute().GetRegexRewrite() + require.NotNil(t, rw, "route should have a RegexRewrite") + pattern := regexp.MustCompile(rw.GetPattern().GetRegex()) + goSub := strings.ReplaceAll(rw.GetSubstitution(), `\1`, `${1}`) + return pattern.ReplaceAllString(requestPath, goSub) +} + +// TestTranslator_GraphQLOperationPathNotAppendedToUpstream guards the fix for a +// GraphQLApi route whose context coincidentally ends with the same text as its +// synthetic OperationPath placeholder (GraphQLAPITransformer sets a fixed, non-empty +// OperationPath so policies like api-key-auth that misread "" as "missing API details" +// don't fail closed — see graphql.go). createRouteFromRDC previously derived the +// route's context by blindly trimming OperationPath off the end of the full path, +// an invariant that only genuinely holds for RestAPITransformer routes. For a context +// like "/sandbox-graphql" with OperationPath "graphql", that trim fired anyway +// (the text really is there, just not because of a real appended operation) and +// doubled the upstream path segment: an upstream of ".../graphql" was rewritten to +// ".../graphqlgraphql" instead of being passed through unchanged. +func TestTranslator_GraphQLOperationPathNotAppendedToUpstream(t *testing.T) { + logger := createTestLogger() + translator := NewTranslator(logger, testRouterConfig(), nil, testConfig()) + + rdc := &models.RuntimeDeployConfig{ + Metadata: models.Metadata{Kind: string(models.KindGraphQLApi)}, + UpstreamClusters: map[string]*models.UpstreamCluster{ + "main": {BasePath: "/graphql", Endpoints: []models.Endpoint{{Host: "sample-backend", Port: 9080}}}, + }, + } + rdcRoute := &models.Route{ + Method: "POST", + Path: "/sandbox-graphql", + OperationPath: "graphql", // the synthetic placeholder, coincidentally a suffix of Path + PathMatchType: "Exact", + AutoHostRewrite: true, + Upstream: models.RouteUpstream{ClusterKey: "main"}, + } + r := translator.createRouteFromRDC("POST|/sandbox-graphql|", rdcRoute, rdc) + require.NotNil(t, r) + assert.Equal(t, "/graphql", applyEnvoyRegexRewrite(t, r, "/sandbox-graphql"), + "upstream path must be passed through unchanged, not have OperationPath appended a second time") +} + // TestSortRoutesByPriority_ExactBeatsLongerPrefixRegex reproduces the HTTPRoutePathMatchOrder // conformance shape: an exact /match must outrank the /match/ prefix even though the prefix's // regex string is longer. Before the fix the exact route was a safe_regex and lost on length. diff --git a/gateway/gateway-controller/tests/integration/schema_test.go b/gateway/gateway-controller/tests/integration/schema_test.go index 75122b4a01..c3908ec985 100644 --- a/gateway/gateway-controller/tests/integration/schema_test.go +++ b/gateway/gateway-controller/tests/integration/schema_test.go @@ -163,7 +163,7 @@ func TestSchemaInitialization(t *testing.T) { // Verify per-resource-type tables exist t.Run("ResourceTypeTablesExist", func(t *testing.T) { - tables := []string{"rest_apis", "llm_providers", "llm_proxies", "mcp_proxies", "agents"} + tables := []string{"rest_apis", "llm_providers", "llm_proxies", "mcp_proxies", "agents", "graphql_apis"} for _, table := range tables { var tableName string err := rawDB.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&tableName) diff --git a/gateway/it/docker-compose.test.yaml b/gateway/it/docker-compose.test.yaml index 0bfd3096a6..160b4709c4 100644 --- a/gateway/it/docker-compose.test.yaml +++ b/gateway/it/docker-compose.test.yaml @@ -120,6 +120,8 @@ services: # condition: service_healthy mock-openapi: condition: service_healthy + mock-graphql-backend: + condition: service_healthy healthcheck: test: ["CMD", "health-check.sh"] interval: 5s @@ -329,6 +331,26 @@ services: networks: - it-gateway-runtime-network + # Mock GraphQL Backend: echoes the request body back verbatim, so a test can produce + # an arbitrary upstream response shape (e.g. a GraphQL {"errors":[...]} body) that the + # generic sample-service fixture's fixed envelope can never produce. + mock-graphql-backend: + container_name: it-mock-graphql-backend + image: ghcr.io/wso2/api-platform/mock-graphql-backend:latest + build: + context: ../../tests/mock-servers/mock-graphql-backend + dockerfile: Dockerfile + ports: + - "8089:8080" + healthcheck: + test: ["CMD", "wget", "--spider", "--quiet", "--tries=1", "http://127.0.0.1:8080/health"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 5s + networks: + - it-gateway-runtime-network + # Redis with RediSearch for semantic cache vector storage redis: container_name: it-redis diff --git a/go.work.sum b/go.work.sum index 51cce59f5c..b3374a5d04 100644 --- a/go.work.sum +++ b/go.work.sum @@ -2214,6 +2214,7 @@ github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agnivade/levenshtein v1.2.0/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9 h1:7kQgkwGRoLzC9K0oyXdJo7nve/bynv/KwUsxbiTlzAM= @@ -2290,6 +2291,7 @@ github.com/apparentlymart/go-cidr v1.0.1 h1:NmIwLZ/KdsjIUlhf+/Np40atNXm/+lZ5txfT github.com/apparentlymart/go-cidr v1.0.1/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/ardielle/ardielle-go v1.5.2 h1:TilHTpHIQJ27R1Tl/iITBzMwiUGSlVfiVhwDNGM3Zj4= github.com/ardielle/ardielle-go v1.5.2/go.mod h1:I4hy1n795cUhaVt/ojz83SNVCYIGsAFAONtv2Dr7HUI= @@ -2823,6 +2825,7 @@ github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczC github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954 h1:RMLoZVzv4GliuWafOuPuQDKSm1SJph7uCRnnS61JAn4= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/digitalocean/godo v1.109.0/go.mod h1:R6EmmWI8CT1+fCtjWY9UCB+L5uufuZH13wk3YhxycCs= github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= @@ -4503,6 +4506,7 @@ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+ github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a h1:0R4NLDRDZX6JcmhJgXi5E4b8Wg84ihbmUKp/GvSPEzc= github.com/vbatts/tar-split v0.11.3/go.mod h1:9QlHN18E+fEH7RdG+QAJJcuya3rqT7eXSTY7wGrAokY= github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= +github.com/vektah/gqlparser/v2 v2.5.36 h1:CN9mKVHgMkc+XftdOWIhb4HEL8wKSYkFAqhf8booa7s= github.com/vektah/gqlparser/v2 v2.5.36/go.mod h1:cAJ9qwVgPaUkWv6Gn8vn0mqOE0Ui5Pn56wNy5396XWo= github.com/veraison/go-cose v1.1.0/go.mod h1:7ziE85vSq4ScFTg6wyoMXjucIGOf4JkFEZi/an96Ct4= github.com/veraison/go-cose v1.3.0/go.mod h1:df09OV91aHoQWLmy1KsDdYiagtXgyAwAl8vFeFn1gMc= diff --git a/license-reports/go/gateway-controller-third-party-go-licenses.csv b/license-reports/go/gateway-controller-third-party-go-licenses.csv index 6633cdc677..f7670f468f 100644 --- a/license-reports/go/gateway-controller-third-party-go-licenses.csv +++ b/license-reports/go/gateway-controller-third-party-go-licenses.csv @@ -48,7 +48,6 @@ github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil,htt github.com/prometheus/client_golang/prometheus,https://github.com/prometheus/client_golang/blob/v1.23.2/LICENSE,Apache-2.0 github.com/prometheus/client_model/go,https://github.com/prometheus/client_model/blob/v0.6.2/LICENSE,Apache-2.0 github.com/prometheus/common,https://github.com/prometheus/common/blob/v0.66.1/LICENSE,Apache-2.0 -github.com/prometheus/procfs,https://github.com/prometheus/procfs/blob/v0.19.2/LICENSE,Apache-2.0 github.com/shopspring/decimal,https://github.com/shopspring/decimal/blob/v1.4.0/LICENSE,MIT github.com/woodsbury/decimal128,https://github.com/woodsbury/decimal128/blob/v1.3.0/LICENCE,BSD-0-Clause github.com/xeipuuv/gojsonpointer,https://github.com/xeipuuv/gojsonpointer/blob/02993c407bfb/LICENSE-APACHE-2.0.txt,Apache-2.0 @@ -58,7 +57,7 @@ go.yaml.in/yaml/v2,https://github.com/yaml/go-yaml/blob/v2.4.3/LICENSE,Apache-2. golang.org/x/crypto,https://cs.opensource.google/go/x/crypto/+/v0.54.0:LICENSE,BSD-3-Clause golang.org/x/net,https://cs.opensource.google/go/x/net/+/v0.56.0:LICENSE,BSD-3-Clause golang.org/x/sync/semaphore,https://cs.opensource.google/go/x/sync/+/v0.22.0:LICENSE,BSD-3-Clause -golang.org/x/sys,https://cs.opensource.google/go/x/sys/+/v0.47.0:LICENSE,BSD-3-Clause +golang.org/x/sys/unix,https://cs.opensource.google/go/x/sys/+/v0.47.0:LICENSE,BSD-3-Clause golang.org/x/text,https://cs.opensource.google/go/x/text/+/v0.40.0:LICENSE,BSD-3-Clause golang.org/x/time/rate,https://cs.opensource.google/go/x/time/+/v0.14.0:LICENSE,BSD-3-Clause google.golang.org/genproto/googleapis/api,https://github.com/googleapis/go-genproto/blob/3dc84a4a5aaa/googleapis/api/LICENSE,Apache-2.0 diff --git a/license-reports/go/gateway-runtime-third-party-go-licenses.csv b/license-reports/go/gateway-runtime-third-party-go-licenses.csv index 66041fd9ed..112aaf062b 100644 --- a/license-reports/go/gateway-runtime-third-party-go-licenses.csv +++ b/license-reports/go/gateway-runtime-third-party-go-licenses.csv @@ -29,7 +29,6 @@ github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil,htt github.com/prometheus/client_golang/prometheus,https://github.com/prometheus/client_golang/blob/v1.23.2/LICENSE,Apache-2.0 github.com/prometheus/client_model/go,https://github.com/prometheus/client_model/blob/v0.6.2/LICENSE,Apache-2.0 github.com/prometheus/common,https://github.com/prometheus/common/blob/v0.66.1/LICENSE,Apache-2.0 -github.com/prometheus/procfs,https://github.com/prometheus/procfs/blob/v0.19.2/LICENSE,Apache-2.0 github.com/stoewer/go-strcase,https://github.com/stoewer/go-strcase/blob/v1.3.1/LICENSE,MIT go.opentelemetry.io/auto/sdk,https://github.com/open-telemetry/opentelemetry-go-instrumentation/blob/sdk/v1.2.1/sdk/LICENSE,Apache-2.0 go.opentelemetry.io/otel,https://github.com/open-telemetry/opentelemetry-go/blob/v1.44.0/LICENSE,Apache-2.0 diff --git a/license-reports/go/platform-api-third-party-go-licenses.csv b/license-reports/go/platform-api-third-party-go-licenses.csv index f4be5982e0..ac690670e2 100644 --- a/license-reports/go/platform-api-third-party-go-licenses.csv +++ b/license-reports/go/platform-api-third-party-go-licenses.csv @@ -1,5 +1,6 @@ github.com/MicahParks/jwkset,https://github.com/MicahParks/jwkset/blob/v0.11.0/LICENSE,Apache-2.0 github.com/MicahParks/keyfunc/v3,https://github.com/MicahParks/keyfunc/blob/v3.7.0/LICENSE,Apache-2.0 +github.com/agnivade/levenshtein,https://github.com/agnivade/levenshtein/blob/v1.2.1/License.txt,MIT github.com/apapsch/go-jsonmerge/v2,https://github.com/apapsch/go-jsonmerge/blob/v2.0.0/LICENSE,MIT github.com/fsnotify/fsnotify,https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE,BSD-3-Clause github.com/gabriel-vasile/mimetype,https://github.com/gabriel-vasile/mimetype/blob/v1.4.12/LICENSE,MIT @@ -31,6 +32,7 @@ github.com/mitchellh/reflectwalk,https://github.com/mitchellh/reflectwalk/blob/v github.com/oapi-codegen/runtime,https://github.com/oapi-codegen/runtime/blob/v1.5.0/LICENSE,Apache-2.0 github.com/pelletier/go-toml/v2,https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE,MIT github.com/shopspring/decimal,https://github.com/shopspring/decimal/blob/v1.4.0/LICENSE,MIT +github.com/vektah/gqlparser/v2,https://github.com/vektah/gqlparser/blob/v2.5.36/LICENSE,MIT golang.org/x/crypto,https://cs.opensource.google/go/x/crypto/+/v0.54.0:LICENSE,BSD-3-Clause golang.org/x/net,https://cs.opensource.google/go/x/net/+/v0.56.0:LICENSE,BSD-3-Clause golang.org/x/sync/semaphore,https://cs.opensource.google/go/x/sync/+/v0.22.0:LICENSE,BSD-3-Clause diff --git a/license-reports/go/summary.md b/license-reports/go/summary.md index 5edecba325..2d0f2ee76c 100644 --- a/license-reports/go/summary.md +++ b/license-reports/go/summary.md @@ -14,14 +14,14 @@ ## License Counts ### gateway-controller -- Apache-2.0: 23 +- Apache-2.0: 22 - BSD-0-Clause: 1 - BSD-2-Clause: 1 - BSD-3-Clause: 14 - MIT: 29 ### gateway-runtime -- Apache-2.0: 24 +- Apache-2.0: 23 - BSD-3-Clause: 18 - MIT: 15 @@ -34,5 +34,5 @@ - Apache-2.0: 4 - BSD-2-Clause: 1 - BSD-3-Clause: 10 -- MIT: 25 +- MIT: 27 diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index 4420fefef0..1cdb315a72 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -58,6 +58,14 @@ const ( CreateGatewayRequestFunctionalityTypeRegular CreateGatewayRequestFunctionalityType = "regular" ) +// Defines values for CreateGraphQLAPIRequestSchemaSource. +const ( + CreateGraphQLAPIRequestSchemaSourceFile CreateGraphQLAPIRequestSchemaSource = "file" + CreateGraphQLAPIRequestSchemaSourceInline CreateGraphQLAPIRequestSchemaSource = "inline" + CreateGraphQLAPIRequestSchemaSourceIntrospection CreateGraphQLAPIRequestSchemaSource = "introspection" + CreateGraphQLAPIRequestSchemaSourceUrl CreateGraphQLAPIRequestSchemaSource = "url" +) + // Defines values for CreateRESTAPIRequestLifeCycleStatus. const ( CreateRESTAPIRequestLifeCycleStatusBLOCKED CreateRESTAPIRequestLifeCycleStatus = "BLOCKED" @@ -119,6 +127,20 @@ const ( GatewayResponseFunctionalityTypeRegular GatewayResponseFunctionalityType = "regular" ) +// Defines values for GraphQLAPISchemaSource. +const ( + GraphQLAPISchemaSourceFile GraphQLAPISchemaSource = "file" + GraphQLAPISchemaSourceInline GraphQLAPISchemaSource = "inline" + GraphQLAPISchemaSourceIntrospection GraphQLAPISchemaSource = "introspection" + GraphQLAPISchemaSourceUrl GraphQLAPISchemaSource = "url" +) + +// Defines values for GraphQLIntrospectionMode. +const ( + ENDPOINT GraphQLIntrospectionMode = "ENDPOINT" + SDL GraphQLIntrospectionMode = "SDL" +) + // Defines values for LLMAccessControlMode. const ( AllowAll LLMAccessControlMode = "allow_all" @@ -374,6 +396,14 @@ const ( Revoked UserAPIKeyItemStatus = "revoked" ) +// Defines values for ValidateGraphQLSchemaRequestSchemaSource. +const ( + File ValidateGraphQLSchemaRequestSchemaSource = "file" + Inline ValidateGraphQLSchemaRequestSchemaSource = "inline" + Introspection ValidateGraphQLSchemaRequestSchemaSource = "introspection" + Url ValidateGraphQLSchemaRequestSchemaSource = "url" +) + // Defines values for DeploymentStatusQ. const ( DeploymentStatusQARCHIVED DeploymentStatusQ = "ARCHIVED" @@ -444,6 +474,28 @@ const ( ListGatewaysParamsSortOrderDesc ListGatewaysParamsSortOrder = "desc" ) +// Defines values for ListGraphQLAPIsParamsSortBy. +const ( + ListGraphQLAPIsParamsSortByCreatedAt ListGraphQLAPIsParamsSortBy = "createdAt" + ListGraphQLAPIsParamsSortByName ListGraphQLAPIsParamsSortBy = "name" +) + +// Defines values for ListGraphQLAPIsParamsSortOrder. +const ( + ListGraphQLAPIsParamsSortOrderAsc ListGraphQLAPIsParamsSortOrder = "asc" + ListGraphQLAPIsParamsSortOrderDesc ListGraphQLAPIsParamsSortOrder = "desc" +) + +// Defines values for GetGraphQLAPIDeploymentsParamsStatus. +const ( + GetGraphQLAPIDeploymentsParamsStatusARCHIVED GetGraphQLAPIDeploymentsParamsStatus = "ARCHIVED" + GetGraphQLAPIDeploymentsParamsStatusDEPLOYED GetGraphQLAPIDeploymentsParamsStatus = "DEPLOYED" + GetGraphQLAPIDeploymentsParamsStatusDEPLOYING GetGraphQLAPIDeploymentsParamsStatus = "DEPLOYING" + GetGraphQLAPIDeploymentsParamsStatusFAILED GetGraphQLAPIDeploymentsParamsStatus = "FAILED" + GetGraphQLAPIDeploymentsParamsStatusUNDEPLOYED GetGraphQLAPIDeploymentsParamsStatus = "UNDEPLOYED" + GetGraphQLAPIDeploymentsParamsStatusUNDEPLOYING GetGraphQLAPIDeploymentsParamsStatus = "UNDEPLOYING" +) + // Defines values for GetLLMProviderDeploymentsParamsStatus. const ( GetLLMProviderDeploymentsParamsStatusARCHIVED GetLLMProviderDeploymentsParamsStatus = "ARCHIVED" @@ -495,24 +547,24 @@ const ( // Defines values for ListRESTAPIsParamsSortBy. const ( - CreatedAt ListRESTAPIsParamsSortBy = "createdAt" - Name ListRESTAPIsParamsSortBy = "name" + ListRESTAPIsParamsSortByCreatedAt ListRESTAPIsParamsSortBy = "createdAt" + ListRESTAPIsParamsSortByName ListRESTAPIsParamsSortBy = "name" ) // Defines values for ListRESTAPIsParamsSortOrder. const ( - Asc ListRESTAPIsParamsSortOrder = "asc" - Desc ListRESTAPIsParamsSortOrder = "desc" + ListRESTAPIsParamsSortOrderAsc ListRESTAPIsParamsSortOrder = "asc" + ListRESTAPIsParamsSortOrderDesc ListRESTAPIsParamsSortOrder = "desc" ) // Defines values for GetDeploymentsParamsStatus. const ( - GetDeploymentsParamsStatusARCHIVED GetDeploymentsParamsStatus = "ARCHIVED" - GetDeploymentsParamsStatusDEPLOYED GetDeploymentsParamsStatus = "DEPLOYED" - GetDeploymentsParamsStatusDEPLOYING GetDeploymentsParamsStatus = "DEPLOYING" - GetDeploymentsParamsStatusFAILED GetDeploymentsParamsStatus = "FAILED" - GetDeploymentsParamsStatusUNDEPLOYED GetDeploymentsParamsStatus = "UNDEPLOYED" - GetDeploymentsParamsStatusUNDEPLOYING GetDeploymentsParamsStatus = "UNDEPLOYING" + ARCHIVED GetDeploymentsParamsStatus = "ARCHIVED" + DEPLOYED GetDeploymentsParamsStatus = "DEPLOYED" + DEPLOYING GetDeploymentsParamsStatus = "DEPLOYING" + FAILED GetDeploymentsParamsStatus = "FAILED" + UNDEPLOYED GetDeploymentsParamsStatus = "UNDEPLOYED" + UNDEPLOYING GetDeploymentsParamsStatus = "UNDEPLOYING" ) // Defines values for ListSubscriptionsParamsStatus. @@ -943,6 +995,112 @@ type CreateGatewayRequest struct { // CreateGatewayRequestFunctionalityType Type of gateway functionality type CreateGatewayRequestFunctionalityType string +// CreateGraphQLAPIRequest defines model for CreateGraphQLAPIRequest. +type CreateGraphQLAPIRequest struct { + // Context Base path for the single GraphQL endpoint. Suggested (not enforced) + // convention: end the path with `/graphql`, matching how most standalone + // GraphQL servers name their single endpoint — this is not validated. + Context string `binding:"required" json:"context" yaml:"context"` + CreatedAt *time.Time `json:"createdAt,omitempty" yaml:"createdAt,omitempty"` + CreatedBy *string `json:"createdBy,omitempty" yaml:"createdBy,omitempty"` + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + + // DisplayName Human-readable name for the API + DisplayName string `binding:"required" json:"displayName" yaml:"displayName"` + + // Id Unique handle/identifier for the API. Can be provided during creation or auto-generated. On update (PUT), if provided must match the path parameter — returns 400 if they differ. + Id *string `json:"id,omitempty" yaml:"id,omitempty"` + + // IntrospectionMode How `sdl` was obtained. SDL = supplied directly in the create/update + // request. ENDPOINT = derived by introspecting `upstream.main.url` at + // creation time. Informational only — storage and downstream behavior are + // identical either way. + IntrospectionMode *GraphQLIntrospectionMode `json:"introspectionMode,omitempty" yaml:"introspectionMode,omitempty"` + + // Kind Kind of the API based on its communication protocol or architectural style + Kind *string `json:"kind,omitempty" yaml:"kind,omitempty"` + + // Policies List of policies to be applied on the API. Reused unmodified from + // REST APIs. A `cors` policy applies only to the API's single `POST` + // route — a GraphQL API has no per-operation list to add an + // `OPTIONS` entry to, so a browser preflight request is not routed + // at all and a `cors` policy will not run for it; cross-origin + // browser clients that trigger a preflight are not currently + // supported. + Policies *[]Policy `json:"policies,omitempty" yaml:"policies,omitempty"` + ProjectId string `binding:"required" json:"projectId" yaml:"projectId"` + + // ReadOnly True if the artifact originated from a data-plane gateway (origin gateway_api) and is read-only in the control plane. + ReadOnly *bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` + + // SchemaSource Declares how the schema is being supplied, so the server validates + // against stated intent instead of guessing it from which fields happen + // to be populated. `inline` requires `sdl`; `url` requires `sdlUrl`; + // `file` requires the `sdlFile` multipart part (see + // GraphQLAPIMultipartRequest); `introspection` (the default) requires a + // literal `upstream.main.url` and derives the schema by querying it. + // Only the field matching the declared source may be present — a + // mismatch (wrong field populated, nothing populated, more than one + // populated) is a `400` (`VALIDATION_FAILED`), not a silent + // fall-through to a different resolution path. Schema *resolution* is + // separate and best-effort: a failure to actually resolve (bad SDL, + // unreachable URL, introspection failing) never fails the request — + // see `sdl` below. + SchemaSource *CreateGraphQLAPIRequestSchemaSource `json:"schemaSource,omitempty" yaml:"schemaSource,omitempty"` + + // Sdl The GraphQL schema in SDL form — resolved per `schemaSource`, from a + // directly-supplied document (`inline`/`file`), fetched from `sdlUrl` + // (`url`), or derived from `upstream.main.url` (`introspection`). Always + // the *resolved* schema, never a document-supplied schema-location + // reference. Optional in practice: if resolution fails, the API is still + // created/updated and this is left empty (create) or unchanged from its + // previous value (update) rather than the request failing — see + // `schemaSource`. + Sdl *string `json:"sdl,omitempty" yaml:"sdl,omitempty"` + + // SdlUrl A URL to a raw SDL document to fetch and use as `sdl` when + // `schemaSource` is `url` — the write-side counterpart to how an OpenAPI + // document can be supplied by reference for other artifact kinds (see + // LlmProviderTemplate's `metadata.openapiSpecUrl`). Distinct from + // `upstream.main.url`: this is a plain HTTP(S) GET of a static schema + // file, not a live introspection query against a GraphQL server, and is + // fetched through the same shared SSRF-guarded HTTP client every other + // operator/tenant-supplied fetch in this API uses, under the operator- + // configured policy (default `netguard.PermitPrivateBlockMetadata()`): the + // host is resolved and every candidate IP — including each redirect hop — + // is checked at dial time, refusing link-local/metadata/unspecified/ + // multicast addresses while private and in-cluster addresses (a Kubernetes + // ClusterIP, a service-DNS name, localhost) remain reachable. Never stored + // or echoed back; only the fetched `sdl` text is persisted and returned. + SdlUrl *string `json:"sdlUrl,omitempty" yaml:"sdlUrl,omitempty"` + + // SubscriptionPlans List of subscription plan names enabled for this API. + SubscriptionPlans *[]string `json:"subscriptionPlans,omitempty" yaml:"subscriptionPlans,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` + + // UpdatedBy Only present in the detail response (GET /graphql-apis/{graphqlApiId}), omitted from list responses. + UpdatedBy *string `json:"updatedBy,omitempty" yaml:"updatedBy,omitempty"` + + // Upstream Upstream backend configuration with main and sandbox endpoints + Upstream Upstream `json:"upstream" yaml:"upstream"` + Version string `binding:"required" json:"version" yaml:"version"` +} + +// CreateGraphQLAPIRequestSchemaSource Declares how the schema is being supplied, so the server validates +// against stated intent instead of guessing it from which fields happen +// to be populated. `inline` requires `sdl`; `url` requires `sdlUrl`; +// `file` requires the `sdlFile` multipart part (see +// GraphQLAPIMultipartRequest); `introspection` (the default) requires a +// literal `upstream.main.url` and derives the schema by querying it. +// Only the field matching the declared source may be present — a +// mismatch (wrong field populated, nothing populated, more than one +// populated) is a `400` (`VALIDATION_FAILED`), not a silent +// fall-through to a different resolution path. Schema *resolution* is +// separate and best-effort: a failure to actually resolve (bad SDL, +// unreachable URL, introspection failing) never fails the request — +// see `sdl` below. +type CreateGraphQLAPIRequestSchemaSource string + // CreateLLMProviderAPIKeyRequest defines model for CreateLLMProviderAPIKeyRequest. type CreateLLMProviderAPIKeyRequest struct { // AllowedTargets Comma-separated list of gateways this key is valid for. @@ -1455,6 +1613,213 @@ type GatewayTokenListResponse struct { Pagination Pagination `json:"pagination" yaml:"pagination"` } +// GraphQLAPI defines model for GraphQLAPI. +type GraphQLAPI struct { + // Context Base path for the single GraphQL endpoint. Suggested (not enforced) + // convention: end the path with `/graphql`, matching how most standalone + // GraphQL servers name their single endpoint — this is not validated. + Context string `binding:"required" json:"context" yaml:"context"` + CreatedAt *time.Time `json:"createdAt,omitempty" yaml:"createdAt,omitempty"` + CreatedBy *string `json:"createdBy,omitempty" yaml:"createdBy,omitempty"` + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + + // DisplayName Human-readable name for the API + DisplayName string `binding:"required" json:"displayName" yaml:"displayName"` + + // Id Unique handle/identifier for the API. Can be provided during creation or auto-generated. On update (PUT), if provided must match the path parameter — returns 400 if they differ. + Id *string `json:"id,omitempty" yaml:"id,omitempty"` + + // IntrospectionMode How `sdl` was obtained. SDL = supplied directly in the create/update + // request. ENDPOINT = derived by introspecting `upstream.main.url` at + // creation time. Informational only — storage and downstream behavior are + // identical either way. + IntrospectionMode *GraphQLIntrospectionMode `json:"introspectionMode,omitempty" yaml:"introspectionMode,omitempty"` + + // Kind Kind of the API based on its communication protocol or architectural style + Kind *string `json:"kind,omitempty" yaml:"kind,omitempty"` + + // Policies List of policies to be applied on the API. Reused unmodified from + // REST APIs. A `cors` policy applies only to the API's single `POST` + // route — a GraphQL API has no per-operation list to add an + // `OPTIONS` entry to, so a browser preflight request is not routed + // at all and a `cors` policy will not run for it; cross-origin + // browser clients that trigger a preflight are not currently + // supported. + Policies *[]Policy `json:"policies,omitempty" yaml:"policies,omitempty"` + ProjectId string `binding:"required" json:"projectId" yaml:"projectId"` + + // ReadOnly True if the artifact originated from a data-plane gateway (origin gateway_api) and is read-only in the control plane. + ReadOnly *bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` + + // SchemaSource Declares how the schema is being supplied, so the server validates + // against stated intent instead of guessing it from which fields happen + // to be populated. `inline` requires `sdl`; `url` requires `sdlUrl`; + // `file` requires the `sdlFile` multipart part (see + // GraphQLAPIMultipartRequest); `introspection` (the default) requires a + // literal `upstream.main.url` and derives the schema by querying it. + // Only the field matching the declared source may be present — a + // mismatch (wrong field populated, nothing populated, more than one + // populated) is a `400` (`VALIDATION_FAILED`), not a silent + // fall-through to a different resolution path. Schema *resolution* is + // separate and best-effort: a failure to actually resolve (bad SDL, + // unreachable URL, introspection failing) never fails the request — + // see `sdl` below. + SchemaSource *GraphQLAPISchemaSource `json:"schemaSource,omitempty" yaml:"schemaSource,omitempty"` + + // Sdl The GraphQL schema in SDL form — resolved per `schemaSource`, from a + // directly-supplied document (`inline`/`file`), fetched from `sdlUrl` + // (`url`), or derived from `upstream.main.url` (`introspection`). Always + // the *resolved* schema, never a document-supplied schema-location + // reference. Optional in practice: if resolution fails, the API is still + // created/updated and this is left empty (create) or unchanged from its + // previous value (update) rather than the request failing — see + // `schemaSource`. + Sdl *string `json:"sdl,omitempty" yaml:"sdl,omitempty"` + + // SdlUrl A URL to a raw SDL document to fetch and use as `sdl` when + // `schemaSource` is `url` — the write-side counterpart to how an OpenAPI + // document can be supplied by reference for other artifact kinds (see + // LlmProviderTemplate's `metadata.openapiSpecUrl`). Distinct from + // `upstream.main.url`: this is a plain HTTP(S) GET of a static schema + // file, not a live introspection query against a GraphQL server, and is + // fetched through the same shared SSRF-guarded HTTP client every other + // operator/tenant-supplied fetch in this API uses, under the operator- + // configured policy (default `netguard.PermitPrivateBlockMetadata()`): the + // host is resolved and every candidate IP — including each redirect hop — + // is checked at dial time, refusing link-local/metadata/unspecified/ + // multicast addresses while private and in-cluster addresses (a Kubernetes + // ClusterIP, a service-DNS name, localhost) remain reachable. Never stored + // or echoed back; only the fetched `sdl` text is persisted and returned. + SdlUrl *string `json:"sdlUrl,omitempty" yaml:"sdlUrl,omitempty"` + + // SubscriptionPlans List of subscription plan names enabled for this API. + SubscriptionPlans *[]string `json:"subscriptionPlans,omitempty" yaml:"subscriptionPlans,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` + + // UpdatedBy Only present in the detail response (GET /graphql-apis/{graphqlApiId}), omitted from list responses. + UpdatedBy *string `json:"updatedBy,omitempty" yaml:"updatedBy,omitempty"` + + // Upstream Upstream backend configuration with main and sandbox endpoints + Upstream Upstream `json:"upstream" yaml:"upstream"` + Version string `binding:"required" json:"version" yaml:"version"` +} + +// GraphQLAPISchemaSource Declares how the schema is being supplied, so the server validates +// against stated intent instead of guessing it from which fields happen +// to be populated. `inline` requires `sdl`; `url` requires `sdlUrl`; +// `file` requires the `sdlFile` multipart part (see +// GraphQLAPIMultipartRequest); `introspection` (the default) requires a +// literal `upstream.main.url` and derives the schema by querying it. +// Only the field matching the declared source may be present — a +// mismatch (wrong field populated, nothing populated, more than one +// populated) is a `400` (`VALIDATION_FAILED`), not a silent +// fall-through to a different resolution path. Schema *resolution* is +// separate and best-effort: a failure to actually resolve (bad SDL, +// unreachable URL, introspection failing) never fails the request — +// see `sdl` below. +type GraphQLAPISchemaSource string + +// GraphQLAPIDetail defines model for GraphQLAPIDetail. +type GraphQLAPIDetail struct { + // Context Base path for the single GraphQL endpoint. Suggested (not enforced) + // convention: end the path with `/graphql`, matching how most standalone + // GraphQL servers name their single endpoint — this is not validated. + Context string `binding:"required" json:"context" yaml:"context"` + CreatedAt *time.Time `json:"createdAt,omitempty" yaml:"createdAt,omitempty"` + CreatedBy *string `json:"createdBy,omitempty" yaml:"createdBy,omitempty"` + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + + // DisplayName Human-readable name for the API + DisplayName string `binding:"required" json:"displayName" yaml:"displayName"` + + // Id Unique handle/identifier for the API. + Id *string `json:"id,omitempty" yaml:"id,omitempty"` + + // IntrospectionMode How the schema was obtained. SDL = supplied directly in the create/update + // request. ENDPOINT = derived by introspecting `upstream.main.url` at + // creation time. Informational only — storage and downstream behavior are + // identical either way. + IntrospectionMode *GraphQLIntrospectionMode `json:"introspectionMode,omitempty" yaml:"introspectionMode,omitempty"` + + // Kind Kind of the API based on its communication protocol or architectural style + Kind *string `json:"kind,omitempty" yaml:"kind,omitempty"` + + // Policies List of policies to be applied on the API. Reused unmodified from + // REST APIs. A `cors` policy applies only to the API's single `POST` + // route — a GraphQL API has no per-operation list to add an + // `OPTIONS` entry to, so a browser preflight request is not routed + // at all and a `cors` policy will not run for it; cross-origin + // browser clients that trigger a preflight are not currently + // supported. + Policies *[]Policy `json:"policies,omitempty" yaml:"policies,omitempty"` + ProjectId string `binding:"required" json:"projectId" yaml:"projectId"` + + // ReadOnly True if the artifact originated from a data-plane gateway (origin gateway_api) and is read-only in the control plane. + ReadOnly *bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` + + // SubscriptionPlans List of subscription plan names enabled for this API. + SubscriptionPlans *[]string `json:"subscriptionPlans,omitempty" yaml:"subscriptionPlans,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` + UpdatedBy *string `json:"updatedBy,omitempty" yaml:"updatedBy,omitempty"` + + // Upstream Upstream backend configuration with main and sandbox endpoints + Upstream Upstream `json:"upstream" yaml:"upstream"` + Version string `binding:"required" json:"version" yaml:"version"` +} + +// GraphQLAPIListItem defines model for GraphQLAPIListItem. +type GraphQLAPIListItem struct { + Context string `binding:"required" json:"context" yaml:"context"` + CreatedAt *time.Time `json:"createdAt,omitempty" yaml:"createdAt,omitempty"` + CreatedBy *string `json:"createdBy,omitempty" yaml:"createdBy,omitempty"` + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + DisplayName string `binding:"required" json:"displayName" yaml:"displayName"` + Id *string `json:"id,omitempty" yaml:"id,omitempty"` + IntrospectionMode *GraphQLIntrospectionMode `json:"introspectionMode,omitempty" yaml:"introspectionMode,omitempty"` + Kind *string `json:"kind,omitempty" yaml:"kind,omitempty"` + ProjectId string `binding:"required" json:"projectId" yaml:"projectId"` + ReadOnly *bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` + + // Upstream Upstream backend configuration with main and sandbox endpoints + Upstream *Upstream `json:"upstream,omitempty" yaml:"upstream,omitempty"` + Version string `binding:"required" json:"version" yaml:"version"` +} + +// GraphQLAPIListResponse defines model for GraphQLAPIListResponse. +type GraphQLAPIListResponse struct { + Count int `binding:"required" json:"count" yaml:"count"` + List []GraphQLAPIListItem `binding:"required" json:"list" yaml:"list"` + Pagination Pagination `json:"pagination" yaml:"pagination"` +} + +// GraphQLAPIMultipartRequest defines model for GraphQLAPIMultipartRequest. +type GraphQLAPIMultipartRequest struct { + // Metadata JSON-encoded request body — CreateGraphQLAPIRequest fields for create, + // GraphQLAPI fields for update, including `schemaSource`. When + // `schemaSource` is `file`, the `sdlFile` part below is required and any + // `sdl`/`sdlUrl` in this metadata is a structural-validation error, not a + // silent override — every schema-source variant is expressed + // consistently through the `schemaSource` field rather than by which + // part happens to be present. + Metadata string `binding:"required" json:"metadata" yaml:"metadata"` + + // SdlFile The GraphQL SDL document as a file upload (e.g. schema.graphql). + // Required when `schemaSource` is `file`; must be omitted otherwise. + SdlFile *openapi_types.File `json:"sdlFile,omitempty" yaml:"sdlFile,omitempty"` +} + +// GraphQLAPISDLResponse defines model for GraphQLAPISDLResponse. +type GraphQLAPISDLResponse struct { + // Sdl The GraphQL schema in SDL form, resolved at create/update time (either + // supplied directly or derived via upstream introspection) — see + // `GET /graphql-apis/{graphqlApiId}` for the rest of the API's metadata. + Sdl string `binding:"required" json:"sdl" yaml:"sdl"` +} + +// GraphQLIntrospectionMode defines model for GraphQLIntrospectionMode. +type GraphQLIntrospectionMode string + // ImportOpenAPIRequest defines model for ImportOpenAPIRequest. type ImportOpenAPIRequest struct { Context string `binding:"required" json:"context" yaml:"context"` @@ -3128,6 +3493,55 @@ type UserAPIKeyListResponse struct { Pagination Pagination `json:"pagination" yaml:"pagination"` } +// ValidateGraphQLSchemaMultipartRequest defines model for ValidateGraphQLSchemaMultipartRequest. +type ValidateGraphQLSchemaMultipartRequest struct { + // Metadata JSON-encoded ValidateGraphQLSchemaRequest. + Metadata string `binding:"required" json:"metadata" yaml:"metadata"` + + // SdlFile The GraphQL SDL document as a file upload. Required when + // `schemaSource` is `file`; must be omitted otherwise. + SdlFile *openapi_types.File `json:"sdlFile,omitempty" yaml:"sdlFile,omitempty"` +} + +// ValidateGraphQLSchemaRequest defines model for ValidateGraphQLSchemaRequest. +type ValidateGraphQLSchemaRequest struct { + // SchemaSource Same semantics as `GraphQLAPI.schemaSource` — declares which of + // `sdl`/`sdlUrl`/the `sdlFile` multipart part/`upstream.main.url` + // supplies the schema to resolve. + SchemaSource *ValidateGraphQLSchemaRequestSchemaSource `json:"schemaSource,omitempty" yaml:"schemaSource,omitempty"` + + // Sdl The GraphQL schema in SDL form, when `schemaSource` is `inline` (or the uploaded file's content, when `file`). + Sdl *string `json:"sdl,omitempty" yaml:"sdl,omitempty"` + + // SdlUrl A URL to fetch the SDL from, when `schemaSource` is `url`. + SdlUrl *string `json:"sdlUrl,omitempty" yaml:"sdlUrl,omitempty"` + + // Upstream Upstream backend configuration with main and sandbox endpoints + Upstream *Upstream `json:"upstream,omitempty" yaml:"upstream,omitempty"` +} + +// ValidateGraphQLSchemaRequestSchemaSource Same semantics as `GraphQLAPI.schemaSource` — declares which of +// `sdl`/`sdlUrl`/the `sdlFile` multipart part/`upstream.main.url` +// supplies the schema to resolve. +type ValidateGraphQLSchemaRequestSchemaSource string + +// ValidateGraphQLSchemaResponse defines model for ValidateGraphQLSchemaResponse. +type ValidateGraphQLSchemaResponse struct { + IntrospectionMode *GraphQLIntrospectionMode `json:"introspectionMode,omitempty" yaml:"introspectionMode,omitempty"` + + // Message A generic explanation, set only when `resolved` is `false`. Never + // the specific parser/fetch/introspection failure reason — reuses + // the same sterile message `GraphQLAPISchemaResolveFailed` uses + // elsewhere (`error-handling.md`). + Message *string `json:"message,omitempty" yaml:"message,omitempty"` + + // Resolved Whether the declared schemaSource actually resolved to a usable schema. + Resolved bool `binding:"required" json:"resolved" yaml:"resolved"` + + // Sdl The resolved SDL text when `resolved` is `true`; empty otherwise. + Sdl string `binding:"required" json:"sdl" yaml:"sdl"` +} + // ValidateOpenAPIResponse defines model for ValidateOpenAPIResponse. type ValidateOpenAPIResponse struct { // Errors Validation errors; empty when isValid is true @@ -3425,6 +3839,75 @@ type ListGatewayTokensParams struct { Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } +// ListGraphQLAPIsParams defines parameters for ListGraphQLAPIs. +type ListGraphQLAPIsParams struct { + // ProjectId **Project ID** consisting of the **handle** (unique slug identifier) of the Project whose resources should be returned. + ProjectId ProjectIdQ `form:"projectId" json:"projectId" yaml:"projectId"` + + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + + // SortBy Field to sort the collection by. An unrecognized value falls back to the default sort (createdAt). + SortBy *ListGraphQLAPIsParamsSortBy `form:"sortBy,omitempty" json:"sortBy,omitempty" yaml:"sortBy,omitempty"` + + // SortOrder Sort direction applied to `sortBy`. + SortOrder *ListGraphQLAPIsParamsSortOrder `form:"sortOrder,omitempty" json:"sortOrder,omitempty" yaml:"sortOrder,omitempty"` + + // Query Case-insensitive substring filter matched against the resource display name and id (handle). + Query *QueryQ `form:"query,omitempty" json:"query,omitempty" yaml:"query,omitempty"` +} + +// ListGraphQLAPIsParamsSortBy defines parameters for ListGraphQLAPIs. +type ListGraphQLAPIsParamsSortBy string + +// ListGraphQLAPIsParamsSortOrder defines parameters for ListGraphQLAPIs. +type ListGraphQLAPIsParamsSortOrder string + +// GetGraphQLAPIDeploymentsParams defines parameters for GetGraphQLAPIDeployments. +type GetGraphQLAPIDeploymentsParams struct { + // GatewayId **Gateway ID** consisting of the **handle** (unique slug identifier) of the Gateway to filter status by. + GatewayId *GatewayIdQ `form:"gatewayId,omitempty" json:"gatewayId,omitempty" yaml:"gatewayId,omitempty"` + + // Status Filter deployments by status (DEPLOYED, UNDEPLOYED, DEPLOYING, UNDEPLOYING, FAILED, or ARCHIVED) + Status *GetGraphQLAPIDeploymentsParamsStatus `form:"status,omitempty" json:"status,omitempty" yaml:"status,omitempty"` + + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` +} + +// GetGraphQLAPIDeploymentsParamsStatus defines parameters for GetGraphQLAPIDeployments. +type GetGraphQLAPIDeploymentsParamsStatus string + +// RestoreGraphQLAPIDeploymentParams defines parameters for RestoreGraphQLAPIDeployment. +type RestoreGraphQLAPIDeploymentParams struct { + // GatewayId Handle (URL-friendly slug) of the gateway (validated against deployment's bound gateway) + GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` +} + +// UndeployGraphQLAPIDeploymentParams defines parameters for UndeployGraphQLAPIDeployment. +type UndeployGraphQLAPIDeploymentParams struct { + // GatewayId Handle (URL-friendly slug) of the gateway (validated against deployment's bound gateway) + GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` +} + +// GetGraphQLAPIGatewaysParams defines parameters for GetGraphQLAPIGateways. +type GetGraphQLAPIGatewaysParams struct { + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` +} + +// AddGatewaysToGraphQLAPIJSONBody defines parameters for AddGatewaysToGraphQLAPI. +type AddGatewaysToGraphQLAPIJSONBody = []AddGatewayToRESTAPIRequest + // ListLLMProviderTemplatesParams defines parameters for ListLLMProviderTemplates. type ListLLMProviderTemplatesParams struct { // Query URL-encoded search DSL. `query=latest:true` lists only the latest version of each family; `query=groupId:` lists that family's versions; adding `&version:` returns the single full template for that version. Terms are `&`-separated `key:value` pairs and the whole value is percent-encoded (e.g. groupId%3Awso2-openai%26version%3Av2.0). @@ -3837,6 +4320,27 @@ type CreateGatewayJSONRequestBody = CreateGatewayRequest // UpdateGatewayJSONRequestBody defines body for UpdateGateway for application/json ContentType. type UpdateGatewayJSONRequestBody = GatewayResponse +// CreateGraphQLAPIMultipartRequestBody defines body for CreateGraphQLAPI for multipart/form-data ContentType. +type CreateGraphQLAPIMultipartRequestBody = GraphQLAPIMultipartRequest + +// ValidateGraphQLSchemaMultipartRequestBody defines body for ValidateGraphQLSchema for multipart/form-data ContentType. +type ValidateGraphQLSchemaMultipartRequestBody = ValidateGraphQLSchemaMultipartRequest + +// UpdateGraphQLAPIMultipartRequestBody defines body for UpdateGraphQLAPI for multipart/form-data ContentType. +type UpdateGraphQLAPIMultipartRequestBody = GraphQLAPIMultipartRequest + +// CreateGraphQLAPIKeyJSONRequestBody defines body for CreateGraphQLAPIKey for application/json ContentType. +type CreateGraphQLAPIKeyJSONRequestBody = CreateAPIKeyRequest + +// UpdateGraphQLAPIKeyJSONRequestBody defines body for UpdateGraphQLAPIKey for application/json ContentType. +type UpdateGraphQLAPIKeyJSONRequestBody = UpdateAPIKeyRequest + +// DeployGraphQLAPIJSONRequestBody defines body for DeployGraphQLAPI for application/json ContentType. +type DeployGraphQLAPIJSONRequestBody = DeployRequest + +// AddGatewaysToGraphQLAPIJSONRequestBody defines body for AddGatewaysToGraphQLAPI for application/json ContentType. +type AddGatewaysToGraphQLAPIJSONRequestBody = AddGatewaysToGraphQLAPIJSONBody + // CreateLLMProviderTemplateJSONRequestBody defines body for CreateLLMProviderTemplate for application/json ContentType. type CreateLLMProviderTemplateJSONRequestBody = LLMProviderTemplate diff --git a/platform-api/config/default_config.go b/platform-api/config/default_config.go index 5e7d439d51..447256c16d 100644 --- a/platform-api/config/default_config.go +++ b/platform-api/config/default_config.go @@ -65,6 +65,7 @@ func defaultConfig() *Server { "/api/internal/v1/llm-proxies", "/api/internal/v1/subscription-plans", "/api/internal/v1/mcp-proxies", + "/api/internal/v1/graphql-apis", "/api/internal/v1/gateways", "/api/internal/v1/deployments", "/api/internal/v1/artifacts", diff --git a/platform-api/go.mod b/platform-api/go.mod index 1372944931..3eb38825ed 100644 --- a/platform-api/go.mod +++ b/platform-api/go.mod @@ -19,6 +19,7 @@ require ( github.com/pb33f/libopenapi v0.38.7 github.com/pb33f/libopenapi-validator v0.14.0 github.com/stretchr/testify v1.12.1 + github.com/vektah/gqlparser/v2 v2.5.36 github.com/wso2/api-platform/common v0.0.0-00010101000000-000000000000 github.com/wso2/api-platform/httpkit v0.0.0-local golang.org/x/crypto v0.57.0 @@ -29,6 +30,7 @@ require ( require ( github.com/MicahParks/jwkset v0.11.0 // indirect github.com/MicahParks/keyfunc/v3 v3.7.0 // indirect + github.com/agnivade/levenshtein v1.2.1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad // indirect diff --git a/platform-api/go.sum b/platform-api/go.sum index 238da3de8b..283ad985ae 100644 --- a/platform-api/go.sum +++ b/platform-api/go.sum @@ -17,8 +17,12 @@ github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7 github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3kITJGM= github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad h1:3swAvbzgfaI6nKuDDU7BiKfZRdF+h2ZwKgMHd8Ha4t8= @@ -31,6 +35,8 @@ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx2 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -137,6 +143,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/vektah/gqlparser/v2 v2.5.36 h1:CN9mKVHgMkc+XftdOWIhb4HEL8wKSYkFAqhf8booa7s= +github.com/vektah/gqlparser/v2 v2.5.36/go.mod h1:cAJ9qwVgPaUkWv6Gn8vn0mqOE0Ui5Pn56wNy5396XWo= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= diff --git a/platform-api/internal/apperror/catalog.go b/platform-api/internal/apperror/catalog.go index 55a1767b1d..42d61e0c2b 100644 --- a/platform-api/internal/apperror/catalog.go +++ b/platform-api/internal/apperror/catalog.go @@ -214,6 +214,19 @@ var ( WebBrokerAPIExists = def(CodeWebBrokerAPIExists, http.StatusConflict, "A WebBroker API with this ID already exists.") ) +// GraphQL API entries (GraphQL is a core artifact kind, not a plugin). +// GraphQLAPISchemaResolveFailed is the generic 422 for both "introspection +// against upstream.main.url failed" and +// "the supplied SDL could not be parsed" — the message never echoes the resolved +// IP, the parser's internal error text, or which specific reason applied +// (error-handling.md / ssrf-prevention.md). +var ( + GraphQLAPINotFound = def(CodeGraphQLAPINotFound, http.StatusNotFound, "The specified GraphQL API could not be found.") + GraphQLAPIExists = def(CodeGraphQLAPIExists, http.StatusConflict, "A GraphQL API with this ID already exists.") + GraphQLAPISchemaResolveFailed = def(CodeGraphQLAPISchemaResolveFailed, http.StatusUnprocessableEntity, "The provided endpoint could not be used to derive a GraphQL schema, or the supplied SDL could not be parsed.") + GraphQLAPIDeploymentValidationFailed = def(CodeGraphQLAPIDeploymentValidationFailed, http.StatusBadRequest, "%s") +) + // HMAC secret entries. The 32-character minimum is a fixed, publicly // documented rule, so stating it in the client message reveals nothing the // API contract does not already. diff --git a/platform-api/internal/apperror/catalog_test.go b/platform-api/internal/apperror/catalog_test.go index 37ec91402e..600a3718b2 100644 --- a/platform-api/internal/apperror/catalog_test.go +++ b/platform-api/internal/apperror/catalog_test.go @@ -43,6 +43,7 @@ var messageArity = map[string]int{ CodeOf(LLMProviderDeploymentValidationFailed): 1, CodeOf(LLMProxyDeploymentValidationFailed): 1, CodeOf(MCPProxyDeploymentValidationFailed): 1, + CodeOf(GraphQLAPIDeploymentValidationFailed): 1, CodeOf(DeploymentNotActive): 1, CodeOf(BuildLimitReached): 1, CodeOf(ArtifactReadOnly): 1, diff --git a/platform-api/internal/apperror/codes.go b/platform-api/internal/apperror/codes.go index 9d191ff682..0fdbfbdbaa 100644 --- a/platform-api/internal/apperror/codes.go +++ b/platform-api/internal/apperror/codes.go @@ -202,6 +202,14 @@ const ( CodeWebBrokerAPIExists = "WEBBROKER_API_EXISTS" ) +// GraphQL API domain codes (GraphQL is a core artifact kind, not a plugin). +const ( + CodeGraphQLAPINotFound = "GRAPHQL_API_NOT_FOUND" + CodeGraphQLAPIExists = "GRAPHQL_API_EXISTS" + CodeGraphQLAPISchemaResolveFailed = "GRAPHQL_API_SCHEMA_RESOLVE_FAILED" + CodeGraphQLAPIDeploymentValidationFailed = "GRAPHQL_API_DEPLOYMENT_VALIDATION_FAILED" +) + // HMAC secret domain codes (WebSub subscriber callback signing secrets). // HMAC_SECRET_NOT_CONFIGURED is a 503 rather than a 500: the encryption key is // a deployment-time setting, so the condition is transient from the client's diff --git a/platform-api/internal/constants/constants.go b/platform-api/internal/constants/constants.go index bfe74ec171..39c607baec 100644 --- a/platform-api/internal/constants/constants.go +++ b/platform-api/internal/constants/constants.go @@ -81,6 +81,11 @@ const ( LLMProviderTemplate = "LlmProviderTemplate" LLMProxy = "LlmProxy" MCPProxy = "Mcp" + // GraphQLApi is a core artifact kind (like RestApi/LlmProvider/LlmProxy/Mcp) — + // always compiled in, pre-seeded in NewArtifactTableRegistry(), no build tag. + // This is a different axis from the APITypeGraphQL/APISubTypeGraphQL dev-portal + // content-type constants below — do not conflate the two. + GraphQLApi = "GraphQLApi" ) // Artifact origin values. Origin distinguishes control-plane created artifacts @@ -232,6 +237,7 @@ var ValidArtifactKinds = map[string]bool{ LLMProvider: true, LLMProxy: true, MCPProxy: true, + GraphQLApi: true, } // Throttle limit unit constants diff --git a/platform-api/internal/database/schema.postgres.sql b/platform-api/internal/database/schema.postgres.sql index 0eb5480641..dee0f053d1 100644 --- a/platform-api/internal/database/schema.postgres.sql +++ b/platform-api/internal/database/schema.postgres.sql @@ -597,6 +597,28 @@ CREATE TABLE IF NOT EXISTS mcp_proxies ( UNIQUE(organization_uuid, handle) ); +-- GraphQL APIs table (core kind, same shape as rest_apis minus operations/channels) +CREATE TABLE IF NOT EXISTS graphql_apis ( + uuid VARCHAR(40) PRIMARY KEY, + organization_uuid VARCHAR(40) NOT NULL, + handle VARCHAR(40) NOT NULL, + display_name VARCHAR(255) NOT NULL, + version VARCHAR(30) NOT NULL DEFAULT 'v1.0', + project_uuid VARCHAR(40) NOT NULL, + description VARCHAR(1023), + configuration BYTEA NOT NULL, + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + origin VARCHAR(20) NOT NULL DEFAULT 'control_plane', + created_by VARCHAR(200), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(200), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, + FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, + FOREIGN KEY (project_uuid) REFERENCES projects(uuid) ON DELETE CASCADE, + UNIQUE(organization_uuid, handle) +); + CREATE TABLE IF NOT EXISTS api_keys ( uuid VARCHAR(40) PRIMARY KEY, artifact_uuid VARCHAR(40) NOT NULL, @@ -671,6 +693,8 @@ CREATE INDEX IF NOT EXISTS idx_llm_proxies_org ON llm_proxies(organization_uuid) CREATE INDEX IF NOT EXISTS idx_mcp_proxies_project ON mcp_proxies(project_uuid); CREATE INDEX IF NOT EXISTS idx_mcp_proxies_org ON mcp_proxies(organization_uuid); CREATE INDEX IF NOT EXISTS idx_api_portals_org ON api_portals(organization_uuid); +CREATE INDEX IF NOT EXISTS idx_graphql_apis_project ON graphql_apis(project_uuid); +CREATE INDEX IF NOT EXISTS idx_graphql_apis_org ON graphql_apis(organization_uuid); CREATE INDEX IF NOT EXISTS idx_api_keys_artifact ON api_keys(artifact_uuid); CREATE INDEX IF NOT EXISTS idx_applications_org ON applications(organization_uuid); CREATE INDEX IF NOT EXISTS idx_applications_project_id ON applications(organization_uuid, project_uuid); diff --git a/platform-api/internal/database/schema.sql b/platform-api/internal/database/schema.sql index 191d95d97b..55a3c64066 100644 --- a/platform-api/internal/database/schema.sql +++ b/platform-api/internal/database/schema.sql @@ -410,6 +410,28 @@ CREATE TABLE IF NOT EXISTS mcp_proxies ( UNIQUE(organization_uuid, handle) ); +-- GraphQL APIs table (core kind, same shape as rest_apis minus operations/channels) +CREATE TABLE IF NOT EXISTS graphql_apis ( + uuid VARCHAR(40) PRIMARY KEY, + organization_uuid VARCHAR(40) NOT NULL, + handle VARCHAR(40) NOT NULL, + display_name VARCHAR(255) NOT NULL, + version VARCHAR(30) NOT NULL DEFAULT 'v1.0', + project_uuid VARCHAR(40) NOT NULL, + description VARCHAR(1023), + configuration BLOB NOT NULL, -- JSON: SDL + upstream + policies + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + origin VARCHAR(20) NOT NULL DEFAULT 'control_plane', + created_by VARCHAR(200), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(200), + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, + FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, + FOREIGN KEY (project_uuid) REFERENCES projects(uuid) ON DELETE CASCADE, + UNIQUE(organization_uuid, handle) +); + CREATE TABLE IF NOT EXISTS api_keys ( uuid VARCHAR(40) PRIMARY KEY, @@ -486,6 +508,8 @@ CREATE INDEX IF NOT EXISTS idx_llm_proxies_provider_uuid ON llm_proxies(provider CREATE INDEX IF NOT EXISTS idx_llm_proxies_org ON llm_proxies(organization_uuid); CREATE INDEX IF NOT EXISTS idx_mcp_proxies_project ON mcp_proxies(project_uuid); CREATE INDEX IF NOT EXISTS idx_mcp_proxies_org ON mcp_proxies(organization_uuid); +CREATE INDEX IF NOT EXISTS idx_graphql_apis_project ON graphql_apis(project_uuid); +CREATE INDEX IF NOT EXISTS idx_graphql_apis_org ON graphql_apis(organization_uuid); CREATE INDEX IF NOT EXISTS idx_api_keys_artifact ON api_keys(artifact_uuid); CREATE INDEX IF NOT EXISTS idx_rest_apis_org ON rest_apis(organization_uuid); CREATE INDEX IF NOT EXISTS idx_applications_org ON applications(organization_uuid); diff --git a/platform-api/internal/database/schema.sqlite.sql b/platform-api/internal/database/schema.sqlite.sql index 9eb5ab4908..87e679a9d4 100644 --- a/platform-api/internal/database/schema.sqlite.sql +++ b/platform-api/internal/database/schema.sqlite.sql @@ -599,6 +599,28 @@ CREATE TABLE IF NOT EXISTS mcp_proxies ( UNIQUE(organization_uuid, handle) ); +-- GraphQL APIs table (core kind, same shape as rest_apis minus operations/channels) +CREATE TABLE IF NOT EXISTS graphql_apis ( + uuid VARCHAR(40) PRIMARY KEY, + organization_uuid VARCHAR(40) NOT NULL, + handle VARCHAR(40) NOT NULL, + display_name VARCHAR(255) NOT NULL, + version VARCHAR(30) NOT NULL DEFAULT 'v1.0', + project_uuid VARCHAR(40) NOT NULL, + description VARCHAR(1023), + configuration BLOB NOT NULL, -- JSON: SDL + upstream + policies + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + origin VARCHAR(20) NOT NULL DEFAULT 'control_plane', + created_by VARCHAR(200), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(200), + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, + FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, + FOREIGN KEY (project_uuid) REFERENCES projects(uuid) ON DELETE CASCADE, + UNIQUE(organization_uuid, handle) +); + -- API Keys table (stores API keys for artifacts with hashes as JSON string) CREATE TABLE IF NOT EXISTS api_keys ( uuid VARCHAR(40) PRIMARY KEY, @@ -673,6 +695,8 @@ CREATE INDEX IF NOT EXISTS idx_llm_proxies_org ON llm_proxies(organization_uuid) CREATE INDEX IF NOT EXISTS idx_mcp_proxies_project ON mcp_proxies(project_uuid); CREATE INDEX IF NOT EXISTS idx_mcp_proxies_org ON mcp_proxies(organization_uuid); CREATE INDEX IF NOT EXISTS idx_api_portals_org ON api_portals(organization_uuid); +CREATE INDEX IF NOT EXISTS idx_graphql_apis_project ON graphql_apis(project_uuid); +CREATE INDEX IF NOT EXISTS idx_graphql_apis_org ON graphql_apis(organization_uuid); CREATE INDEX IF NOT EXISTS idx_api_keys_artifact ON api_keys(artifact_uuid); CREATE INDEX IF NOT EXISTS idx_rest_apis_org ON rest_apis(organization_uuid); CREATE INDEX IF NOT EXISTS idx_applications_org ON applications(organization_uuid); diff --git a/platform-api/internal/database/schema.sqlserver.sql b/platform-api/internal/database/schema.sqlserver.sql index f6ba87b355..da273e1da8 100644 --- a/platform-api/internal/database/schema.sqlserver.sql +++ b/platform-api/internal/database/schema.sqlserver.sql @@ -683,6 +683,30 @@ CREATE TABLE dbo.mcp_proxies ( UNIQUE(organization_uuid, handle) ); +-- GraphQL APIs table (core kind, same shape as rest_apis minus operations/channels) +IF OBJECT_ID(N'dbo.graphql_apis', N'U') IS NULL +CREATE TABLE dbo.graphql_apis ( + uuid VARCHAR(40) PRIMARY KEY, + organization_uuid VARCHAR(40) NOT NULL, + handle VARCHAR(40) NOT NULL, + display_name VARCHAR(255) NOT NULL, + version VARCHAR(30) NOT NULL DEFAULT 'v1.0', + project_uuid VARCHAR(40) NOT NULL, + description VARCHAR(1023), + configuration VARBINARY(MAX) NOT NULL, + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + origin VARCHAR(20) NOT NULL DEFAULT 'control_plane', + created_by VARCHAR(200), + created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), + updated_by VARCHAR(200), + updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), + FOREIGN KEY (uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, + -- NO ACTION to avoid SQL Server multiple-cascade-paths restriction (error 1785). + FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION, + FOREIGN KEY (project_uuid) REFERENCES projects(uuid) ON DELETE CASCADE, + UNIQUE(organization_uuid, handle) +); + IF OBJECT_ID(N'dbo.api_keys', N'U') IS NULL CREATE TABLE dbo.api_keys ( uuid VARCHAR(40) PRIMARY KEY, @@ -789,6 +813,10 @@ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_mcp_proxies_org' AND CREATE INDEX idx_mcp_proxies_org ON dbo.mcp_proxies(organization_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_portals_org' AND object_id = OBJECT_ID(N'dbo.api_portals')) CREATE INDEX idx_api_portals_org ON dbo.api_portals(organization_uuid); +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_graphql_apis_project' AND object_id = OBJECT_ID(N'dbo.graphql_apis')) +CREATE INDEX idx_graphql_apis_project ON dbo.graphql_apis(project_uuid); +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_graphql_apis_org' AND object_id = OBJECT_ID(N'dbo.graphql_apis')) +CREATE INDEX idx_graphql_apis_org ON dbo.graphql_apis(organization_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_keys_artifact' AND object_id = OBJECT_ID(N'dbo.api_keys')) CREATE INDEX idx_api_keys_artifact ON dbo.api_keys(artifact_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_keys_status' AND object_id = OBJECT_ID(N'dbo.api_keys')) diff --git a/platform-api/internal/dto/graphql_api.go b/platform-api/internal/dto/graphql_api.go new file mode 100644 index 0000000000..e2fafee176 --- /dev/null +++ b/platform-api/internal/dto/graphql_api.go @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package dto + +import "github.com/wso2/api-platform/platform-api/internal/model" + +// GraphQLAPIDeploymentYAML represents the GraphQL API deployment YAML structure +// pushed to the gateway-controller. Mirrors APIDeploymentYAML (api.go) in shape, +// substituting GraphQLAPIYAMLData for the spec section. +type GraphQLAPIDeploymentYAML struct { + ApiVersion string `yaml:"apiVersion" binding:"required"` + Kind string `yaml:"kind" binding:"required"` + Metadata DeploymentMetadata `yaml:"metadata" binding:"required"` + Spec GraphQLAPIYAMLData `yaml:"spec" binding:"required"` +} + +// GetApiVersion returns the artifact's CRD apiVersion. +func (d *GraphQLAPIDeploymentYAML) GetApiVersion() string { return d.ApiVersion } + +// SetApiVersion sets the artifact's CRD apiVersion. +func (d *GraphQLAPIDeploymentYAML) SetApiVersion(v string) { d.ApiVersion = v } + +// GraphQLAPIYAMLData represents the spec section of the GraphQL API deployment +// YAML. Deliberately absent compared to APIYAMLData: Operations/Channels — a +// GraphQL API has exactly one logical endpoint, not a per-resource/per-verb +// operation list. The schema itself is never sent to the gateway: it plays no +// role in routing (GraphQLAPITransformer always builds exactly one POST route, +// regardless of what queries/mutations exist), so platform-api keeps SDL as a +// CP-side onboarding/documentation concern (model.GraphQLAPIConfig.SDL, used by +// dev-portal's schema viewer) and never forwards it into this deployment shape. +type GraphQLAPIYAMLData struct { + DisplayName string `yaml:"displayName"` + Version string `yaml:"version"` + Context string `yaml:"context"` + SubscriptionPlans []string `yaml:"subscriptionPlans,omitempty"` + Upstream *GraphQLUpstream `yaml:"upstream,omitempty"` + Policies []Policy `yaml:"policies,omitempty"` +} + +// GraphQLUpstream represents the upstream configuration for the GraphQL API +// deployment YAML. Unlike RestAPI's per-operation upstream shape, a GraphQL +// API has exactly one logical endpoint per environment, but — like REST — it +// still supports an optional sandbox split alongside the main upstream (see +// GraphQLAPIConfigData.Upstream.Sandbox in the gateway's OpenAPI spec and +// GraphQLAPITransformer's sandbox route handling). +type GraphQLUpstream struct { + Main *GraphQLUpstreamTarget `yaml:"main,omitempty"` + Sandbox *GraphQLUpstreamTarget `yaml:"sandbox,omitempty"` +} + +// GraphQLUpstreamTarget represents the GraphQL upstream endpoint (url or ref), +// including auth. Unlike REST's UpstreamTarget (which has no Auth field, so +// upstream credentials are silently dropped from the deployment YAML), this +// type carries Auth from day one, matching MCP Proxy's +// BuildMCPDeploymentYAML (internal/utils/mcp.go). Auth is the raw model type +// (not the redacted api.UpstreamAuth used in read responses) because this YAML +// is what the gateway actually uses to authenticate to the upstream. +type GraphQLUpstreamTarget struct { + URL string `yaml:"url,omitempty"` + Ref string `yaml:"ref,omitempty"` + Auth *model.UpstreamAuth `yaml:"auth,omitempty"` +} diff --git a/platform-api/internal/gatewaytranslator/dataversion.go b/platform-api/internal/gatewaytranslator/dataversion.go index 62531d5cc1..74059d5162 100644 --- a/platform-api/internal/gatewaytranslator/dataversion.go +++ b/platform-api/internal/gatewaytranslator/dataversion.go @@ -87,6 +87,7 @@ var platformDataMinorVersions = map[string]int{ constants.WebSubApi: 0, constants.WebBrokerApi: 0, constants.MCPProxy: 0, + constants.GraphQLApi: 0, constants.LLMProxy: 1, constants.LLMProvider: 1, } diff --git a/platform-api/internal/handler/gateway_internal.go b/platform-api/internal/handler/gateway_internal.go index 98e7caa2af..5303958517 100644 --- a/platform-api/internal/handler/gateway_internal.go +++ b/platform-api/internal/handler/gateway_internal.go @@ -577,6 +577,63 @@ func (h *GatewayInternalAPIHandler) GetMCPProxy(w http.ResponseWriter, r *http.R _, _ = w.Write(zipData) } +// GetGraphQLAPI handles GET /api/internal/v1/graphql-apis/:apiId +func (h *GatewayInternalAPIHandler) GetGraphQLAPI(w http.ResponseWriter, r *http.Request) { + orgID, gatewayID, ok := h.authenticateRequest(w, r) + if !ok { + return + } + + apiID := r.PathValue("apiId") + if apiID == "" { + httputil.WriteJSON(w, http.StatusBadRequest, dto.NewInternalErrorResponse(400, "Bad Request", + "API ID is required")) + return + } + + api, err := h.gatewayInternalService.GetActiveGraphQLAPIDeploymentByGateway(apiID, orgID, gatewayID) + if err != nil { + clientIP := r.RemoteAddr + if i := strings.LastIndex(clientIP, ":"); i != -1 { + clientIP = clientIP[:i] + } + if apperror.DeploymentNotActive.Is(err) { + h.slogger.Error("No active deployment found for GraphQL API", "clientIP", clientIP, "apiID", apiID, "orgID", orgID, "gatewayID", gatewayID, "error", err) + httputil.WriteJSON(w, http.StatusNotFound, dto.NewInternalErrorResponse(404, "Not Found", + "No active deployment found for this GraphQL API on this gateway")) + return + } + if apperror.GraphQLAPINotFound.Is(err) { + h.slogger.Error("GraphQL API not found", "clientIP", clientIP, "apiID", apiID, "orgID", orgID, "gatewayID", gatewayID, "error", err) + httputil.WriteJSON(w, http.StatusNotFound, dto.NewInternalErrorResponse(404, "Not Found", + "GraphQL API not found")) + return + } + h.slogger.Error("Failed to get GraphQL API", "clientIP", clientIP, "apiID", apiID, "orgID", orgID, "gatewayID", gatewayID, "error", err) + httputil.WriteJSON(w, http.StatusInternalServerError, dto.NewInternalErrorResponse(500, "Internal Server Error", + "Failed to get GraphQL API")) + return + } + + // Create ZIP file from GraphQL API YAML file + zipData, err := utils.CreateGraphQLAPIYamlZip(api) + if err != nil { + h.slogger.Error("Failed to create ZIP file", "apiID", apiID, "error", err) + httputil.WriteJSON(w, http.StatusInternalServerError, dto.NewInternalErrorResponse(500, "Internal Server Error", + "Failed to create GraphQL API package")) + return + } + + // Set headers for ZIP file download + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"graphql-api-%s.zip\"", apiID)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(zipData))) + + // Return ZIP file + w.WriteHeader(http.StatusOK) + _, _ = w.Write(zipData) +} + // GetWebSubAPI handles GET /api/internal/v1/websub-apis/:apiId func (h *GatewayInternalAPIHandler) GetWebSubAPI(w http.ResponseWriter, r *http.Request) { orgID, gatewayID, ok := h.authenticateRequest(w, r) @@ -809,6 +866,22 @@ func (h *GatewayInternalAPIHandler) GetWebBrokerAPIAPIKeys(w http.ResponseWriter httputil.WriteJSON(w, http.StatusOK, keys) } +// GetGraphQLAPIAPIKeys handles GET /api/internal/v1/graphql-apis/api-keys +func (h *GatewayInternalAPIHandler) GetGraphQLAPIAPIKeys(w http.ResponseWriter, r *http.Request) { + orgID, gatewayID, ok := h.authenticateRequest(w, r) + if !ok { + return + } + issuer := r.URL.Query().Get("issuer") + keys, err := h.gatewayInternalService.GetAPIKeysByKind(gatewayID, orgID, constants.GraphQLApi, issuer) + if err != nil { + h.slogger.Error("Failed to get API keys for GraphQL APIs", "gatewayID", gatewayID, "error", err) + httputil.WriteJSON(w, http.StatusInternalServerError, dto.NewInternalErrorResponse(500, "Internal Server Error", "Failed to get API keys")) + return + } + httputil.WriteJSON(w, http.StatusOK, keys) +} + // CheckArtifactsExist handles POST /api/internal/v1/artifacts/exists // Returns the subset of provided artifact UUIDs that still exist on the platform. // Used by the gateway during sync to avoid deleting artifacts that still exist @@ -1018,6 +1091,8 @@ func (h *GatewayInternalAPIHandler) RegisterRoutes(mux router.Router) { mux.HandleFunc("GET /api/internal/v1/deployments", h.GetGatewayDeployments) mux.HandleFunc("POST /api/internal/v1/deployments/fetch-batch", h.BatchFetchDeployments) mux.HandleFunc("GET /api/internal/v1/mcp-proxies/{proxyId}", h.GetMCPProxy) + mux.HandleFunc("GET /api/internal/v1/graphql-apis/api-keys", h.GetGraphQLAPIAPIKeys) + mux.HandleFunc("GET /api/internal/v1/graphql-apis/{apiId}", h.GetGraphQLAPI) mux.HandleFunc("GET /api/internal/v1/websub-apis/api-keys", h.GetWebSubAPIAPIKeys) mux.HandleFunc("GET /api/internal/v1/websub-apis/{apiId}", h.GetWebSubAPI) mux.HandleFunc("GET /api/internal/v1/websub-apis/{apiId}/secrets", h.GetWebSubAPIHmacSecrets) diff --git a/platform-api/internal/handler/gateway_secret_integration_test.go b/platform-api/internal/handler/gateway_secret_integration_test.go index a7ce9abedf..79f7bf5e18 100644 --- a/platform-api/internal/handler/gateway_secret_integration_test.go +++ b/platform-api/internal/handler/gateway_secret_integration_test.go @@ -117,7 +117,7 @@ func setupGatewaySecretTestEnv(t *testing.T) (*gatewaySecretTestEnv, func()) { cfg := &config.Server{} gwInternalSvc := service.NewGatewayInternalAPIService( - nil, nil, nil, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, deploymentRepo, gatewayRepo, nil, nil, nil, nil, secretRepo, diff --git a/platform-api/internal/handler/graphql_api.go b/platform-api/internal/handler/graphql_api.go new file mode 100644 index 0000000000..a3912bd8f5 --- /dev/null +++ b/platform-api/internal/handler/graphql_api.go @@ -0,0 +1,409 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package handler + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/middleware" + "github.com/wso2/api-platform/platform-api/internal/router" + "github.com/wso2/api-platform/platform-api/internal/service" + "github.com/wso2/api-platform/platform-api/internal/utils" + + "github.com/wso2/api-platform/httpkit/httputil" +) + +// GraphQLAPIHandler handles CRUD routes for GraphQL APIs. GraphQL is a core +// artifact kind (like RestApi/LlmProvider/LlmProxy/Mcp), so this handler is +// wired into the server the same way APIHandler/MCPProxyHandler are, not via +// a plugin. +type GraphQLAPIHandler struct { + graphqlAPIService *service.GraphQLAPIService + identity *service.IdentityService + slogger *slog.Logger +} + +// NewGraphQLAPIHandler creates a new GraphQLAPIHandler instance. +func NewGraphQLAPIHandler(graphqlAPIService *service.GraphQLAPIService, identity *service.IdentityService, slogger *slog.Logger) *GraphQLAPIHandler { + return &GraphQLAPIHandler{ + graphqlAPIService: graphqlAPIService, + identity: identity, + slogger: slogger, + } +} + +// CreateGraphQLAPI handles POST /api/v0.9/graphql-apis and creates a new GraphQL API. +func (h *GraphQLAPIHandler) CreateGraphQLAPI(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + var req api.CreateGraphQLAPIRequest + if err := decodeCreateGraphQLAPIRequest(r, &req); err != nil { + return apperror.NewValidation(err) + } + + if req.DisplayName == "" { + return apperror.ValidationFailed.New("API name is required") + } + if req.Context == "" { + return apperror.ValidationFailed.New("API context is required") + } + if req.Version == "" { + return apperror.ValidationFailed.New("API version is required") + } + if strings.TrimSpace(req.ProjectId) == "" { + return apperror.ValidationFailed.New("Project ID is required") + } + + createdBy, err := resolveActorErr(r, h.identity, "create GraphQL API") + if err != nil { + return err + } + apiResponse, err := h.graphqlAPIService.Create(orgId, createdBy, &req) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to create GraphQL API in org %s", orgId)) + } + + setLocation(w, "graphql-apis", strOrEmpty(apiResponse.Id)) + httputil.WriteJSON(w, http.StatusCreated, apiResponse) + return nil +} + +// ValidateGraphQLSchema handles POST /api/v0.9/graphql-apis/validate-schema — +// a dry-run of resolveSchema (see graphql_api.go's Create for the mutating +// counterpart) that never persists anything. A structural mismatch +// (schemaSource inconsistent with the fields supplied) surfaces as the usual +// 400 via serviceError; an actual resolution failure is not an error here — +// it comes back as {resolved: false, message: "..."} with a 200. +func (h *GraphQLAPIHandler) ValidateGraphQLSchema(w http.ResponseWriter, r *http.Request) error { + if _, exists := middleware.GetOrganizationFromRequest(r); !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + var req api.ValidateGraphQLSchemaRequest + if err := decodeValidateGraphQLSchemaRequest(r, &req); err != nil { + return apperror.NewValidation(err) + } + + resolution, err := h.graphqlAPIService.ValidateSchema(req) + if err != nil { + return serviceError(err, "failed to validate GraphQL schema") + } + + resp := api.ValidateGraphQLSchemaResponse{Resolved: resolution.Resolved, Sdl: resolution.SDL} + if resolution.Resolved { + mode := api.GraphQLIntrospectionMode(resolution.IntrospectionMode) + resp.IntrospectionMode = &mode + } else { + msg := apperror.GraphQLAPISchemaResolveFailed.New().Message + resp.Message = &msg + } + + httputil.WriteJSON(w, http.StatusOK, resp) + return nil +} + +// GetGraphQLAPI handles GET /api/v0.9/graphql-apis/:graphqlApiId and retrieves a GraphQL API by its handle. +func (h *GraphQLAPIHandler) GetGraphQLAPI(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("graphqlApiId") + if apiId == "" { + return apperror.ValidationFailed.New("API ID is required") + } + + apiResponse, err := h.graphqlAPIService.GetDetail(orgId, apiId) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to get GraphQL API %s in org %s", apiId, orgId)) + } + + httputil.WriteJSON(w, http.StatusOK, apiResponse) + return nil +} + +// GetGraphQLAPISDL handles GET /api/v0.9/graphql-apis/:graphqlApiId/sdl and +// retrieves a GraphQL API's resolved SDL text — split out from +// GetGraphQLAPI's response since sdl can be large and most callers only need +// the metadata. +func (h *GraphQLAPIHandler) GetGraphQLAPISDL(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("graphqlApiId") + if apiId == "" { + return apperror.ValidationFailed.New("API ID is required") + } + + sdl, err := h.graphqlAPIService.GetSDL(orgId, apiId) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to get GraphQL API %s SDL in org %s", apiId, orgId)) + } + + httputil.WriteJSON(w, http.StatusOK, api.GraphQLAPISDLResponse{Sdl: sdl}) + return nil +} + +// ListGraphQLAPIs handles GET /api/v0.9/graphql-apis and lists GraphQL APIs for an organization filtered by project. +func (h *GraphQLAPIHandler) ListGraphQLAPIs(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + projectId := strings.TrimSpace(r.URL.Query().Get("projectId")) + if projectId == "" { + return apperror.ValidationFailed.New("projectId query parameter is required") + } + + opts := parseListOptions(r) + + resp, err := h.graphqlAPIService.List(orgId, projectId, opts) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to get GraphQL APIs for project %s in org %s", projectId, orgId)) + } + + httputil.WriteJSON(w, http.StatusOK, resp) + return nil +} + +// UpdateGraphQLAPI handles PUT /api/v0.9/graphql-apis/:graphqlApiId and updates an existing GraphQL API. +func (h *GraphQLAPIHandler) UpdateGraphQLAPI(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("graphqlApiId") + if apiId == "" { + return apperror.ValidationFailed.New("API ID is required") + } + + var req api.GraphQLAPI + if err := decodeUpdateGraphQLAPIRequest(r, &req); err != nil { + return apperror.NewValidation(err) + } + + updatedBy, err := resolveActorErr(r, h.identity, "update GraphQL API") + if err != nil { + return err + } + apiResponse, err := h.graphqlAPIService.Update(orgId, apiId, updatedBy, &req) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to update GraphQL API %s in org %s", apiId, orgId)) + } + + httputil.WriteJSON(w, http.StatusOK, apiResponse) + return nil +} + +// DeleteGraphQLAPI handles DELETE /api/v0.9/graphql-apis/:graphqlApiId and deletes a GraphQL API by its handle. +func (h *GraphQLAPIHandler) DeleteGraphQLAPI(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("graphqlApiId") + if apiId == "" { + return apperror.ValidationFailed.New("API ID is required") + } + + deletedBy, err := resolveActorErr(r, h.identity, "delete GraphQL API") + if err != nil { + return err + } + if err := h.graphqlAPIService.Delete(orgId, apiId, deletedBy); err != nil { + return serviceError(err, fmt.Sprintf("failed to delete GraphQL API %s in org %s", apiId, orgId)) + } + + w.WriteHeader(http.StatusNoContent) + return nil +} + +// AddGatewaysToAPI handles POST /api/v0.9/graphql-apis/:graphqlApiId/gateways to +// associate gateways with a GraphQL API. Mirrors APIHandler.AddGatewaysToAPI. +func (h *GraphQLAPIHandler) AddGatewaysToAPI(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("graphqlApiId") + if apiId == "" { + return apperror.ValidationFailed.New("API ID is required") + } + + var req []api.AddGatewayToRESTAPIRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return apperror.NewValidation(err) + } + + if len(req) == 0 { + return apperror.ValidationFailed.New("At least one gateway ID is required") + } + + gatewayIds := make([]string, len(req)) + for i, gw := range req { + gatewayIds[i] = gw.GatewayId + } + + createdBy, err := resolveActorErr(r, h.identity, "associate gateways with GraphQL API") + if err != nil { + return err + } + + gatewaysResponse, err := h.graphqlAPIService.AddGatewaysToAPI(apiId, gatewayIds, orgId, createdBy) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to associate gateways with GraphQL API %s in org %s", apiId, orgId)) + } + + httputil.WriteJSON(w, http.StatusOK, gatewaysResponse) + return nil +} + +// GetAPIGateways handles GET /api/v0.9/graphql-apis/:graphqlApiId/gateways to get +// gateways associated with a GraphQL API including deployment details. Mirrors +// APIHandler.GetAPIGateways. +func (h *GraphQLAPIHandler) GetAPIGateways(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("graphqlApiId") + if apiId == "" { + return apperror.ValidationFailed.New("API ID is required") + } + + limit, offset := parsePagination(r) + + gatewaysResponse, err := h.graphqlAPIService.GetAPIGateways(apiId, orgId, limit, offset) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to get gateways for GraphQL API %s in org %s", apiId, orgId)) + } + + httputil.WriteJSON(w, http.StatusOK, gatewaysResponse) + return nil +} + +// decodeCreateGraphQLAPIRequest decodes a create request from +// multipart/form-data — a JSON "metadata" field plus an optional "sdlFile" +// upload — see GraphQLAPIMultipartRequest in resources/openapi.yaml. This is +// the only accepted content type: every schemaSource variant (inline, url, +// file, introspection) is expressed the same way rather than splitting file +// uploads onto a second content type. A file part's content is copied into +// req.Sdl as the schemaSource="file" candidate text; it is not silently +// preferred over a conflicting sdl/sdlUrl already in metadata — that +// conflict is a schemaSource structural-validation error, caught in the +// service layer (graphql_api.go's resolveSchema), not resolved here. +func decodeCreateGraphQLAPIRequest(r *http.Request, req *api.CreateGraphQLAPIRequest) error { + if !utils.IsMultipartFormRequest(r) { + return fmt.Errorf("Content-Type must be multipart/form-data") + } + metadataJSON, sdl, err := utils.ParseGraphQLAPIMultipartRequest(r) + if err != nil { + return err + } + if err := json.Unmarshal(metadataJSON, req); err != nil { + return err + } + if sdl != "" { + req.Sdl = &sdl + } + return nil +} + +// decodeUpdateGraphQLAPIRequest is decodeCreateGraphQLAPIRequest's update +// counterpart, targeting api.GraphQLAPI instead of api.CreateGraphQLAPIRequest +// (oapi-codegen generates these as distinct, non-embedding struct types, so +// the two can't share one generic function). +func decodeUpdateGraphQLAPIRequest(r *http.Request, req *api.GraphQLAPI) error { + if !utils.IsMultipartFormRequest(r) { + return fmt.Errorf("Content-Type must be multipart/form-data") + } + metadataJSON, sdl, err := utils.ParseGraphQLAPIMultipartRequest(r) + if err != nil { + return err + } + if err := json.Unmarshal(metadataJSON, req); err != nil { + return err + } + if sdl != "" { + req.Sdl = &sdl + } + return nil +} + +// decodeValidateGraphQLSchemaRequest is decodeCreateGraphQLAPIRequest's +// counterpart for the dry-run validate endpoint, targeting +// api.ValidateGraphQLSchemaRequest — the lightweight schema-only shape, +// without displayName/context/version/projectId. +func decodeValidateGraphQLSchemaRequest(r *http.Request, req *api.ValidateGraphQLSchemaRequest) error { + if !utils.IsMultipartFormRequest(r) { + return fmt.Errorf("Content-Type must be multipart/form-data") + } + metadataJSON, sdl, err := utils.ParseGraphQLAPIMultipartRequest(r) + if err != nil { + return err + } + if err := json.Unmarshal(metadataJSON, req); err != nil { + return err + } + if sdl != "" { + req.Sdl = &sdl + } + return nil +} + +// RegisterRoutes registers all GraphQL API routes. +func (h *GraphQLAPIHandler) RegisterRoutes(mux router.Router) { + h.slogger.Debug("Registering GraphQL API routes") + base := constants.APIBasePath + "/graphql-apis" + mux.HandleFunc("POST "+base, middleware.MapErrors(h.slogger, h.CreateGraphQLAPI)) + mux.HandleFunc("POST "+base+"/validate-schema", middleware.MapErrors(h.slogger, h.ValidateGraphQLSchema)) + mux.HandleFunc("GET "+base, middleware.MapErrors(h.slogger, h.ListGraphQLAPIs)) + mux.HandleFunc("GET "+base+"/{graphqlApiId}", middleware.MapErrors(h.slogger, h.GetGraphQLAPI)) + mux.HandleFunc("GET "+base+"/{graphqlApiId}/sdl", middleware.MapErrors(h.slogger, h.GetGraphQLAPISDL)) + mux.HandleFunc("PUT "+base+"/{graphqlApiId}", middleware.MapErrors(h.slogger, h.UpdateGraphQLAPI)) + mux.HandleFunc("DELETE "+base+"/{graphqlApiId}", middleware.MapErrors(h.slogger, h.DeleteGraphQLAPI)) + mux.HandleFunc("GET "+base+"/{graphqlApiId}/gateways", middleware.MapErrors(h.slogger, h.GetAPIGateways)) + mux.HandleFunc("POST "+base+"/{graphqlApiId}/gateways", middleware.MapErrors(h.slogger, h.AddGatewaysToAPI)) +} diff --git a/platform-api/internal/handler/graphql_api_test.go b/platform-api/internal/handler/graphql_api_test.go new file mode 100644 index 0000000000..26421b0ecd --- /dev/null +++ b/platform-api/internal/handler/graphql_api_test.go @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package handler + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/wso2/api-platform/platform-api/api" +) + +func newGraphQLAPIMultipartHandlerRequest(t *testing.T, metadata, sdlFileContent string, includeFile bool) *http.Request { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + + if metadata != "" { + if err := w.WriteField("metadata", metadata); err != nil { + t.Fatalf("failed to write metadata field: %v", err) + } + } + if includeFile { + fw, err := w.CreateFormFile("sdlFile", "schema.graphql") + if err != nil { + t.Fatalf("failed to create form file: %v", err) + } + if _, err := fw.Write([]byte(sdlFileContent)); err != nil { + t.Fatalf("failed to write form file content: %v", err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("failed to close multipart writer: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/graphql-apis", &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + return req +} + +const graphQLHandlerTestSDL = "type Query { countries: [String] }" + +// TestDecodeCreateGraphQLAPIRequest_JSON_Rejected guards the multipart-only +// requirement: application/json is no longer an accepted content type for +// GraphQL API create/update, so every schemaSource variant is expressed the +// same way instead of splitting file uploads onto a second content type. +func TestDecodeCreateGraphQLAPIRequest_JSON_Rejected(t *testing.T) { + body := `{"displayName":"Countries","context":"/countries","version":"v1.0","projectId":"default-project","sdl":"type Query { x: String }"}` + req := httptest.NewRequest(http.MethodPost, "/graphql-apis", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + var out api.CreateGraphQLAPIRequest + if err := decodeCreateGraphQLAPIRequest(req, &out); err == nil { + t.Fatal("expected application/json to be rejected now that multipart/form-data is the only accepted content type") + } +} + +// TestDecodeCreateGraphQLAPIRequest_Multipart_FileContentAndMetadataSDLUrlBothSurvive +// guards a deliberate behavior change from the old "file always wins, +// clearing sdlUrl" decoder logic: the decoder no longer resolves a +// file-vs-sdlUrl conflict itself, it only copies the file's content into +// req.Sdl and leaves whatever else was in metadata untouched. Detecting (and +// rejecting) a request that populated more than one schema source is now +// resolveSchema's job, driven by the declared schemaSource — see +// graphql_api.go in internal/service. +func TestDecodeCreateGraphQLAPIRequest_Multipart_FileContentAndMetadataSDLUrlBothSurvive(t *testing.T) { + metadata := `{"displayName":"Countries","context":"/countries","version":"v1.0","projectId":"default-project","sdlUrl":"https://example.com/schema.graphql"}` + req := newGraphQLAPIMultipartHandlerRequest(t, metadata, graphQLHandlerTestSDL, true) + + var out api.CreateGraphQLAPIRequest + if err := decodeCreateGraphQLAPIRequest(req, &out); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Sdl == nil || *out.Sdl != graphQLHandlerTestSDL { + t.Errorf("expected sdl to carry the uploaded file's content, got %v", out.Sdl) + } + if out.SdlUrl == nil || *out.SdlUrl != "https://example.com/schema.graphql" { + t.Errorf("expected sdlUrl from metadata to be left as-is (not silently cleared), got %v", out.SdlUrl) + } + if out.DisplayName != "Countries" { + t.Errorf("expected other metadata fields to still be populated, got %+v", out) + } +} + +func TestDecodeCreateGraphQLAPIRequest_Multipart_NoFile_PreservesMetadataFields(t *testing.T) { + metadata := `{"displayName":"Countries","context":"/countries","version":"v1.0","projectId":"default-project","sdlUrl":"https://example.com/schema.graphql"}` + req := newGraphQLAPIMultipartHandlerRequest(t, metadata, "", false) + + var out api.CreateGraphQLAPIRequest + if err := decodeCreateGraphQLAPIRequest(req, &out); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.SdlUrl == nil || *out.SdlUrl != "https://example.com/schema.graphql" { + t.Errorf("expected sdlUrl from metadata to survive when no file part is uploaded, got %v", out.SdlUrl) + } + if out.Sdl != nil { + t.Errorf("expected sdl to remain unset, got %v", *out.Sdl) + } +} + +func TestDecodeCreateGraphQLAPIRequest_Multipart_MissingMetadata(t *testing.T) { + req := newGraphQLAPIMultipartHandlerRequest(t, "", graphQLHandlerTestSDL, true) + + var out api.CreateGraphQLAPIRequest + if err := decodeCreateGraphQLAPIRequest(req, &out); err == nil { + t.Fatal("expected an error when the metadata field is missing") + } +} + +// TestDecodeUpdateGraphQLAPIRequest_JSON_Rejected is Update's counterpart to +// TestDecodeCreateGraphQLAPIRequest_JSON_Rejected. +func TestDecodeUpdateGraphQLAPIRequest_JSON_Rejected(t *testing.T) { + body := `{"displayName":"Countries","context":"/countries","version":"v1.0","sdl":"type Query { x: String }"}` + req := httptest.NewRequest(http.MethodPut, "/graphql-apis/countries", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + var out api.GraphQLAPI + if err := decodeUpdateGraphQLAPIRequest(req, &out); err == nil { + t.Fatal("expected application/json to be rejected now that multipart/form-data is the only accepted content type") + } +} + +// TestDecodeUpdateGraphQLAPIRequest_Multipart_FileContentAndMetadataSDLUrlBothSurvive +// is Update's counterpart to +// TestDecodeCreateGraphQLAPIRequest_Multipart_FileContentAndMetadataSDLUrlBothSurvive. +func TestDecodeUpdateGraphQLAPIRequest_Multipart_FileContentAndMetadataSDLUrlBothSurvive(t *testing.T) { + metadata := `{"displayName":"Countries","context":"/countries","version":"v1.0","sdlUrl":"https://example.com/schema.graphql"}` + req := newGraphQLAPIMultipartHandlerRequest(t, metadata, graphQLHandlerTestSDL, true) + + var out api.GraphQLAPI + if err := decodeUpdateGraphQLAPIRequest(req, &out); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Sdl == nil || *out.Sdl != graphQLHandlerTestSDL { + t.Errorf("expected sdl to carry the uploaded file's content, got %v", out.Sdl) + } + if out.SdlUrl == nil || *out.SdlUrl != "https://example.com/schema.graphql" { + t.Errorf("expected sdlUrl from metadata to be left as-is (not silently cleared), got %v", out.SdlUrl) + } +} + +func TestDecodeUpdateGraphQLAPIRequest_Multipart_MissingMetadata(t *testing.T) { + req := newGraphQLAPIMultipartHandlerRequest(t, "", graphQLHandlerTestSDL, true) + + var out api.GraphQLAPI + if err := decodeUpdateGraphQLAPIRequest(req, &out); err == nil { + t.Fatal("expected an error when the metadata field is missing") + } +} + +// TestDecodeValidateGraphQLSchemaRequest_JSON_Rejected mirrors +// TestDecodeCreateGraphQLAPIRequest_JSON_Rejected — the validate endpoint is +// multipart-only too, for the same "every schemaSource variant expressed the +// same way" reason. +func TestDecodeValidateGraphQLSchemaRequest_JSON_Rejected(t *testing.T) { + body := `{"sdl":"type Query { x: String }"}` + req := httptest.NewRequest(http.MethodPost, "/graphql-apis/validate-schema", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + var out api.ValidateGraphQLSchemaRequest + if err := decodeValidateGraphQLSchemaRequest(req, &out); err == nil { + t.Fatal("expected application/json to be rejected now that multipart/form-data is the only accepted content type") + } +} + +// TestDecodeValidateGraphQLSchemaRequest_Multipart_FileContentLandsInSdl +// mirrors TestDecodeCreateGraphQLAPIRequest_Multipart_FileContentAndMetadataSDLUrlBothSurvive +// for the validate endpoint's lightweight request type. +func TestDecodeValidateGraphQLSchemaRequest_Multipart_FileContentLandsInSdl(t *testing.T) { + metadata := `{"schemaSource":"file"}` + req := newGraphQLAPIMultipartHandlerRequest(t, metadata, graphQLHandlerTestSDL, true) + + var out api.ValidateGraphQLSchemaRequest + if err := decodeValidateGraphQLSchemaRequest(req, &out); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Sdl == nil || *out.Sdl != graphQLHandlerTestSDL { + t.Errorf("expected sdl to carry the uploaded file's content, got %v", out.Sdl) + } +} + +func TestDecodeValidateGraphQLSchemaRequest_Multipart_MissingMetadata(t *testing.T) { + req := newGraphQLAPIMultipartHandlerRequest(t, "", graphQLHandlerTestSDL, true) + + var out api.ValidateGraphQLSchemaRequest + if err := decodeValidateGraphQLSchemaRequest(req, &out); err == nil { + t.Fatal("expected an error when the metadata field is missing") + } +} diff --git a/platform-api/internal/handler/graphql_apikey.go b/platform-api/internal/handler/graphql_apikey.go new file mode 100644 index 0000000000..c2715deba6 --- /dev/null +++ b/platform-api/internal/handler/graphql_apikey.go @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package handler + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/middleware" + "github.com/wso2/api-platform/platform-api/internal/router" + "github.com/wso2/api-platform/platform-api/internal/service" + "github.com/wso2/api-platform/platform-api/internal/utils" + + "github.com/wso2/api-platform/httpkit/httputil" +) + +// GraphQLAPIKeyHandler handles API key operations for GraphQL APIs. +// +// Unlike LLM Provider/Proxy (which each get a dedicated APIKeyService — +// see llm_apikey.go/llm_proxy_apikey.go), GraphQL API keys reuse the existing +// *service.APIKeyService unmodified. That service already resolves the target +// artifact via the kind-agnostic ArtifactRepository.GetAPIMetadataByHandleAndKind +// and is exercised in production with multiple kinds beyond RestApi today (the +// eventgateway plugin's WebSub/WebBroker API key handlers call the very same +// instance with constants.WebSubApi/constants.WebBrokerApi — see +// plugins/eventgateway/handler/{websub,webbroker}_apikey.go). Its only +// REST-typed dependency (apiRepo repository.APIRepository) is used solely for +// GetAPIGatewaysWithDetails, which reads the kind-agnostic +// artifact_gateway_mappings table and works correctly for any artifact kind. +// So this handler is the only new code needed here — introducing a +// GraphQLAPIKeyService would duplicate ~300 lines of hashing/broadcast logic +// that is already proven kind-agnostic. +type GraphQLAPIKeyHandler struct { + apiKeyService *service.APIKeyService + identity *service.IdentityService + authzMode string + slogger *slog.Logger +} + +// NewGraphQLAPIKeyHandler creates a new GraphQL API key handler. +func NewGraphQLAPIKeyHandler(apiKeyService *service.APIKeyService, identity *service.IdentityService, authzMode string, slogger *slog.Logger) *GraphQLAPIKeyHandler { + return &GraphQLAPIKeyHandler{ + apiKeyService: apiKeyService, + identity: identity, + authzMode: authzMode, + slogger: slogger, + } +} + +// isKeyAdmin reports whether the caller holds constants.ScopeAPIKeyAllManage and may +// therefore act on API keys created by other users, not only their own. +func (h *GraphQLAPIKeyHandler) isKeyAdmin(r *http.Request) bool { + return middleware.HasEffectiveScope(r, h.authzMode, constants.ScopeAPIKeyAllManage) +} + +// CreateAPIKey handles POST /api/v0.9/graphql-apis/{graphqlApiId}/api-keys +func (h *GraphQLAPIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + userId, err := resolveActorErr(r, h.identity, "create GraphQL API key") + if err != nil { + return err + } + + apiHandle := r.PathValue("graphqlApiId") + if apiHandle == "" { + return apperror.ValidationFailed.New("API handle is required") + } + + var req api.CreateAPIKeyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid API key creation request for user %s", userId)) + } + + if req.DisplayName == "" { + return apperror.ValidationFailed.New("Display name is required"). + WithLogMessage(fmt.Sprintf("missing display name in API key creation request for user %s", userId)) + } + + var name string + if req.Id != nil && *req.Id != "" { + name = *req.Id + } else { + generatedName, err := utils.GenerateHandle(req.DisplayName, nil) + if err != nil { + return apperror.ValidationFailed.Wrap(err, "Failed to generate API key name") + } + name = generatedName + req.Id = &name + } + + resp, err := h.apiKeyService.CreateAPIKey(r.Context(), apiHandle, constants.GraphQLApi, orgId, userId, &req) + if err != nil { + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err + } + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to create API key %q for GraphQL API %s in org %s by user %s", name, apiHandle, orgId, userId)) + } + + keyName := "" + if req.Id != nil { + keyName = *req.Id + } + h.slogger.Info("Successfully created GraphQL API key", "userId", userId, "apiHandle", apiHandle, "orgId", orgId, "keyName", keyName) + + setLocation(w, "graphql-apis", apiHandle, "api-keys", name) + httputil.WriteJSON(w, http.StatusCreated, resp) + return nil +} + +// UpdateAPIKey handles PUT /api/v0.9/graphql-apis/{graphqlApiId}/api-keys/{apiKeyId} +func (h *GraphQLAPIKeyHandler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + userId, err := resolveActorErr(r, h.identity, "update GraphQL API key") + if err != nil { + return err + } + + apiHandle := r.PathValue("graphqlApiId") + if apiHandle == "" { + return apperror.ValidationFailed.New("API handle is required") + } + + keyName := r.PathValue("apiKeyId") + if keyName == "" { + return apperror.ValidationFailed.New("API key name is required") + } + + var req api.UpdateAPIKeyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid API key update request for key %s of GraphQL API %s in org %s by user %s", keyName, apiHandle, orgId, userId)) + } + + if req.ApiKey == "" { + return apperror.ValidationFailed.New("API key value is required") + } + + if err := utils.ValidateHandleImmutable(keyName, req.Name); err != nil { + h.slogger.Warn("API key name mismatch", "userId", userId, "orgId", orgId, "apiHandle", apiHandle, "urlKeyName", keyName, "bodyKeyName", *req.Name) + return apperror.ValidationFailed.New(fmt.Sprintf("API key name mismatch: name in request body '%s' must match the key name in URL '%s'", *req.Name, keyName)). + WithLogMessage(fmt.Sprintf("API key name mismatch for GraphQL API %s in org %s by user %s", apiHandle, orgId, userId)) + } + + if err := h.apiKeyService.UpdateAPIKey(r.Context(), apiHandle, constants.GraphQLApi, orgId, keyName, userId, h.isKeyAdmin(r), false, &req); err != nil { + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err + } + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to update API key %s for GraphQL API %s in org %s by user %s", keyName, apiHandle, orgId, userId)) + } + + h.slogger.Info("Successfully updated GraphQL API key", "userId", userId, "apiHandle", apiHandle, "orgId", orgId, "keyName", keyName) + + httputil.WriteJSON(w, http.StatusOK, api.UpdateAPIKeyResponse{ + Status: api.UpdateAPIKeyResponseStatusSuccess, + Message: "API key updated and broadcasted to gateways successfully", + KeyId: &keyName, + }) + return nil +} + +// RevokeAPIKey handles DELETE /api/v0.9/graphql-apis/{graphqlApiId}/api-keys/{apiKeyId} +func (h *GraphQLAPIKeyHandler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiHandle := r.PathValue("graphqlApiId") + if apiHandle == "" { + return apperror.ValidationFailed.New("API handle is required") + } + + keyName := r.PathValue("apiKeyId") + if keyName == "" { + return apperror.ValidationFailed.New("API key name is required") + } + + userId, err := resolveActorErr(r, h.identity, "revoke GraphQL API key") + if err != nil { + return err + } + + if err := h.apiKeyService.RevokeAPIKey(r.Context(), apiHandle, constants.GraphQLApi, orgId, keyName, userId, h.isKeyAdmin(r), false); err != nil { + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err + } + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to revoke API key %s for GraphQL API %s in org %s by user %s", keyName, apiHandle, orgId, userId)) + } + + h.slogger.Info("Successfully revoked GraphQL API key", "userId", userId, "apiHandle", apiHandle, "orgId", orgId, "keyName", keyName) + + w.WriteHeader(http.StatusNoContent) + return nil +} + +// RegisterRoutes registers GraphQL API key routes with the router. +func (h *GraphQLAPIKeyHandler) RegisterRoutes(mux router.Router) { + h.slogger.Debug("Registering GraphQL API key routes") + base := constants.APIBasePath + "/graphql-apis/{graphqlApiId}/api-keys" + mux.HandleFunc("POST "+base, middleware.MapErrors(h.slogger, h.CreateAPIKey)) + mux.HandleFunc("PUT "+base+"/{apiKeyId}", middleware.MapErrors(h.slogger, h.UpdateAPIKey)) + mux.HandleFunc("DELETE "+base+"/{apiKeyId}", middleware.MapErrors(h.slogger, h.RevokeAPIKey)) +} diff --git a/platform-api/internal/handler/graphql_deployment.go b/platform-api/internal/handler/graphql_deployment.go new file mode 100644 index 0000000000..b23b8a03d9 --- /dev/null +++ b/platform-api/internal/handler/graphql_deployment.go @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package handler + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/middleware" + "github.com/wso2/api-platform/platform-api/internal/router" + "github.com/wso2/api-platform/platform-api/internal/service" + + "github.com/wso2/api-platform/httpkit/httputil" +) + +// GraphQLAPIDeploymentHandler handles GraphQL API deployment endpoints using the +// shared deployment model. Mirrors LLMProviderDeploymentHandler +// (internal/handler/llm_deployment.go) — see GraphQLAPIDeploymentService's doc +// comment for why GraphQL gets its own dedicated deployment service/handler +// pair rather than reusing DeploymentHandler/DeploymentService. +type GraphQLAPIDeploymentHandler struct { + deploymentService *service.GraphQLAPIDeploymentService + identity *service.IdentityService + slogger *slog.Logger +} + +// NewGraphQLAPIDeploymentHandler creates a new GraphQL API deployment handler. +func NewGraphQLAPIDeploymentHandler(deploymentService *service.GraphQLAPIDeploymentService, identity *service.IdentityService, slogger *slog.Logger) *GraphQLAPIDeploymentHandler { + return &GraphQLAPIDeploymentHandler{deploymentService: deploymentService, identity: identity, slogger: slogger} +} + +// DeployGraphQLAPI handles POST /api/v0.9/graphql-apis/{graphqlApiId}/deployments +func (h *GraphQLAPIDeploymentHandler) DeployGraphQLAPI(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("graphqlApiId") + if apiId == "" { + return apperror.ValidationFailed.New("GraphQL API ID is required") + } + + var req api.DeployRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid GraphQL API deployment request body for API %s", apiId)) + } + + if req.Name == "" { + return apperror.GraphQLAPIDeploymentValidationFailed.New("name is required") + } + if req.Base == "" { + return apperror.GraphQLAPIDeploymentValidationFailed.New("base is required (use 'current' or a deploymentId)") + } + if strings.TrimSpace(req.GatewayId) == "" { + return apperror.GraphQLAPIDeploymentValidationFailed.New("gatewayId is required") + } + + createdBy, err := resolveActorErr(r, h.identity, "deploy GraphQL API") + if err != nil { + return err + } + + deployment, err := h.deploymentService.DeployGraphQLAPI(apiId, &req, orgId, createdBy) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to deploy GraphQL API %s", apiId)) + } + + setLocation(w, "graphql-apis", apiId, "deployments", deployment.DeploymentId.String()) + httputil.WriteJSON(w, http.StatusCreated, deployment) + return nil +} + +// UndeployGraphQLAPIDeployment handles POST /api/v0.9/graphql-apis/{graphqlApiId}/deployments/{deploymentId}/undeploy +func (h *GraphQLAPIDeploymentHandler) UndeployGraphQLAPIDeployment(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("graphqlApiId") + deploymentId := r.PathValue("deploymentId") + gatewayId := r.URL.Query().Get("gatewayId") + + if apiId == "" { + return apperror.ValidationFailed.New("GraphQL API ID is required") + } + deployment, err := h.deploymentService.UndeployGraphQLAPIDeployment(apiId, deploymentId, gatewayId, orgId) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to undeploy GraphQL API %s deployment %s on gateway %q", apiId, deploymentId, gatewayId)) + } + + httputil.WriteJSON(w, http.StatusOK, deployment) + return nil +} + +// RestoreGraphQLAPIDeployment handles POST /api/v0.9/graphql-apis/{graphqlApiId}/deployments/{deploymentId}/restore +func (h *GraphQLAPIDeploymentHandler) RestoreGraphQLAPIDeployment(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("graphqlApiId") + deploymentId := r.PathValue("deploymentId") + gatewayId := r.URL.Query().Get("gatewayId") + + if apiId == "" { + return apperror.ValidationFailed.New("GraphQL API ID is required") + } + deployment, err := h.deploymentService.RestoreGraphQLAPIDeployment(apiId, deploymentId, gatewayId, orgId) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to restore GraphQL API %s deployment %s on gateway %q", apiId, deploymentId, gatewayId)) + } + + httputil.WriteJSON(w, http.StatusOK, deployment) + return nil +} + +// DeleteGraphQLAPIDeployment handles DELETE /api/v0.9/graphql-apis/{graphqlApiId}/deployments/{deploymentId} +func (h *GraphQLAPIDeploymentHandler) DeleteGraphQLAPIDeployment(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("graphqlApiId") + deploymentId := r.PathValue("deploymentId") + + if apiId == "" { + return apperror.ValidationFailed.New("GraphQL API ID is required") + } + if deploymentId == "" { + return apperror.ValidationFailed.New("Deployment ID is required") + } + + if err := h.deploymentService.DeleteGraphQLAPIDeployment(apiId, deploymentId, orgId); err != nil { + return serviceError(err, fmt.Sprintf("failed to delete GraphQL API %s deployment %s", apiId, deploymentId)) + } + + w.WriteHeader(http.StatusNoContent) + return nil +} + +// GetGraphQLAPIDeployment handles GET /api/v0.9/graphql-apis/{graphqlApiId}/deployments/{deploymentId} +func (h *GraphQLAPIDeploymentHandler) GetGraphQLAPIDeployment(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("graphqlApiId") + deploymentId := r.PathValue("deploymentId") + + if apiId == "" { + return apperror.ValidationFailed.New("GraphQL API ID is required") + } + if deploymentId == "" { + return apperror.ValidationFailed.New("Deployment ID is required") + } + + deployment, err := h.deploymentService.GetGraphQLAPIDeployment(apiId, deploymentId, orgId) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to get GraphQL API %s deployment %s", apiId, deploymentId)) + } + + httputil.WriteJSON(w, http.StatusOK, deployment) + return nil +} + +// GetGraphQLAPIDeployments handles GET /api/v0.9/graphql-apis/{graphqlApiId}/deployments +func (h *GraphQLAPIDeploymentHandler) GetGraphQLAPIDeployments(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("graphqlApiId") + if apiId == "" { + return apperror.ValidationFailed.New("GraphQL API ID is required") + } + + q := r.URL.Query() + var gatewayId, status *string + if v := q.Get("gatewayId"); v != "" { + gatewayId = &v + } + if v := q.Get("status"); v != "" { + status = &v + } + + limit, offset := parsePagination(r) + + deployments, err := h.deploymentService.GetGraphQLAPIDeployments(apiId, orgId, gatewayId, status) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to get GraphQL API %s deployments", apiId)) + } + + paginateDeploymentList(deployments, limit, offset) + httputil.WriteJSON(w, http.StatusOK, deployments) + return nil +} + +// RegisterRoutes registers all GraphQL API deployment-related routes. +func (h *GraphQLAPIDeploymentHandler) RegisterRoutes(mux router.Router) { + base := constants.APIBasePath + "/graphql-apis/{graphqlApiId}" + mux.HandleFunc("POST "+base+"/deployments", middleware.MapErrors(h.slogger, h.DeployGraphQLAPI)) + mux.HandleFunc("POST "+base+"/deployments/{deploymentId}/undeploy", middleware.MapErrors(h.slogger, h.UndeployGraphQLAPIDeployment)) + mux.HandleFunc("POST "+base+"/deployments/{deploymentId}/restore", middleware.MapErrors(h.slogger, h.RestoreGraphQLAPIDeployment)) + mux.HandleFunc("GET "+base+"/deployments", middleware.MapErrors(h.slogger, h.GetGraphQLAPIDeployments)) + mux.HandleFunc("GET "+base+"/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.GetGraphQLAPIDeployment)) + mux.HandleFunc("DELETE "+base+"/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.DeleteGraphQLAPIDeployment)) +} diff --git a/platform-api/internal/handler/pagination_test.go b/platform-api/internal/handler/pagination_test.go new file mode 100644 index 0000000000..540c1e889d --- /dev/null +++ b/platform-api/internal/handler/pagination_test.go @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestParsePagination pins parsePagination's clamping contract — shared by +// every kind's list handler (including GraphQL's ListGraphQLAPIs), and +// previously untested anywhere in the repo despite being the one place that +// stands between a client-supplied limit/offset and an unbounded query. +func TestParsePagination(t *testing.T) { + tests := []struct { + name string + query string + wantLimit int + wantOffset int + }{ + {"defaults when absent", "", defaultPageLimit, defaultPageOffset}, + {"limit clamped at the upper bound", "limit=999", maxPageLimit, defaultPageOffset}, + {"limit clamped at the lower bound (zero)", "limit=0", minPageLimit, defaultPageOffset}, + {"limit clamped at the lower bound (negative)", "limit=-5", minPageLimit, defaultPageOffset}, + {"limit within range is respected", "limit=42", 42, defaultPageOffset}, + {"malformed limit falls back to default", "limit=not-a-number", defaultPageLimit, defaultPageOffset}, + {"offset respected when non-negative", "offset=15", defaultPageLimit, 15}, + {"negative offset falls back to default", "offset=-1", defaultPageLimit, defaultPageOffset}, + {"malformed offset falls back to default", "offset=not-a-number", defaultPageLimit, defaultPageOffset}, + {"limit and offset combined", "limit=999&offset=15", maxPageLimit, 15}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/graphql-apis?"+tt.query, nil) + limit, offset := parsePagination(r) + if limit != tt.wantLimit { + t.Errorf("limit = %d, want %d", limit, tt.wantLimit) + } + if offset != tt.wantOffset { + t.Errorf("offset = %d, want %d", offset, tt.wantOffset) + } + }) + } +} diff --git a/platform-api/internal/model/gateway_event.go b/platform-api/internal/model/gateway_event.go index 31c7b6b60a..469b649d20 100644 --- a/platform-api/internal/model/gateway_event.go +++ b/platform-api/internal/model/gateway_event.go @@ -174,6 +174,39 @@ type MCPProxyDeletionEvent struct { ProxyId string `json:"proxyId"` } +// GraphQLAPIDeploymentEvent contains payload data for "graphqlapi.deployed" event +// type. This event is sent when a GraphQL API is successfully deployed to a gateway. +type GraphQLAPIDeploymentEvent struct { + // ApiId identifies the deployed GraphQL API (handle) + ApiId string `json:"apiId"` + + // DeploymentID identifies the specific deployment artifact + DeploymentID string `json:"deploymentId"` + + // PerformedAt is the timestamp when the deployment was initiated (concurrency token) + PerformedAt time.Time `json:"performedAt"` +} + +// GraphQLAPIUndeploymentEvent contains payload data for "graphqlapi.undeployed" event +// type. This event is sent when a GraphQL API is undeployed from a gateway. +type GraphQLAPIUndeploymentEvent struct { + // ApiId identifies the undeployed GraphQL API (handle) + ApiId string `json:"apiId"` + + // DeploymentID identifies the specific deployment being undeployed + DeploymentID string `json:"deploymentId"` + + // PerformedAt is the timestamp when the undeployment was initiated (concurrency token) + PerformedAt time.Time `json:"performedAt"` +} + +// GraphQLAPIDeletionEvent contains payload data for "graphqlapi.deleted" event +// type. This event is sent when a GraphQL API is permanently deleted from the platform. +type GraphQLAPIDeletionEvent struct { + // ApiId identifies the deleted GraphQL API (handle) + ApiId string `json:"apiId"` +} + // WebSubAPIDeploymentEvent contains payload data for "websub.deployed" event type. // This event is sent when a WebSub API is successfully deployed to a gateway. type WebSubAPIDeploymentEvent struct { diff --git a/platform-api/internal/model/graphql_api.go b/platform-api/internal/model/graphql_api.go new file mode 100644 index 0000000000..54c075218c --- /dev/null +++ b/platform-api/internal/model/graphql_api.go @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package model + +import ( + "time" +) + +// GraphQLAPI represents a GraphQL API artifact entity. GraphQL is a core +// artifact kind (like RestApi/LlmProvider/LlmProxy/Mcp), so this type lives +// directly in the core model package. +type GraphQLAPI struct { + ID string `json:"id" db:"uuid"` + Handle string `json:"handle" db:"handle"` + Name string `json:"displayName" db:"display_name"` + Kind string `json:"kind" db:"kind"` + Description string `json:"description,omitempty" db:"description"` + Version string `json:"version" db:"version"` + CreatedBy string `json:"createdBy,omitempty" db:"created_by"` + UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` + ProjectID string `json:"projectId" db:"project_uuid"` + OrganizationID string `json:"organizationId" db:"organization_uuid"` + CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt,omitempty" db:"updated_at"` + Configuration GraphQLAPIConfig `json:"configuration" db:"-"` + Origin string `json:"origin,omitempty" db:"origin"` + DataVersion string `json:"dataVersion,omitempty" db:"data_version"` +} + +// GraphQLAPIConfig holds the GraphQL API configuration stored as JSON in the +// DB. Deliberately absent compared to RestAPIConfig: Transport and +// Operations — a GraphQL API has exactly one logical endpoint, not a +// per-resource/per-verb operation list. +type GraphQLAPIConfig struct { + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + Context *string `json:"context,omitempty"` // e.g. "/countries/$version" — same $version substitution as REST + + // SDL is the GraphQL schema, always stored resolved — never a + // document-supplied schemaLocation (xxe-xml-processing.md §3 applies by + // analogy: the server never auto-dereferences a secondary location). + SDL string `json:"sdl"` + + // IntrospectionMode records how SDL was obtained: "SDL" (supplied + // directly) or "ENDPOINT" (derived by introspecting upstream.main.url at + // creation/update time). Informational only; storage is identical either way. + IntrospectionMode string `json:"introspectionMode,omitempty"` + + // Upstream is reused as-is from model/upstream.go — a GraphQL API has a + // single endpoint (no per-operation paths), so upstream.main is the one + // GraphQL endpoint. + Upstream UpstreamConfig `json:"upstream,omitempty"` + Policies []Policy `json:"policies,omitempty"` + SubscriptionPlans []string `json:"subscriptionPlans,omitempty"` +} diff --git a/platform-api/internal/repository/api.go b/platform-api/internal/repository/api.go index f715f4f537..4aea3c60a9 100644 --- a/platform-api/internal/repository/api.go +++ b/platform-api/internal/repository/api.go @@ -572,9 +572,42 @@ func (r *APIRepo) CheckAPIExistsByNameAndVersionInOrganization(name, version, or } // CreateAPIAssociation creates a gateway-API association in artifact_gateway_mappings. -// created_by/updated_by are seeded from association.CreatedBy (the acting user); on create -// updated_by mirrors created_by. Both are stored as NULL when the actor is unknown. +// Delegates to the kind-agnostic createArtifactGatewayAssociation helper — see that +// function's doc comment (this method exists only to satisfy APIRepository). func (r *APIRepo) CreateAPIAssociation(association *model.APIAssociation) error { + return createArtifactGatewayAssociation(r.db, association) +} + +// UpdateAPIAssociation updates the updated_at timestamp and updated_by actor for a +// gateway-API association. Delegates to updateArtifactGatewayAssociation. +func (r *APIRepo) UpdateAPIAssociation(apiUUID, resourceId, associationType, orgUUID, updatedBy string) error { + return updateArtifactGatewayAssociation(r.db, apiUUID, resourceId, orgUUID, updatedBy) +} + +// GetAPIAssociations retrieves all gateway associations for an API. +// associationType is accepted for interface compatibility but only 'gateway' associations are stored. +// Delegates to getArtifactGatewayAssociations. +func (r *APIRepo) GetAPIAssociations(apiUUID, associationType, orgUUID string) ([]*model.APIAssociation, error) { + return getArtifactGatewayAssociations(r.db, apiUUID, orgUUID) +} + +// GetAPIGatewaysWithDetails retrieves all gateways associated with an API including +// deployment details. Delegates to getArtifactGatewaysWithDetails. +func (r *APIRepo) GetAPIGatewaysWithDetails(apiUUID, orgUUID string) ([]*model.APIGatewayWithDetails, error) { + return getArtifactGatewaysWithDetails(r.db, apiUUID, orgUUID) +} + +// createArtifactGatewayAssociation creates a gateway-artifact association in +// artifact_gateway_mappings. created_by/updated_by are seeded from +// association.CreatedBy (the acting user); on create updated_by mirrors created_by. +// Both are stored as NULL when the actor is unknown. +// +// This helper (and its update/get/getWithDetails siblings below) is kind-agnostic — +// artifact_gateway_mappings is keyed solely on artifact_uuid, with no REST-specific +// columns — so both *APIRepo and *GraphQLAPIRepo delegate to the exact same SQL +// rather than each maintaining their own copy. Any future kind's gateway-association +// repo methods should do the same. +func createArtifactGatewayAssociation(db *database.DB, association *model.APIAssociation) error { association.CreatedAt = time.Now().UTC() association.UpdatedAt = association.CreatedAt if association.UpdatedBy == "" { @@ -585,34 +618,35 @@ func (r *APIRepo) CreateAPIAssociation(association *model.APIAssociation) error INSERT INTO artifact_gateway_mappings (artifact_uuid, organization_uuid, gateway_uuid, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ` - _, err := r.db.Exec(r.db.Rebind(query), + _, err := db.Exec(db.Rebind(query), association.ArtifactID, association.OrganizationID, association.GatewayID, association.CreatedBy, association.UpdatedBy, association.CreatedAt, association.UpdatedAt) return err } -// UpdateAPIAssociation updates the updated_at timestamp and updated_by actor for a -// gateway-API association. -func (r *APIRepo) UpdateAPIAssociation(apiUUID, resourceId, associationType, orgUUID, updatedBy string) error { +// updateArtifactGatewayAssociation updates the updated_at timestamp and updated_by +// actor for a gateway-artifact association. See createArtifactGatewayAssociation for +// why this is a shared, kind-agnostic helper. +func updateArtifactGatewayAssociation(db *database.DB, artifactUUID, gatewayUUID, orgUUID, updatedBy string) error { query := ` UPDATE artifact_gateway_mappings SET updated_at = ?, updated_by = ? WHERE artifact_uuid = ? AND gateway_uuid = ? AND organization_uuid = ? ` - _, err := r.db.Exec(r.db.Rebind(query), time.Now().UTC(), updatedBy, apiUUID, resourceId, orgUUID) + _, err := db.Exec(db.Rebind(query), time.Now().UTC(), updatedBy, artifactUUID, gatewayUUID, orgUUID) return err } -// GetAPIAssociations retrieves all gateway associations for an API. -// associationType is accepted for interface compatibility but only 'gateway' associations are stored. -func (r *APIRepo) GetAPIAssociations(apiUUID, associationType, orgUUID string) ([]*model.APIAssociation, error) { +// getArtifactGatewayAssociations retrieves all gateway associations for an artifact. +// See createArtifactGatewayAssociation for why this is a shared, kind-agnostic helper. +func getArtifactGatewayAssociations(db *database.DB, artifactUUID, orgUUID string) ([]*model.APIAssociation, error) { query := ` SELECT artifact_uuid, organization_uuid, gateway_uuid, created_by, updated_by, created_at, updated_at FROM artifact_gateway_mappings WHERE artifact_uuid = ? AND organization_uuid = ? ` - rows, err := r.db.Query(r.db.Rebind(query), apiUUID, orgUUID) + rows, err := db.Query(db.Rebind(query), artifactUUID, orgUUID) if err != nil { return nil, err } @@ -635,8 +669,10 @@ func (r *APIRepo) GetAPIAssociations(apiUUID, associationType, orgUUID string) ( return associations, rows.Err() } -// GetAPIGatewaysWithDetails retrieves all gateways associated with an API including deployment details. -func (r *APIRepo) GetAPIGatewaysWithDetails(apiUUID, orgUUID string) ([]*model.APIGatewayWithDetails, error) { +// getArtifactGatewaysWithDetails retrieves all gateways associated with an artifact, +// including deployment details. See createArtifactGatewayAssociation for why this is +// a shared, kind-agnostic helper. +func getArtifactGatewaysWithDetails(db *database.DB, artifactUUID, orgUUID string) ([]*model.APIGatewayWithDetails, error) { query := ` SELECT g.uuid as id, @@ -664,7 +700,7 @@ func (r *APIRepo) GetAPIGatewaysWithDetails(apiUUID, orgUUID string) ([]*model.A ORDER BY aa.created_at DESC, ge.id ASC ` - rows, err := r.db.Query(r.db.Rebind(query), apiUUID, string(model.DeploymentStatusDeployed), apiUUID, orgUUID) + rows, err := db.Query(db.Rebind(query), artifactUUID, string(model.DeploymentStatusDeployed), artifactUUID, orgUUID) if err != nil { return nil, err } diff --git a/platform-api/internal/repository/artifact_tables.go b/platform-api/internal/repository/artifact_tables.go index 914c59e854..8d6956a40b 100644 --- a/platform-api/internal/repository/artifact_tables.go +++ b/platform-api/internal/repository/artifact_tables.go @@ -22,6 +22,8 @@ import ( "fmt" "strings" "sync" + + "github.com/wso2/api-platform/platform-api/internal/constants" ) // ArtifactTableEntry describes a kind-specific child table that backs artifact rows. @@ -67,6 +69,11 @@ func NewArtifactTableRegistry() *ArtifactTableRegistry { KindAlias: "Mcp", KindKeys: []string{"mcp-proxy", "MCPProxy", "Mcp"}, }) + r.Register(ArtifactTableEntry{ + Table: "graphql_apis", + KindAlias: constants.GraphQLApi, + KindKeys: []string{"graphql-api", constants.GraphQLApi}, + }) return r } diff --git a/platform-api/internal/repository/artifact_tables_test.go b/platform-api/internal/repository/artifact_tables_test.go new file mode 100644 index 0000000000..133679a8e8 --- /dev/null +++ b/platform-api/internal/repository/artifact_tables_test.go @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + */ + +package repository + +import "testing" + +// TestNewArtifactTableRegistry_AllCoreKindsRegistered guards GraphQL's status +// as a core kind (like RestApi/LlmProvider/LlmProxy/Mcp): NewArtifactTableRegistry +// must register all five unconditionally, with no build tag or plugin Init() +// step able to skip any of them. A future kind silently dropped from this +// constructor would otherwise only surface as a runtime 404 on that kind's +// API-key/deployment/gateway-association endpoints — this test catches it at +// build time instead. +func TestNewArtifactTableRegistry_AllCoreKindsRegistered(t *testing.T) { + reg := NewArtifactTableRegistry() + + wantKindAliases := []string{"RestApi", "LlmProvider", "LlmProxy", "Mcp", "GraphQLApi"} + for _, alias := range wantKindAliases { + if !reg.IsValidKindAlias(alias) { + t.Errorf("expected core kind %q to be registered, but it wasn't", alias) + } + } + + entries := reg.Entries() + if len(entries) != len(wantKindAliases) { + t.Errorf("expected exactly %d core tables registered, got %d: %+v", len(wantKindAliases), len(entries), entries) + } + + // GraphQLApi specifically: confirm both the handle form ("graphql-api") + // and the Go-constant form ("GraphQLApi") resolve to the graphql_apis + // table, matching every other core kind's dual-key convention. + for _, key := range []string{"graphql-api", "GraphQLApi"} { + entry, ok := reg.TableByKindKey(key) + if !ok { + t.Fatalf("expected kind key %q to resolve to a table entry", key) + } + if entry.Table != "graphql_apis" { + t.Errorf("expected kind key %q to resolve to table \"graphql_apis\", got %q", key, entry.Table) + } + } +} diff --git a/platform-api/internal/repository/graphql_api.go b/platform-api/internal/repository/graphql_api.go new file mode 100644 index 0000000000..9ffb3aea09 --- /dev/null +++ b/platform-api/internal/repository/graphql_api.go @@ -0,0 +1,392 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package repository + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/database" + "github.com/wso2/api-platform/platform-api/internal/gatewaytranslator" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/utils" +) + +// GraphQLAPIRepo handles database operations for GraphQL APIs. GraphQL is a +// core artifact kind (like RestApi/LlmProvider/LlmProxy/Mcp), so this repo +// lives directly alongside api.go/mcp.go rather than in a plugin package. +type GraphQLAPIRepo struct { + db *database.DB + artifactRepo *ArtifactRepo +} + +// NewGraphQLAPIRepo creates a new GraphQLAPIRepo instance. +func NewGraphQLAPIRepo(db *database.DB, reg *ArtifactTableRegistry) *GraphQLAPIRepo { + return &GraphQLAPIRepo{db: db, artifactRepo: NewArtifactRepo(db, reg)} +} + +// Create creates a new GraphQL API in the database. +func (r *GraphQLAPIRepo) Create(a *model.GraphQLAPI) error { + uuidStr, err := utils.GenerateUUID() + if err != nil { + return fmt.Errorf("failed to generate GraphQL API ID: %w", err) + } + a.ID = uuidStr + now := time.Now().UTC() + a.CreatedAt = now + a.UpdatedAt = now + + configurationJSON, err := serializeGraphQLAPIConfiguration(a.Configuration) + if err != nil { + return fmt.Errorf("failed to serialize configuration: %w", err) + } + + tx, err := r.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + // Insert into artifacts table first. + if err := r.artifactRepo.Create(tx, &model.Artifact{ + UUID: a.ID, + Type: constants.GraphQLApi, + OrganizationUUID: a.OrganizationID, + }); err != nil { + return fmt.Errorf("failed to create artifact: %w", err) + } + + origin := a.Origin + if origin == "" { + origin = constants.OriginCP + } + + if a.DataVersion == "" { + a.DataVersion = string(gatewaytranslator.ComputeDataVersion(constants.GraphQLApi, constants.GatewayApiVersion)) + } + + query := ` + INSERT INTO graphql_apis ( + uuid, organization_uuid, handle, display_name, version, project_uuid, description, created_by, updated_by, configuration, origin, data_version, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + _, err = tx.Exec(r.db.Rebind(query), + a.ID, a.OrganizationID, a.Handle, a.Name, a.Version, a.ProjectID, a.Description, a.CreatedBy, a.UpdatedBy, + configurationJSON, origin, a.DataVersion, a.CreatedAt, a.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("failed to create GraphQL API: %w", err) + } + + if err := upsertArtifactSecretRefs(tx, r.db, a.OrganizationID, a.ID, configurationJSON); err != nil { + return fmt.Errorf("failed to upsert artifact secret refs: %w", err) + } + + return tx.Commit() +} + +// GetByHandle retrieves a GraphQL API by its handle and organization UUID. +func (r *GraphQLAPIRepo) GetByHandle(handle, orgUUID string) (*model.GraphQLAPI, error) { + query := ` + SELECT + uuid, handle, display_name, version, organization_uuid, origin, created_at, updated_at, + project_uuid, description, created_by, updated_by, configuration, data_version + FROM graphql_apis + WHERE handle = ? AND organization_uuid = ?` + row := r.db.QueryRow(r.db.Rebind(query), handle, orgUUID) + return r.scanGraphQLAPI(row) +} + +// GetByUUID retrieves a GraphQL API by its UUID and organization UUID. +func (r *GraphQLAPIRepo) GetByUUID(uuid, orgUUID string) (*model.GraphQLAPI, error) { + query := ` + SELECT + uuid, handle, display_name, version, organization_uuid, origin, created_at, updated_at, + project_uuid, description, created_by, updated_by, configuration, data_version + FROM graphql_apis + WHERE uuid = ? AND organization_uuid = ?` + row := r.db.QueryRow(r.db.Rebind(query), uuid, orgUUID) + return r.scanGraphQLAPI(row) +} + +// List retrieves all GraphQL APIs for an organization, optionally filtered by project. +func (r *GraphQLAPIRepo) List(orgUUID, projectUUID string, opts ListOptions) ([]*model.GraphQLAPI, error) { + query := ` + SELECT + uuid, handle, display_name, version, organization_uuid, origin, created_at, updated_at, + project_uuid, description, created_by, updated_by, configuration, data_version + FROM graphql_apis + WHERE organization_uuid = ?` + args := []interface{}{orgUUID} + + if projectUUID != "" { + query += " AND project_uuid = ?" + args = append(args, projectUUID) + } + if searchClause, searchArgs := handleSearchClause(opts.Search); searchClause != "" { + query += searchClause + args = append(args, searchArgs...) + } + col, dir := opts.resolveSort(listSortColumns, "created_at") + query += " ORDER BY " + col + " " + dir + ", handle ASC" + + pageClause, pageArgs := r.db.PaginationClause(opts.Limit, opts.Offset) + query += " " + pageClause + args = append(args, pageArgs...) + + rows, err := r.db.Query(r.db.Rebind(query), args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var res []*model.GraphQLAPI + for rows.Next() { + a, err := r.scanGraphQLAPIFromRows(rows) + if err != nil { + return nil, err + } + res = append(res, a) + } + return res, rows.Err() +} + +// Count returns the total number of GraphQL APIs for an organization. +func (r *GraphQLAPIRepo) Count(orgUUID string) (int, error) { + return r.artifactRepo.CountByKindAndOrg(constants.GraphQLApi, orgUUID) +} + +// CountByProject returns the total number of GraphQL APIs for a specific +// project, optionally narrowed by the same case-insensitive handle search +// List applies. +func (r *GraphQLAPIRepo) CountByProject(orgUUID, projectUUID, search string) (int, error) { + query := `SELECT COUNT(*) FROM graphql_apis WHERE organization_uuid = ? AND project_uuid = ?` + args := []interface{}{orgUUID, projectUUID} + if searchClause, searchArgs := handleSearchClause(search); searchClause != "" { + query += searchClause + args = append(args, searchArgs...) + } + + var count int + if err := r.db.QueryRow(r.db.Rebind(query), args...).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +// Update updates an existing GraphQL API. +func (r *GraphQLAPIRepo) Update(a *model.GraphQLAPI) error { + now := time.Now().UTC() + a.UpdatedAt = now + + configurationJSON, err := serializeGraphQLAPIConfiguration(a.Configuration) + if err != nil { + return fmt.Errorf("failed to serialize configuration: %w", err) + } + + tx, err := r.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + var apiUUID string + query := ` + SELECT uuid FROM graphql_apis + WHERE handle = ? AND organization_uuid = ?` + err = tx.QueryRow(r.db.Rebind(query), a.Handle, a.OrganizationID).Scan(&apiUUID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return sql.ErrNoRows + } + return err + } + + if a.DataVersion == "" { + a.DataVersion = string(gatewaytranslator.ComputeDataVersion(constants.GraphQLApi, constants.GatewayApiVersion)) + } + + query = ` + UPDATE graphql_apis + SET display_name = ?, version = ?, description = ?, configuration = ?, updated_by = ?, data_version = ?, updated_at = ? + WHERE uuid = ?` + result, err := tx.Exec(r.db.Rebind(query), + a.Name, a.Version, a.Description, configurationJSON, a.UpdatedBy, a.DataVersion, now, + apiUUID, + ) + if err != nil { + return fmt.Errorf("failed to update GraphQL API: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + + if err := upsertArtifactSecretRefs(tx, r.db, a.OrganizationID, apiUUID, configurationJSON); err != nil { + return fmt.Errorf("failed to upsert artifact secret refs: %w", err) + } + + return tx.Commit() +} + +// Delete deletes a GraphQL API by its handle and organization UUID. +func (r *GraphQLAPIRepo) Delete(handle, orgUUID string) error { + tx, err := r.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + var apiUUID string + query := ` + SELECT uuid FROM graphql_apis + WHERE handle = ? AND organization_uuid = ?` + err = tx.QueryRow(r.db.Rebind(query), handle, orgUUID).Scan(&apiUUID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return sql.ErrNoRows + } + return err + } + + _, err = tx.Exec(r.db.Rebind(`DELETE FROM graphql_apis WHERE uuid = ?`), apiUUID) + if err != nil { + return err + } + + if err := r.artifactRepo.Delete(tx, apiUUID); err != nil { + return err + } + + return tx.Commit() +} + +// Exists checks if a GraphQL API exists by its handle and organization UUID. +func (r *GraphQLAPIRepo) Exists(handle, orgUUID string) (bool, error) { + return r.artifactRepo.Exists(constants.GraphQLApi, handle, orgUUID) +} + +// scanGraphQLAPI scans a single Row into a GraphQLAPI. +func (r *GraphQLAPIRepo) scanGraphQLAPI(row *sql.Row) (*model.GraphQLAPI, error) { + var a model.GraphQLAPI + var createdBy, updatedBy sql.NullString + var configurationJSON []byte + if err := row.Scan( + &a.ID, &a.Handle, &a.Name, &a.Version, &a.OrganizationID, &a.Origin, &a.CreatedAt, &a.UpdatedAt, + &a.ProjectID, &a.Description, &createdBy, &updatedBy, &configurationJSON, &a.DataVersion, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + a.Kind = constants.GraphQLApi + a.CreatedBy = createdBy.String + a.UpdatedBy = updatedBy.String + if len(configurationJSON) > 0 { + if config, err := deserializeGraphQLAPIConfiguration(configurationJSON); err != nil { + return nil, fmt.Errorf("unmarshal configuration for GraphQL API %s: %w", a.Handle, err) + } else if config != nil { + a.Configuration = *config + } + } + return &a, nil +} + +// scanGraphQLAPIFromRows scans a Rows row into a GraphQLAPI. +func (r *GraphQLAPIRepo) scanGraphQLAPIFromRows(rows *sql.Rows) (*model.GraphQLAPI, error) { + var a model.GraphQLAPI + var createdBy, updatedBy sql.NullString + var configurationJSON []byte + if err := rows.Scan( + &a.ID, &a.Handle, &a.Name, &a.Version, &a.OrganizationID, &a.Origin, &a.CreatedAt, &a.UpdatedAt, + &a.ProjectID, &a.Description, &createdBy, &updatedBy, &configurationJSON, &a.DataVersion, + ); err != nil { + return nil, err + } + a.Kind = constants.GraphQLApi + a.CreatedBy = createdBy.String + a.UpdatedBy = updatedBy.String + if len(configurationJSON) > 0 { + if config, err := deserializeGraphQLAPIConfiguration(configurationJSON); err != nil { + return nil, fmt.Errorf("unmarshal configuration for GraphQL API %s: %w", a.Handle, err) + } else if config != nil { + a.Configuration = *config + } + } + return &a, nil +} + +func serializeGraphQLAPIConfiguration(config model.GraphQLAPIConfig) ([]byte, error) { + return json.Marshal(config) +} + +func deserializeGraphQLAPIConfiguration(configJSON []byte) (*model.GraphQLAPIConfig, error) { + if len(configJSON) == 0 { + return nil, fmt.Errorf("null configuration") + } + var config model.GraphQLAPIConfig + if err := json.Unmarshal(configJSON, &config); err != nil { + return nil, err + } + return &config, nil +} + +// GetAPIGatewaysWithDetails retrieves all gateways associated with this GraphQL +// API, including deployment details. Delegates to the same kind-agnostic helper +// APIRepo uses — see createArtifactGatewayAssociation's doc comment in +// repository/api.go for why this is shared rather than duplicated SQL. +func (r *GraphQLAPIRepo) GetAPIGatewaysWithDetails(apiUUID, orgUUID string) ([]*model.APIGatewayWithDetails, error) { + return getArtifactGatewaysWithDetails(r.db, apiUUID, orgUUID) +} + +// CreateAPIAssociation creates a gateway-API association for this GraphQL API. +func (r *GraphQLAPIRepo) CreateAPIAssociation(association *model.APIAssociation) error { + return createArtifactGatewayAssociation(r.db, association) +} + +// GetAPIAssociations retrieves all gateway associations for this GraphQL API. +// associationType is accepted for interface compatibility but only 'gateway' +// associations are stored. +func (r *GraphQLAPIRepo) GetAPIAssociations(apiUUID, associationType, orgUUID string) ([]*model.APIAssociation, error) { + return getArtifactGatewayAssociations(r.db, apiUUID, orgUUID) +} + +// UpdateAPIAssociation updates the updated_at timestamp and updated_by actor for a +// gateway-API association. +func (r *GraphQLAPIRepo) UpdateAPIAssociation(apiUUID, resourceId, associationType, orgUUID, updatedBy string) error { + return updateArtifactGatewayAssociation(r.db, apiUUID, resourceId, orgUUID, updatedBy) +} + +// EnsureGatewayAssociation creates a gateway association for the API if one does not +// already exist and resolves the metadata to use for the deployment. See +// ensureArtifactGatewayAssociation (repository/llm.go) for the full semantics — +// LLMProviderRepo/LLMProxyRepo delegate to the exact same helper. +func (r *GraphQLAPIRepo) EnsureGatewayAssociation(apiUUID, gatewayUUID, orgUUID, createdBy, deployMetadata string, metadataProvided bool) (string, error) { + return ensureArtifactGatewayAssociation(r.db, apiUUID, gatewayUUID, orgUUID, createdBy, deployMetadata, metadataProvided) +} + +// Compile-time assertion that GraphQLAPIRepo satisfies GraphQLAPIRepository. +var _ GraphQLAPIRepository = (*GraphQLAPIRepo)(nil) diff --git a/platform-api/internal/repository/graphql_api_test.go b/platform-api/internal/repository/graphql_api_test.go new file mode 100644 index 0000000000..e1a48ada66 --- /dev/null +++ b/platform-api/internal/repository/graphql_api_test.go @@ -0,0 +1,709 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package repository + +import ( + "database/sql" + "errors" + "reflect" + "testing" + "time" + + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/model" + + _ "github.com/mattn/go-sqlite3" +) + +// This file is the GraphQL counterpart to api_test.go — real SQLite (via +// setupTestDB/setupTestDBWithoutForeignKeys, shared with api_deployments_test.go), +// not the mock repo used by internal/service/graphql_api_test.go. Mirrors the +// same coverage REST APIs already have at this layer, since the mock-repo +// service tests can't catch a broken SQL query, a wrong column mapping, or a +// missed artifact-row insert. + +func newTestGraphQLAPI(handle, orgUUID, projectUUID string) *model.GraphQLAPI { + return &model.GraphQLAPI{ + Handle: handle, + Name: "Countries GraphQL API", + Version: "v1.0", + Description: "Test GraphQL API", + CreatedBy: "test-user", + UpdatedBy: "test-user", + ProjectID: projectUUID, + OrganizationID: orgUUID, + Configuration: model.GraphQLAPIConfig{ + Name: "Countries GraphQL API", + Version: "v1.0", + Context: strPtr("/countries/$version"), + SDL: "type Query { countries: [Country] }\ntype Country { code: String name: String }", + IntrospectionMode: "SDL", + Upstream: model.UpstreamConfig{ + Main: &model.UpstreamEndpoint{ + URL: "https://countries.trevorblades.com/graphql", + }, + }, + Policies: []model.Policy{ + {Name: "jwt-auth", Version: "v1"}, + }, + SubscriptionPlans: []string{"Gold", "Silver"}, + }, + } +} + +func TestGraphQLAPIRepo_CreateAndRead(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-crud-001" + projectUUID := "project-graphql-crud-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + api := newTestGraphQLAPI("countries-graphql", orgUUID, projectUUID) + + if err := repo.Create(api); err != nil { + t.Fatalf("Create failed: %v", err) + } + if api.ID == "" { + t.Fatal("Create should set api.ID") + } + + created, err := repo.GetByUUID(api.ID, orgUUID) + if err != nil { + t.Fatalf("GetByUUID failed: %v", err) + } + if created == nil { + t.Fatal("GetByUUID returned nil") + } + + if created.Handle != api.Handle || created.Name != api.Name || created.Version != api.Version { + t.Fatalf("GetByUUID returned unexpected metadata: %+v", created) + } + if created.Description != api.Description || created.CreatedBy != api.CreatedBy || created.ProjectID != api.ProjectID { + t.Fatalf("GetByUUID returned unexpected details: %+v", created) + } + if created.OrganizationID != api.OrganizationID { + t.Fatalf("GetByUUID returned unexpected organization: %+v", created) + } + if created.UpdatedBy == "" { + t.Fatal("expected updated_by to be set on creation, got empty string") + } +} + +// TestGraphQLAPIRepo_CreateAndRead_FullConfiguration is the GraphQL counterpart +// to TestAPIRepo_CreateAndRead_FullConfiguration — round-trips sdl, +// introspectionMode, upstream, policies, and subscriptionPlans through the +// configuration BLOB. +func TestGraphQLAPIRepo_CreateAndRead_FullConfiguration(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-crud-002" + projectUUID := "project-graphql-crud-002" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + api := newTestGraphQLAPI("countries-graphql-full", orgUUID, projectUUID) + + if err := repo.Create(api); err != nil { + t.Fatalf("Create failed: %v", err) + } + + created, err := repo.GetByUUID(api.ID, orgUUID) + if err != nil { + t.Fatalf("GetByUUID failed: %v", err) + } + if created == nil { + t.Fatal("GetByUUID returned nil") + } + + if !reflect.DeepEqual(created.Configuration, api.Configuration) { + t.Fatalf("Full configuration mismatch. expected=%+v actual=%+v", api.Configuration, created.Configuration) + } +} + +// TestGraphQLAPIRepo_CreateSetsArtifactKind guards the artifact-type insertion +// behavior confirmed earlier in this session: Create must insert an artifacts +// row with type=GraphQLApi, exactly mirroring how rest_apis/Create sets +// type=RestApi (see constants.GraphQLApi usage in Create above). +func TestGraphQLAPIRepo_CreateSetsArtifactKind(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-kind-001" + projectUUID := "project-graphql-kind-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + api := newTestGraphQLAPI("kind-graphql", orgUUID, projectUUID) + if err := repo.Create(api); err != nil { + t.Fatalf("Create failed: %v", err) + } + + var artifactType string + err := db.QueryRow("SELECT type FROM artifacts WHERE uuid = ?", api.ID).Scan(&artifactType) + if err != nil { + t.Fatalf("failed to read artifact type: %v", err) + } + if artifactType != constants.GraphQLApi { + t.Fatalf("expected artifact type %s, got %s", constants.GraphQLApi, artifactType) + } +} + +func TestGraphQLAPIRepo_GetByHandle(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-handle-001" + projectUUID := "project-graphql-handle-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + api := newTestGraphQLAPI("handle-graphql", orgUUID, projectUUID) + if err := repo.Create(api); err != nil { + t.Fatalf("Create failed: %v", err) + } + + found, err := repo.GetByHandle(api.Handle, orgUUID) + if err != nil { + t.Fatalf("GetByHandle failed: %v", err) + } + if found == nil || found.ID != api.ID { + t.Fatalf("GetByHandle returned unexpected result: %+v", found) + } + + notFound, err := repo.GetByHandle("does-not-exist", orgUUID) + if err != nil { + t.Fatalf("GetByHandle for unknown handle returned error: %v", err) + } + if notFound != nil { + t.Fatalf("expected nil for unknown handle, got %+v", notFound) + } +} + +// TestGraphQLAPIRepo_CrossOrgIsolation guards GO-AUTH-005-style tenant +// isolation at the repository layer: a handle/UUID that exists in one org must +// never resolve when queried with a different org's UUID. +func TestGraphQLAPIRepo_CrossOrgIsolation(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-iso-001" + otherOrgUUID := "org-graphql-iso-002" + projectUUID := "project-graphql-iso-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + createTestOrganizationAndProject(t, db, otherOrgUUID, "project-graphql-iso-002") + + api := newTestGraphQLAPI("iso-graphql", orgUUID, projectUUID) + if err := repo.Create(api); err != nil { + t.Fatalf("Create failed: %v", err) + } + + if found, err := repo.GetByHandle(api.Handle, otherOrgUUID); err != nil || found != nil { + t.Fatalf("GetByHandle across orgs = (%+v, %v), want (nil, nil)", found, err) + } + if found, err := repo.GetByUUID(api.ID, otherOrgUUID); err != nil || found != nil { + t.Fatalf("GetByUUID across orgs = (%+v, %v), want (nil, nil)", found, err) + } +} + +// TestGraphQLAPIRepo_CreateSameHandleDifferentOrgs_Succeeds is the mirror +// image of TestGraphQLAPIRepo_CrossOrgIsolation: the same handle string must +// be independently creatable in two different orgs (the uniqueness +// constraint is scoped to org_id, not global) — otherwise a tenant could be +// blocked from using a handle another, unrelated tenant already picked. +func TestGraphQLAPIRepo_CreateSameHandleDifferentOrgs_Succeeds(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-samehandle-001" + otherOrgUUID := "org-graphql-samehandle-002" + projectUUID := "project-graphql-samehandle-001" + otherProjectUUID := "project-graphql-samehandle-002" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + createTestOrganizationAndProject(t, db, otherOrgUUID, otherProjectUUID) + + first := newTestGraphQLAPI("shared-handle", orgUUID, projectUUID) + if err := repo.Create(first); err != nil { + t.Fatalf("Create in first org failed: %v", err) + } + + second := newTestGraphQLAPI("shared-handle", otherOrgUUID, otherProjectUUID) + if err := repo.Create(second); err != nil { + t.Fatalf("Create with the same handle in a different org should succeed, got: %v", err) + } + + if found, err := repo.GetByHandle("shared-handle", orgUUID); err != nil || found == nil { + t.Fatalf("GetByHandle in first org = (%+v, %v), want a result", found, err) + } + if found, err := repo.GetByHandle("shared-handle", otherOrgUUID); err != nil || found == nil { + t.Fatalf("GetByHandle in second org = (%+v, %v), want a result", found, err) + } +} + +func TestGraphQLAPIRepo_List(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-list-001" + projectUUID := "project-graphql-list-001" + otherProjectUUID := "project-graphql-list-002" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + projectQuery := `INSERT INTO projects (uuid, handle, display_name, organization_uuid, created_at, updated_at) + VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))` + if _, err := db.Exec(projectQuery, otherProjectUUID, "other-project-list-001", "Other Project", orgUUID); err != nil { + t.Fatalf("failed to create second project: %v", err) + } + + apiInProject := newTestGraphQLAPI("list-graphql-a", orgUUID, projectUUID) + apiInOtherProject := newTestGraphQLAPI("list-graphql-b", orgUUID, otherProjectUUID) + if err := repo.Create(apiInProject); err != nil { + t.Fatalf("Create failed: %v", err) + } + if err := repo.Create(apiInOtherProject); err != nil { + t.Fatalf("Create failed: %v", err) + } + + all, err := repo.List(orgUUID, "", ListOptions{Limit: 100, Offset: 0}) + if err != nil { + t.Fatalf("List (no project filter) failed: %v", err) + } + if len(all) != 2 { + t.Fatalf("expected 2 GraphQL APIs for org, got %d", len(all)) + } + + filtered, err := repo.List(orgUUID, projectUUID, ListOptions{Limit: 100, Offset: 0}) + if err != nil { + t.Fatalf("List (project filter) failed: %v", err) + } + if len(filtered) != 1 || filtered[0].Handle != apiInProject.Handle { + t.Fatalf("expected only %s scoped to project, got %+v", apiInProject.Handle, filtered) + } + + otherOrg := "org-graphql-list-002" + createTestOrganizationAndProject(t, db, otherOrg, "project-graphql-list-other-org") + emptyList, err := repo.List(otherOrg, "", ListOptions{Limit: 100, Offset: 0}) + if err != nil { + t.Fatalf("List for a different org failed: %v", err) + } + if len(emptyList) != 0 { + t.Fatalf("expected empty list for a different org, got %+v", emptyList) + } +} + +// TestGraphQLAPIRepo_List_PaginationBoundaries exercises an actual page +// boundary (limit smaller than the total row count, non-zero offset) — +// TestGraphQLAPIRepo_List only ever passes limit=100 against a 1-2 row +// dataset, which can't distinguish "pagination works" from "pagination is a +// no-op because nothing was ever truncated." +func TestGraphQLAPIRepo_List_PaginationBoundaries(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-page-001" + projectUUID := "project-graphql-page-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + // Created in order a, b, c; List orders by created_at DESC, so the + // expected page order is c, b, a. + for _, handle := range []string{"page-graphql-a", "page-graphql-b", "page-graphql-c"} { + if err := repo.Create(newTestGraphQLAPI(handle, orgUUID, projectUUID)); err != nil { + t.Fatalf("Create %s failed: %v", handle, err) + } + } + + page1, err := repo.List(orgUUID, "", ListOptions{Limit: 1, Offset: 0}) + if err != nil { + t.Fatalf("List (limit=1, offset=0) failed: %v", err) + } + if len(page1) != 1 || page1[0].Handle != "page-graphql-c" { + t.Fatalf("expected page 1 = [page-graphql-c], got %+v", page1) + } + + page2, err := repo.List(orgUUID, "", ListOptions{Limit: 1, Offset: 1}) + if err != nil { + t.Fatalf("List (limit=1, offset=1) failed: %v", err) + } + if len(page2) != 1 || page2[0].Handle != "page-graphql-b" { + t.Fatalf("expected page 2 = [page-graphql-b], got %+v", page2) + } + + pastEnd, err := repo.List(orgUUID, "", ListOptions{Limit: 10, Offset: 3}) + if err != nil { + t.Fatalf("List (offset past the end) failed: %v", err) + } + if len(pastEnd) != 0 { + t.Fatalf("expected an empty page once offset exceeds the row count, got %+v", pastEnd) + } +} + +// TestGraphQLAPIRepo_List_Search pins the fix for the gap where List/ +// CountByProject silently ignored the spec's documented query parameter: a +// search with no matching handle must return an empty result and a total of +// 0, not the whole unfiltered collection. +func TestGraphQLAPIRepo_List_Search(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-search-001" + projectUUID := "project-graphql-search-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + if err := repo.Create(newTestGraphQLAPI("countries-graphql-api", orgUUID, projectUUID)); err != nil { + t.Fatalf("Create failed: %v", err) + } + + matched, err := repo.List(orgUUID, projectUUID, ListOptions{Limit: 100, Search: "countries"}) + if err != nil { + t.Fatalf("List (matching search) failed: %v", err) + } + if len(matched) != 1 { + t.Fatalf("expected 1 match for a search matching the handle, got %d", len(matched)) + } + + noMatch, err := repo.List(orgUUID, projectUUID, ListOptions{Limit: 100, Search: "zzz-no-match"}) + if err != nil { + t.Fatalf("List (non-matching search) failed: %v", err) + } + if len(noMatch) != 0 { + t.Fatalf("expected an empty result for a non-matching search, got %+v", noMatch) + } + + total, err := repo.CountByProject(orgUUID, projectUUID, "zzz-no-match") + if err != nil { + t.Fatalf("CountByProject (non-matching search) failed: %v", err) + } + if total != 0 { + t.Fatalf("expected total 0 for a non-matching search, got %d", total) + } +} + +// TestGraphQLAPIRepo_List_SortBy pins sortBy=name changing the ordering +// (previously always ORDER BY created_at DESC regardless of the request), +// and that an unrecognized sortBy token falls back to the default order +// (matching the shared allowlist's documented fallback behavior) rather than +// erroring. +func TestGraphQLAPIRepo_List_SortBy(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-sort-001" + projectUUID := "project-graphql-sort-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + fixtures := []struct{ handle, name string }{ + {"sort-graphql-a", "Charlie API"}, + {"sort-graphql-b", "Alpha API"}, + {"sort-graphql-c", "Bravo API"}, + } + for _, f := range fixtures { + a := newTestGraphQLAPI(f.handle, orgUUID, projectUUID) + a.Name = f.name + if err := repo.Create(a); err != nil { + t.Fatalf("Create %s failed: %v", f.handle, err) + } + } + + byNameAsc, err := repo.List(orgUUID, projectUUID, ListOptions{Limit: 100, SortBy: "name", SortOrder: "asc"}) + if err != nil { + t.Fatalf("List (sortBy=name, asc) failed: %v", err) + } + if len(byNameAsc) != 3 || byNameAsc[0].Name != "Alpha API" || byNameAsc[1].Name != "Bravo API" || byNameAsc[2].Name != "Charlie API" { + names := make([]string, len(byNameAsc)) + for i, a := range byNameAsc { + names[i] = a.Name + } + t.Fatalf("expected [Alpha API, Bravo API, Charlie API], got %v", names) + } + + // An unrecognized sortBy token must fall back to the default order + // (created_at) rather than erroring or being interpolated into SQL — + // creation order here is a, b, c, so default DESC order is c, b, a. + fallback, err := repo.List(orgUUID, projectUUID, ListOptions{Limit: 100, SortBy: "not-a-real-column"}) + if err != nil { + t.Fatalf("List (unrecognized sortBy) failed: %v", err) + } + if len(fallback) != 3 || fallback[0].Handle != "sort-graphql-c" || fallback[1].Handle != "sort-graphql-b" || fallback[2].Handle != "sort-graphql-a" { + handles := make([]string, len(fallback)) + for i, a := range fallback { + handles[i] = a.Handle + } + t.Fatalf("expected fallback to default created_at DESC order [c, b, a], got %v", handles) + } +} + +func TestGraphQLAPIRepo_Update(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-update-001" + projectUUID := "project-graphql-update-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + api := newTestGraphQLAPI("update-graphql", orgUUID, projectUUID) + if err := repo.Create(api); err != nil { + t.Fatalf("Create failed: %v", err) + } + + api.Name = "Updated Countries API" + api.Description = "Updated description" + api.Configuration.SDL = "type Query { countries: [Country] country(code: ID!): Country }\ntype Country { code: String }" + api.Configuration.IntrospectionMode = "ENDPOINT" + + if err := repo.Update(api); err != nil { + t.Fatalf("Update failed: %v", err) + } + + updated, err := repo.GetByUUID(api.ID, orgUUID) + if err != nil { + t.Fatalf("GetByUUID failed: %v", err) + } + if updated == nil { + t.Fatal("GetByUUID returned nil") + } + if updated.Name != api.Name || updated.Description != api.Description { + t.Fatalf("Update changes not persisted: %+v", updated) + } + if updated.Configuration.SDL != api.Configuration.SDL || updated.Configuration.IntrospectionMode != api.Configuration.IntrospectionMode { + t.Fatalf("Update did not persist configuration changes: %+v", updated.Configuration) + } +} + +func TestGraphQLAPIRepo_Update_NotFound(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-update-404" + projectUUID := "project-graphql-update-404" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + ghost := newTestGraphQLAPI("does-not-exist", orgUUID, projectUUID) + err := repo.Update(ghost) + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("Update on a non-existent handle = %v, want sql.ErrNoRows", err) + } +} + +func TestGraphQLAPIRepo_Delete(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-delete-001" + projectUUID := "project-graphql-delete-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + api := newTestGraphQLAPI("delete-graphql", orgUUID, projectUUID) + if err := repo.Create(api); err != nil { + t.Fatalf("Create failed: %v", err) + } + + if err := repo.Delete(api.Handle, orgUUID); err != nil { + t.Fatalf("Delete failed: %v", err) + } + + deleted, err := repo.GetByUUID(api.ID, orgUUID) + if err != nil { + t.Fatalf("GetByUUID failed: %v", err) + } + if deleted != nil { + t.Fatalf("expected GraphQL API to be deleted, got: %+v", deleted) + } + + var count int + err = db.QueryRow("SELECT COUNT(*) FROM artifacts WHERE uuid = ?", api.ID).Scan(&count) + if err != nil && err != sql.ErrNoRows { + t.Fatalf("failed to verify artifact cleanup: %v", err) + } + if count != 0 { + t.Fatalf("expected artifact row to be removed, found %d", count) + } + + exists, err := repo.Exists(api.Handle, orgUUID) + if err != nil { + t.Fatalf("Exists failed: %v", err) + } + if exists { + t.Fatal("expected handle to no longer exist after delete") + } +} + +// TestGraphQLAPIRepo_Delete_CascadesRelatedRows is the real cascade test +// TestGraphQLAPIRepo_Delete couldn't be: that test never creates any +// deployment or gateway-association rows, so its own "0 rows remain" check +// is trivially true whether or not ON DELETE CASCADE actually fires. This +// test seeds a deployment and an artifact_gateway_mappings row first, so the +// post-delete zero-count genuinely exercises the FK chain +// (deployments/artifact_gateway_mappings -> artifacts(uuid) ON DELETE CASCADE) +// rather than asserting over an empty table. This is the first cascade-delete +// test in the repo for any artifact kind. +func TestGraphQLAPIRepo_Delete_CascadesRelatedRows(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-cascade-001" + projectUUID := "project-graphql-cascade-001" + gatewayUUID := "gateway-graphql-cascade-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + createTestGateway(t, db, gatewayUUID, orgUUID) + + api := newTestGraphQLAPI("cascade-graphql", orgUUID, projectUUID) + if err := repo.Create(api); err != nil { + t.Fatalf("Create failed: %v", err) + } + + insertDeployment(t, db, "deployment-graphql-cascade-001", "cascade-deployment", api.ID, orgUUID, gatewayUUID, time.Now()) + + mappingQuery := ` + INSERT INTO artifact_gateway_mappings (artifact_uuid, organization_uuid, gateway_uuid, created_at, updated_at) + VALUES (?, ?, ?, datetime('now'), datetime('now')) + ` + if _, err := db.Exec(mappingQuery, api.ID, orgUUID, gatewayUUID); err != nil { + t.Fatalf("failed to seed artifact_gateway_mappings: %v", err) + } + + if err := repo.Delete(api.Handle, orgUUID); err != nil { + t.Fatalf("Delete failed: %v", err) + } + + for _, tbl := range []string{"artifacts", "graphql_apis", "deployments", "artifact_gateway_mappings"} { + var count int + if err := db.QueryRow("SELECT COUNT(*) FROM "+tbl+" WHERE "+cascadeFKColumn(tbl)+" = ?", api.ID).Scan(&count); err != nil { + t.Fatalf("failed to verify %s cleanup: %v", tbl, err) + } + if count != 0 { + t.Errorf("expected all %s rows for this artifact to be gone after delete, found %d", tbl, count) + } + } +} + +// cascadeFKColumn returns the column each table keys its artifact reference +// by — "uuid" for the artifact's own primary-key tables, "artifact_uuid" for +// the generic child tables that reference it. +func cascadeFKColumn(table string) string { + if table == "artifacts" || table == "graphql_apis" { + return "uuid" + } + return "artifact_uuid" +} + +func TestGraphQLAPIRepo_Delete_NotFound(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-delete-404" + projectUUID := "project-graphql-delete-404" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + err := repo.Delete("does-not-exist", orgUUID) + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("Delete on a non-existent handle = %v, want sql.ErrNoRows", err) + } +} + +func TestGraphQLAPIRepo_Exists(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-exists-001" + projectUUID := "project-graphql-exists-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + api := newTestGraphQLAPI("exists-graphql", orgUUID, projectUUID) + if err := repo.Create(api); err != nil { + t.Fatalf("Create failed: %v", err) + } + + exists, err := repo.Exists(api.Handle, orgUUID) + if err != nil { + t.Fatalf("Exists failed: %v", err) + } + if !exists { + t.Fatal("expected handle to exist") + } + + exists, err = repo.Exists("unknown-handle", orgUUID) + if err != nil { + t.Fatalf("Exists for unknown handle failed: %v", err) + } + if exists { + t.Fatal("expected unknown handle to not exist") + } +} + +// TestGraphQLAPIRepo_CreateRecordsArtifactSecretRefs guards the {{ secret "..." }} +// reference-tracking path shared with REST (upsertArtifactSecretRefs) — a +// GraphQL upstream auth value referencing a secret must be recorded the same +// way a REST API's would be, so the secret's "in use" delete-protection sees it. +func TestGraphQLAPIRepo_CreateRecordsArtifactSecretRefs(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-secretref-001" + projectUUID := "project-graphql-secretref-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + api := newTestGraphQLAPI("secretref-graphql", orgUUID, projectUUID) + api.Configuration.Upstream.Main.Auth = &model.UpstreamAuth{ + Type: "header", + Header: "Authorization", + Value: `{{ secret "upstream-token" }}`, + } + if err := repo.Create(api); err != nil { + t.Fatalf("Create failed: %v", err) + } + + var refCount int + if err := db.QueryRow("SELECT COUNT(*) FROM artifact_secret_refs WHERE artifact_uuid = ? AND secret_handle = ?", api.ID, "upstream-token").Scan(&refCount); err != nil { + t.Fatalf("failed to count artifact_secret_refs: %v", err) + } + if refCount == 0 { + t.Fatal("expected an artifact_secret_refs row recording the {{ secret \"upstream-token\" }} reference") + } +} diff --git a/platform-api/internal/repository/interfaces.go b/platform-api/internal/repository/interfaces.go index f53a5ed278..3801baaf49 100644 --- a/platform-api/internal/repository/interfaces.go +++ b/platform-api/internal/repository/interfaces.go @@ -412,6 +412,39 @@ type MCPProxyRepository interface { EnsureGatewayAssociation(proxyUUID, gatewayUUID, orgUUID, createdBy, deployMetadata string, metadataProvided bool) (string, error) } +// GraphQLAPIRepository defines the interface for GraphQL API persistence. +// GraphQL is a core artifact kind (like RestApi/LlmProvider/LlmProxy/Mcp). No +// cross-service hooks are wired for it yet (unlike APIRepository, which +// plugin.Deps exposes for +// eventgateway to reference), so this interface is declared for the same +// service/repo decoupling and testability every other core kind gets, without +// also adding a plugin.Deps field until a real consumer needs one. +type GraphQLAPIRepository interface { + Create(a *model.GraphQLAPI) error + GetByHandle(handle, orgUUID string) (*model.GraphQLAPI, error) + GetByUUID(uuid, orgUUID string) (*model.GraphQLAPI, error) + List(orgUUID, projectUUID string, opts ListOptions) ([]*model.GraphQLAPI, error) + Count(orgUUID string) (int, error) + CountByProject(orgUUID, projectUUID, search string) (int, error) + Update(a *model.GraphQLAPI) error + Delete(handle, orgUUID string) error + Exists(handle, orgUUID string) (bool, error) + + // API-Gateway association methods. These operate on the same + // artifact_gateway_mappings table as APIRepository's identically-named + // methods — the table is kind-agnostic (keyed on artifact_uuid), so both + // interfaces are backed by the same shared repository helpers + // (createArtifactGatewayAssociation et al. in repository/api.go) rather + // than duplicated SQL. + GetAPIGatewaysWithDetails(apiUUID, orgUUID string) ([]*model.APIGatewayWithDetails, error) + CreateAPIAssociation(association *model.APIAssociation) error + GetAPIAssociations(apiUUID, associationType, orgUUID string) ([]*model.APIAssociation, error) + UpdateAPIAssociation(apiUUID, resourceId, associationType, orgUUID, updatedBy string) error + // EnsureGatewayAssociation creates a gateway association for the API if one + // does not already exist and resolves the metadata to use for the deployment. + EnsureGatewayAssociation(apiUUID, gatewayUUID, orgUUID, createdBy, deployMetadata string, metadataProvided bool) (string, error) +} + // WebSubAPIHmacSecretRepository defines the interface for WebSub API HMAC secret persistence type WebSubAPIHmacSecretRepository interface { Create(secret *model.WebSubAPIHmacSecret) error diff --git a/platform-api/internal/server/scope_route_coverage_test.go b/platform-api/internal/server/scope_route_coverage_test.go index adccf27aa9..e0487697ae 100644 --- a/platform-api/internal/server/scope_route_coverage_test.go +++ b/platform-api/internal/server/scope_route_coverage_test.go @@ -58,6 +58,9 @@ func registerAllRoutes(mux *http.ServeMux) { handler.NewAPIKeyUserHandler(nil, nil, "scope", logger).RegisterRoutes(mux) handler.NewMCPProxyHandler(nil, nil, logger).RegisterRoutes(mux) handler.NewMCPProxyDeploymentHandler(nil, nil, logger).RegisterRoutes(mux) + handler.NewGraphQLAPIHandler(nil, nil, logger).RegisterRoutes(mux) + handler.NewGraphQLAPIDeploymentHandler(nil, nil, logger).RegisterRoutes(mux) + handler.NewGraphQLAPIKeyHandler(nil, nil, "scope", logger).RegisterRoutes(mux) handler.NewSecretHandler(nil, nil, logger).RegisterRoutes(mux) // Plugin routes are registered on the same mux and their specs merged into diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index fa65546ab8..2aac1369cb 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -139,6 +139,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, llmProviderRepo := repository.NewLLMProviderRepo(db) llmProxyRepo := repository.NewLLMProxyRepo(db) mcpProxyRepo := repository.NewMCPProxyRepo(db) + graphqlAPIRepo := repository.NewGraphQLAPIRepo(db, artifactTableRegistry) apiKeyRepo := repository.NewAPIKeyRepo(db, artifactTableRegistry) auditRepo := repository.NewAuditRepo(db) secretRepo := repository.NewSecretRepo(db) @@ -258,7 +259,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, gatewayService := service.NewGatewayService(gatewayRepo, orgRepo, apiRepo, customPolicyRepo, gatewayEventsService, slogger, cfg.Gateway.EnableVersionVerification, cfg.Gateway.EnableFunctionalityTypeVerification, auditRepo, identityService) subscriptionService := service.NewSubscriptionService(apiRepo, artifactRepo, subscriptionRepo, subscriptionPlanRepo, orgRepo, gatewayEventsService, auditRepo, slogger) subscriptionPlanService := service.NewSubscriptionPlanService(subscriptionPlanRepo, gatewayRepo, orgRepo, gatewayEventsService, auditRepo, slogger) - internalGatewayService := service.NewGatewayInternalAPIService(apiRepo, subscriptionRepo, subscriptionPlanRepo, llmProviderRepo, llmProxyRepo, mcpProxyRepo, deploymentRepo, gatewayRepo, orgRepo, projectRepo, apiKeyRepo, artifactRepo, secretRepo, cfg, slogger) + internalGatewayService := service.NewGatewayInternalAPIService(apiRepo, subscriptionRepo, subscriptionPlanRepo, llmProviderRepo, llmProxyRepo, mcpProxyRepo, graphqlAPIRepo, deploymentRepo, gatewayRepo, orgRepo, projectRepo, apiKeyRepo, artifactRepo, secretRepo, cfg, slogger) apiKeyService := service.NewAPIKeyService(apiRepo, artifactRepo, apiKeyRepo, gatewayEventsService, auditRepo, cfg.Security.APIKey.HashingAlgorithms, slogger) // One definition per artifact kind, indexed by the kind the artifact row // carries. Builds and deployments are shared across kinds; rendering is the @@ -268,6 +269,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, service.NewMCPProxyDefinition(mcpProxyRepo, &utils.MCPUtils{}), service.NewLLMProxyDefinition(llmProxyRepo), service.NewLLMProviderDefinition(llmProviderRepo, llmTemplateRepo), + service.NewGraphQLAPIDefinition(graphqlAPIRepo), ) deploymentService := service.NewDeploymentService(apiRepo, artifactRepo, deploymentRepo, gatewayRepo, orgRepo, apiKeyRepo, gatewayEventsService, auditRepo, apiUtil, artifactDefinitions, cfg, slogger) llmTemplateService := service.NewLLMProviderTemplateService(llmTemplateRepo, auditRepo, identityService) @@ -275,6 +277,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, llmProviderService.SetCustomPolicyRepository(customPolicyRepo) llmProxyService := service.NewLLMProxyService(llmProxyRepo, llmProviderRepo, projectRepo, deploymentRepo, gatewayRepo, gatewayEventsService, slogger, auditRepo, cfg, identityService) mcpProxyService := service.NewMCPProxyService(mcpProxyRepo, projectRepo, deploymentRepo, gatewayRepo, gatewayEventsService, slogger, auditRepo, cfg, identityService) + graphqlAPIService := service.NewGraphQLAPIService(graphqlAPIRepo, projectRepo, auditRepo, deploymentRepo, gatewayRepo, orgRepo, gatewayEventsService, identityService, slogger) // The single configured encryption key (APIP_CP_ENCRYPTION_KEY) is used for all encrypted DB // columns (secrets, subscription tokens, WebSub HMAC secrets) @@ -319,6 +322,18 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, cfg, slogger, ) + graphqlAPIDeploymentService := service.NewGraphQLAPIDeploymentService( + graphqlAPIRepo, + deploymentRepo, + gatewayRepo, + orgRepo, + apiKeyRepo, + artifactRepo, + gatewayEventsService, + artifactDefinitions, + cfg, + slogger, + ) // One place that knows which service serves which artifact kind, so plugins and // the per-kind paths reach the same code. deploymentsByKind := service.NewDeploymentsByKind( @@ -333,6 +348,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, llmTemplateRepo, llmProxyRepo, mcpProxyRepo, + graphqlAPIRepo, artifactRepo, deploymentRepo, gatewayRepo, @@ -382,12 +398,17 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, llmProxyDeploymentHandler := handler.NewLLMProxyDeploymentHandler(llmProxyDeploymentService, identityService, slogger) mcpProxyHandler := handler.NewMCPProxyHandler(mcpProxyService, identityService, slogger) mcpProxyDeploymentHandler := handler.NewMCPProxyDeploymentHandler(mcpDeploymentService, identityService, slogger) + graphqlAPIHandler := handler.NewGraphQLAPIHandler(graphqlAPIService, identityService, slogger) + graphqlAPIKeyHandler := handler.NewGraphQLAPIKeyHandler(apiKeyService, identityService, cfg.Auth.Authorization.Mode, slogger) + graphqlAPIDeploymentHandler := handler.NewGraphQLAPIDeploymentHandler(graphqlAPIDeploymentService, identityService, slogger) // Wire secret placeholder validation into dependent services llmProviderService.SetSecretService(secretService) llmProviderDeploymentService.SetSecretService(secretService) llmProxyService.SetSecretService(secretService) mcpProxyService.WithSecretService(secretService) apiService.SetSecretService(secretService) + graphqlAPIService.SetSecretService(secretService) + graphqlAPIService.SetMaxSDLFetchBytes(cfg.OpenAPISpecMaxFetchBytes) secretHandler := handler.NewSecretHandler(secretService, identityService, slogger) // Start deployment timeout background job timeoutConfig := service.DeploymentTimeoutConfig{ @@ -444,6 +465,9 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, llmProxyDeploymentHandler.RegisterRoutes(core) mcpProxyHandler.RegisterRoutes(core) mcpProxyDeploymentHandler.RegisterRoutes(core) + graphqlAPIHandler.RegisterRoutes(core) + graphqlAPIKeyHandler.RegisterRoutes(core) + graphqlAPIDeploymentHandler.RegisterRoutes(core) secretHandler.RegisterRoutes(core) // Initialize plugins and register their routes. diff --git a/platform-api/internal/service/artifact_definition.go b/platform-api/internal/service/artifact_definition.go index 44a2834734..845631dfb5 100644 --- a/platform-api/internal/service/artifact_definition.go +++ b/platform-api/internal/service/artifact_definition.go @@ -284,3 +284,42 @@ func (d *mcpProxyDefinition) Decode(content []byte) (any, error) { } return definition, nil } + +// graphqlAPIDefinition renders GraphQL APIs. +type graphqlAPIDefinition struct { + graphqlRepo repository.GraphQLAPIRepository +} + +// NewGraphQLAPIDefinition returns the ArtifactDefinition for GraphQL APIs. +func NewGraphQLAPIDefinition(graphqlRepo repository.GraphQLAPIRepository) ArtifactDefinition { + return &graphqlAPIDefinition{graphqlRepo: graphqlRepo} +} + +func (d *graphqlAPIDefinition) Kind() string { return constants.GraphQLApi } + +func (d *graphqlAPIDefinition) Current(artifact *model.Artifact) (*ArtifactSnapshot, error) { + apiModel, err := d.graphqlRepo.GetByUUID(artifact.UUID, artifact.OrganizationUUID) + if err != nil { + return nil, err + } + if apiModel == nil { + return nil, apperror.GraphQLAPINotFound.New() + } + definition, err := generateGraphQLAPIDeploymentYAML(apiModel) + if err != nil { + return nil, fmt.Errorf("failed to generate GraphQL API deployment YAML: %w", err) + } + return &ArtifactSnapshot{ + Definition: &definition, + DataVersion: apiModel.DataVersion, + Origin: apiModel.Origin, + }, nil +} + +func (d *graphqlAPIDefinition) Decode(content []byte) (any, error) { + definition := &dto.GraphQLAPIDeploymentYAML{} + if err := yaml.Unmarshal(content, definition); err != nil { + return nil, fmt.Errorf("failed to parse stored GraphQL API deployment YAML: %w", err) + } + return definition, nil +} diff --git a/platform-api/internal/service/artifact_dp_apikey_test.go b/platform-api/internal/service/artifact_dp_apikey_test.go index 02cda7f72d..2c056f7e67 100644 --- a/platform-api/internal/service/artifact_dp_apikey_test.go +++ b/platform-api/internal/service/artifact_dp_apikey_test.go @@ -81,6 +81,24 @@ func (c *dpCapturingAPIKeyRepo) Create(k *model.APIKey) error { return nil } +// GetByArtifactAndName reports no existing key by that name (used by +// APIKeyService.resolveUniqueKeyName's collision check, and by +// Update/RevokeAPIKey's ownership lookup once a key has been created). +func (c *dpCapturingAPIKeyRepo) GetByArtifactAndName(artifactUUID, name string) (*model.APIKey, error) { + if c.created != nil && c.created.ArtifactUUID == artifactUUID && c.created.Name == name { + return c.created, nil + } + return nil, nil +} + +// Revoke marks the captured key revoked, for tests exercising RevokeAPIKey. +func (c *dpCapturingAPIKeyRepo) Revoke(artifactUUID, name, updatedBy string) error { + if c.created != nil && c.created.ArtifactUUID == artifactUUID && c.created.Name == name { + c.created.Status = "revoked" + } + return nil +} + func newDPKeyEventsService() *GatewayEventsService { return NewGatewayEventsService(dpNoopEventHub{}, newTestIdentityService(), newTestLogger()) } diff --git a/platform-api/internal/service/artifact_import.go b/platform-api/internal/service/artifact_import.go index a45b249236..155191ed72 100644 --- a/platform-api/internal/service/artifact_import.go +++ b/platform-api/internal/service/artifact_import.go @@ -117,6 +117,7 @@ func NewArtifactImportService( templateRepo repository.LLMProviderTemplateRepository, proxyRepo repository.LLMProxyRepository, mcpProxyRepo repository.MCPProxyRepository, + graphqlAPIRepo repository.GraphQLAPIRepository, artifactRepo repository.ArtifactRepository, deploymentRepo repository.DeploymentRepository, gatewayRepo repository.GatewayRepository, @@ -140,6 +141,7 @@ func NewArtifactImportService( constants.LLMProviderTemplate: newLLMProviderTemplateImporter(templateRepo), constants.LLMProxy: newLLMProxyImporter(proxyRepo, providerRepo, artifactRepo), constants.MCPProxy: newMCPProxyImporter(mcpProxyRepo, artifactRepo, mcpServerInfo), + constants.GraphQLApi: newGraphQLAPIImporter(graphqlAPIRepo, artifactRepo), } return s } diff --git a/platform-api/internal/service/artifact_import_graphql.go b/platform-api/internal/service/artifact_import_graphql.go new file mode 100644 index 0000000000..8eb4bf8008 --- /dev/null +++ b/platform-api/internal/service/artifact_import_graphql.go @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +import ( + "fmt" + + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/utils" +) + +// graphqlAPIImporter imports GraphQL API artifacts (project-scoped). +type graphqlAPIImporter struct { + graphqlAPIRepo repository.GraphQLAPIRepository + artifactRepo repository.ArtifactRepository +} + +func newGraphQLAPIImporter(graphqlAPIRepo repository.GraphQLAPIRepository, artifactRepo repository.ArtifactRepository) *graphqlAPIImporter { + return &graphqlAPIImporter{graphqlAPIRepo: graphqlAPIRepo, artifactRepo: artifactRepo} +} + +func (i *graphqlAPIImporter) Kind() string { return constants.GraphQLApi } +func (i *graphqlAPIImporter) RequiresProject() bool { return true } + +func (i *graphqlAPIImporter) Import(ctx *ImportContext) (*ImportResult, error) { + version := utils.ImportVersion(ctx.Configuration) + + // The gateway pushes the artifact spec in the same shape the control plane emits + // when generating a deployment (context + upstream only — see + // generateGraphQLAPIDeploymentYAML in graphql_deployment.go). It never carries the + // schema, so SDL/introspectionMode come back empty from the decode and are + // resolved separately below, mirroring mcpProxyImporter's out-of-band capability + // fetch. + var cfg model.GraphQLAPIConfig + if err := utils.DecodeSpec(ctx.Configuration.Spec, &cfg); err != nil { + return nil, err + } + + if ctx.Existing == nil { + cfg.SDL, cfg.IntrospectionMode, _ = i.resolveSchema(cfg.Upstream.Main) + projectID := ctx.ProjectID + graphqlAPI := &model.GraphQLAPI{ + ID: ctx.ID, + Handle: utils.ImportHandle(ctx.Configuration), + Name: utils.ImportDisplayName(ctx.Configuration), + Kind: constants.GraphQLApi, + Version: version, + ProjectID: projectID, + OrganizationID: ctx.OrgID, + Origin: constants.OriginDP, + Configuration: cfg, + } + if err := i.graphqlAPIRepo.Create(graphqlAPI); err != nil { + return nil, fmt.Errorf("failed to create GraphQL API from gateway import: %w", err) + } + return &ImportResult{ID: graphqlAPI.ID, DeployedVersion: version, Deployable: true}, nil + } + + existing, err := i.graphqlAPIRepo.GetByUUID(ctx.ID, ctx.OrgID) + if err != nil { + return nil, fmt.Errorf("failed to load existing GraphQL API: %w", err) + } + if existing == nil { + return &ImportResult{ID: ctx.ID, DeployedVersion: version, Deployable: true}, nil + } + + switch ctx.MetadataMode { + case utils.SkipWorkingCopy: + // Stale, out-of-order push: a newer deployment already defines the working copy. + return &ImportResult{ID: ctx.ID, DeployedVersion: version, Deployable: true}, nil + case utils.WriteFullMetadata: + existing.Name = utils.ImportDisplayName(ctx.Configuration) + existing.Version = version + existing.ProjectID = ctx.ProjectID + // Refresh the schema from the (possibly new) upstream alongside the rest of + // the configuration, the same as at create time — but unlike create, a + // failed resolution here keeps the previously-stored schema instead of + // blanking it out (mirrors GraphQLAPIService.Update's same posture): a + // transient upstream issue during a metadata-only re-import must not + // destroy a schema that was working before this push. + if sdl, mode, ok := i.resolveSchema(cfg.Upstream.Main); ok { + cfg.SDL, cfg.IntrospectionMode = sdl, mode + } else { + cfg.SDL, cfg.IntrospectionMode = existing.Configuration.SDL, existing.Configuration.IntrospectionMode + } + existing.Configuration = cfg + case utils.WriteGatewaySpecificOnly: + // CP-owned: only the upstream is gateway-specific data; SDL/name/etc. are not + // touched. + existing.Configuration.Upstream = cfg.Upstream + } + if err := i.graphqlAPIRepo.Update(existing); err != nil { + return nil, fmt.Errorf("failed to update GraphQL API from gateway import: %w", err) + } + return &ImportResult{ID: ctx.ID, DeployedVersion: version, Deployable: true}, nil +} + +// resolveSchema derives SDL/introspectionMode via the same introspection path +// CP-native create/update uses (fetchAndConvertGraphQLSchema, graphql_introspection.go), +// since the gateway-pushed spec never carries the schema. Best-effort, mirroring +// mcpProxyImporter.fetchCapabilities: an unreachable or misbehaving upstream must +// not fail the whole import — ok reports whether resolution actually succeeded, +// so a caller updating an existing artifact can keep its previously-stored +// schema instead of blanking it out, the same way GraphQLAPIService.Update does. +func (i *graphqlAPIImporter) resolveSchema(upstreamMain *model.UpstreamEndpoint) (sdl, introspectionMode string, ok bool) { + if upstreamMain == nil || upstreamMain.URL == "" { + return "", "", false + } + derived, err := fetchAndConvertGraphQLSchema(upstreamMain.URL) + if err != nil { + return "", "", false + } + return derived, "ENDPOINT", true +} diff --git a/platform-api/internal/service/artifact_import_test.go b/platform-api/internal/service/artifact_import_test.go index 9089049297..c0c18ca3b4 100644 --- a/platform-api/internal/service/artifact_import_test.go +++ b/platform-api/internal/service/artifact_import_test.go @@ -69,12 +69,13 @@ func withDeployedAt(req dto.ImportGatewayArtifactRequest, t time.Time) dto.Impor // importTestDeps bundles the import service with the repos and db needed for assertions. type importTestDeps struct { - svc *ArtifactImportService - db *database.DB - artifactRepo repository.ArtifactRepository - apiRepo repository.APIRepository - templateRepo repository.LLMProviderTemplateRepository - deployment repository.DeploymentRepository + svc *ArtifactImportService + db *database.DB + artifactRepo repository.ArtifactRepository + apiRepo repository.APIRepository + templateRepo repository.LLMProviderTemplateRepository + deployment repository.DeploymentRepository + graphqlAPIRepo repository.GraphQLAPIRepository } func setupImportTest(t *testing.T) *importTestDeps { @@ -126,21 +127,23 @@ func setupImportTest(t *testing.T) *importTestDeps { deploymentRepo := repository.NewDeploymentRepo(db, reg) gatewayRepo := repository.NewGatewayRepo(db) projectRepo := repository.NewProjectRepo(db) + graphqlAPIRepo := repository.NewGraphQLAPIRepo(db, reg) cfg := &config.Server{} cfg.Deployments.MaxPerAPIGateway = 10 logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - svc := NewArtifactImportService(apiRepo, providerRepo, templateRepo, proxyRepo, mcpProxyRepo, + svc := NewArtifactImportService(apiRepo, providerRepo, templateRepo, proxyRepo, mcpProxyRepo, graphqlAPIRepo, artifactRepo, deploymentRepo, gatewayRepo, projectRepo, cfg, logger, fakeMCPServerInfoFetcher{}) return &importTestDeps{ - svc: svc, - db: db, - artifactRepo: artifactRepo, - apiRepo: apiRepo, - templateRepo: templateRepo, - deployment: deploymentRepo, + svc: svc, + db: db, + artifactRepo: artifactRepo, + apiRepo: apiRepo, + templateRepo: templateRepo, + deployment: deploymentRepo, + graphqlAPIRepo: graphqlAPIRepo, } } @@ -206,6 +209,77 @@ func TestArtifactImport_CreateRestAPI(t *testing.T) { } } +func graphqlImportRequest(id, name, displayName string) dto.ImportGatewayArtifactRequest { + return dto.ImportGatewayArtifactRequest{ + DPID: id, + Status: "deployed", + Configuration: dto.ArtifactImportConfig{ + APIVersion: "gateway.api-platform.wso2.com/v1", + Kind: constants.GraphQLApi, + Metadata: dto.ArtifactImportMetadata{Name: name, Annotations: projectAnnotations("default")}, + Spec: map[string]interface{}{ + "displayName": displayName, + "version": "v1.0", + "context": "/countries", + // upstream is deliberately omitted so resolveSchema's introspection + // fetch short-circuits without a real network call — this test only + // asserts the artifact lands, not schema resolution. + }, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} + +func TestArtifactImport_CreateGraphQLAPI(t *testing.T) { + d := setupImportTest(t) + + const id = "55555555-5555-5555-5555-555555555555" + resp, err := d.svc.Import(importTestOrgID, importTestGatewayID, graphqlImportRequest(id, "countries-graphql", "Countries GraphQL API")) + if err != nil { + t.Fatalf("Import() error = %v", err) + } + + // The control plane mints its own UUID; it must NOT reuse the data-plane UUID. + if resp.ID == "" || resp.ID == id { + t.Errorf("response ID = %q, want a freshly generated CP UUID (not the DP UUID %q)", resp.ID, id) + } + cpID := resp.ID + if resp.Origin != constants.OriginDP { + t.Errorf("response Origin = %q, want DP", resp.Origin) + } + + // Artifact row should exist with origin DP and kind GraphQLApi under the + // CP-generated UUID. + art, err := d.artifactRepo.GetByUUID(cpID, importTestOrgID) + if err != nil || art == nil { + t.Fatalf("GetByUUID returned (%v, %v)", art, err) + } + if art.Origin != constants.OriginDP { + t.Errorf("artifact origin = %q, want DP", art.Origin) + } + if art.Type != constants.GraphQLApi { + t.Errorf("artifact kind = %q, want GraphQLApi", art.Type) + } + + graphqlAPI, err := d.graphqlAPIRepo.GetByUUID(cpID, importTestOrgID) + if err != nil || graphqlAPI == nil { + t.Fatalf("GetByUUID returned (%v, %v)", graphqlAPI, err) + } + if graphqlAPI.Kind != constants.GraphQLApi { + t.Errorf("GraphQLAPI.Kind = %q, want GraphQLApi", graphqlAPI.Kind) + } + + // Deployment status should be DEPLOYED on the gateway. + depID, status, _, err := d.deployment.GetStatus(cpID, importTestOrgID, importTestGatewayID) + if err != nil { + t.Fatalf("GetStatus error = %v", err) + } + if depID == "" || status != model.DeploymentStatusDeployed { + t.Errorf("deployment status = (%q,%q), want non-empty DEPLOYED", depID, status) + } +} + func TestArtifactImport_UnsupportedKind(t *testing.T) { d := setupImportTest(t) req := restImportRequest("22222222-2222-2222-2222-222222222222", "x", "X") @@ -726,6 +800,7 @@ func TestArtifactImport_AllSupportedKindsRegistered(t *testing.T) { constants.LLMProviderTemplate, constants.LLMProxy, constants.MCPProxy, + constants.GraphQLApi, } { importer, ok := d.svc.importers[kind] if !ok { diff --git a/platform-api/internal/service/deployment_test.go b/platform-api/internal/service/deployment_test.go index 603e25c00a..eed1542ca1 100644 --- a/platform-api/internal/service/deployment_test.go +++ b/platform-api/internal/service/deployment_test.go @@ -280,6 +280,7 @@ type mockDeploymentRepo struct { setCurrentStatus model.DeploymentStatus setCurrentPerformedAt *time.Time deleteCalled bool + createdDeployment *model.Deployment } func (m *mockDeploymentRepo) GetWithContent(deploymentID, artifactUUID, orgUUID string) (*model.Deployment, error) { @@ -343,7 +344,13 @@ func (m *mockDeploymentRepo) CreateFromBuildWithLimitEnforcement(deployment *mod return m.CreateWithLimitEnforcement(deployment, hardLimit) } +func (m *mockDeploymentRepo) CreateWithBuild(deployment *model.Deployment, _ *model.Build, + _, hardLimit int) error { + return m.CreateWithLimitEnforcement(deployment, hardLimit) +} + func (m *mockDeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment, hardLimit int) error { + m.createdDeployment = deployment return m.createWithLimitError } diff --git a/platform-api/internal/service/gateway_events.go b/platform-api/internal/service/gateway_events.go index 095bb0aea8..23645e0ee0 100644 --- a/platform-api/internal/service/gateway_events.go +++ b/platform-api/internal/service/gateway_events.go @@ -64,6 +64,10 @@ const ( EventTypeWebBrokerAPIUndeployed = "webbroker.undeployed" EventTypeWebBrokerAPIDeleted = "webbroker.deleted" + EventTypeGraphQLAPIDeployed = "graphqlapi.deployed" + EventTypeGraphQLAPIUndeployed = "graphqlapi.undeployed" + EventTypeGraphQLAPIDeleted = "graphqlapi.deleted" + EventTypeAPIKeyCreated = "apikey.created" EventTypeAPIKeyRevoked = "apikey.revoked" EventTypeAPIKeyUpdated = "apikey.updated" @@ -179,6 +183,21 @@ func (s *GatewayEventsService) BroadcastWebBrokerAPIDeletionEvent(gatewayID stri return s.broadcastEvent(gatewayID, EventTypeWebBrokerAPIDeleted, deletion) } +// BroadcastGraphQLAPIDeploymentEvent sends a GraphQL API deployment event to target gateway. +func (s *GatewayEventsService) BroadcastGraphQLAPIDeploymentEvent(gatewayID string, deployment *model.GraphQLAPIDeploymentEvent) error { + return s.broadcastEvent(gatewayID, EventTypeGraphQLAPIDeployed, deployment) +} + +// BroadcastGraphQLAPIUndeploymentEvent sends a GraphQL API undeployment event to target gateway. +func (s *GatewayEventsService) BroadcastGraphQLAPIUndeploymentEvent(gatewayID string, undeployment *model.GraphQLAPIUndeploymentEvent) error { + return s.broadcastEvent(gatewayID, EventTypeGraphQLAPIUndeployed, undeployment) +} + +// BroadcastGraphQLAPIDeletionEvent sends a GraphQL API deletion event to target gateway. +func (s *GatewayEventsService) BroadcastGraphQLAPIDeletionEvent(gatewayID string, deletion *model.GraphQLAPIDeletionEvent) error { + return s.broadcastEvent(gatewayID, EventTypeGraphQLAPIDeleted, deletion) +} + // BroadcastLLMProviderDeletionEvent sends an LLM provider deletion event to target gateway. func (s *GatewayEventsService) BroadcastLLMProviderDeletionEvent(gatewayID string, deletion *model.LLMProviderDeletionEvent) error { return s.broadcastEvent(gatewayID, EventTypeLLMProviderDeleted, deletion) diff --git a/platform-api/internal/service/gateway_internal.go b/platform-api/internal/service/gateway_internal.go index 11b7cec3d4..3b073b6fef 100644 --- a/platform-api/internal/service/gateway_internal.go +++ b/platform-api/internal/service/gateway_internal.go @@ -39,6 +39,7 @@ type GatewayInternalAPIService struct { providerRepo repository.LLMProviderRepository proxyRepo repository.LLMProxyRepository mcpProxyRepo repository.MCPProxyRepository + graphqlAPIRepo repository.GraphQLAPIRepository websubAPIRepo repository.WebSubAPIRepository webbrokerAPIRepo repository.WebBrokerAPIRepository deploymentRepo repository.DeploymentRepository @@ -58,7 +59,7 @@ type GatewayInternalAPIService struct { // event-gateway plugin in experimental builds via SetEventArtifactRepos. func NewGatewayInternalAPIService(apiRepo repository.APIRepository, subscriptionRepo repository.SubscriptionRepository, subscriptionPlanRepo repository.SubscriptionPlanRepository, providerRepo repository.LLMProviderRepository, - proxyRepo repository.LLMProxyRepository, mcpProxyRepo repository.MCPProxyRepository, + proxyRepo repository.LLMProxyRepository, mcpProxyRepo repository.MCPProxyRepository, graphqlAPIRepo repository.GraphQLAPIRepository, deploymentRepo repository.DeploymentRepository, gatewayRepo repository.GatewayRepository, orgRepo repository.OrganizationRepository, projectRepo repository.ProjectRepository, apiKeyRepo repository.APIKeyRepository, artifactRepo repository.ArtifactRepository, secretRepo repository.SecretRepository, cfg *config.Server, slogger *slog.Logger) *GatewayInternalAPIService { @@ -69,6 +70,7 @@ func NewGatewayInternalAPIService(apiRepo repository.APIRepository, subscription providerRepo: providerRepo, proxyRepo: proxyRepo, mcpProxyRepo: mcpProxyRepo, + graphqlAPIRepo: graphqlAPIRepo, deploymentRepo: deploymentRepo, gatewayRepo: gatewayRepo, orgRepo: orgRepo, @@ -354,6 +356,31 @@ func (s *GatewayInternalAPIService) GetActiveMCPProxyDeploymentByGateway(proxyID return proxyYamlMap, nil } +// GetActiveGraphQLAPIDeploymentByGateway retrieves the currently deployed GraphQL API artifact for a specific gateway +func (s *GatewayInternalAPIService) GetActiveGraphQLAPIDeploymentByGateway(apiID, orgID, gatewayID string) (map[string]string, error) { + graphqlAPI, err := s.graphqlAPIRepo.GetByUUID(apiID, orgID) + if err != nil { + return nil, fmt.Errorf("failed to get GraphQL API: %w", err) + } + if graphqlAPI == nil { + return nil, apperror.GraphQLAPINotFound.New() + } + + deployment, err := s.deploymentRepo.GetCurrentByGateway(graphqlAPI.ID, gatewayID, orgID) + if err != nil { + return nil, fmt.Errorf("failed to get deployment: %w", err) + } + if deployment == nil { + return nil, apperror.DeploymentNotActive.New("GraphQL API") + } + + apiYaml := string(deployment.Content) + apiYamlMap := map[string]string{ + apiID: apiYaml, + } + return apiYamlMap, nil +} + // GetActiveWebSubAPIDeploymentByGateway retrieves the currently deployed WebSub API artifact for a specific gateway func (s *GatewayInternalAPIService) GetActiveWebSubAPIDeploymentByGateway(apiID, orgID, gatewayID string) (map[string]string, error) { if s.websubAPIRepo == nil { diff --git a/platform-api/internal/service/graphql_api.go b/platform-api/internal/service/graphql_api.go new file mode 100644 index 0000000000..1b5e387f16 --- /dev/null +++ b/platform-api/internal/service/graphql_api.go @@ -0,0 +1,862 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "strings" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/utils" +) + +// GraphQLAPIService handles business logic for GraphQL API operations. +// GraphQL is a core artifact kind (like RestApi/LlmProvider/LlmProxy/Mcp) +type GraphQLAPIService struct { + repo repository.GraphQLAPIRepository + projectRepo repository.ProjectRepository + auditRepo repository.AuditRepository + deploymentRepo repository.DeploymentRepository + gatewayRepo repository.GatewayRepository + orgRepo repository.OrganizationRepository + gatewayEventsService *GatewayEventsService + identity *IdentityService + secretService *SecretService + slogger *slog.Logger + maxSDLFetchBytes int64 +} + +// NewGraphQLAPIService creates a new GraphQLAPIService instance. +func NewGraphQLAPIService( + repo repository.GraphQLAPIRepository, + projectRepo repository.ProjectRepository, + auditRepo repository.AuditRepository, + deploymentRepo repository.DeploymentRepository, + gatewayRepo repository.GatewayRepository, + orgRepo repository.OrganizationRepository, + gatewayEventsService *GatewayEventsService, + identity *IdentityService, + slogger *slog.Logger, +) *GraphQLAPIService { + return &GraphQLAPIService{ + repo: repo, + projectRepo: projectRepo, + auditRepo: auditRepo, + deploymentRepo: deploymentRepo, + gatewayRepo: gatewayRepo, + orgRepo: orgRepo, + gatewayEventsService: gatewayEventsService, + identity: identity, + slogger: slogger, + } +} + +// SetSecretService injects the SecretService used to validate +// {{ secret "..." }} placeholders on Create/Update — GraphQL's +// upstream.auth/policy params can embed the same placeholders REST's can, +// so this is wired the same way APIService.SetSecretService is. Called +// after both services are constructed to avoid a circular dependency. +func (s *GraphQLAPIService) SetSecretService(ss *SecretService) { + s.secretService = ss +} + +// SetMaxSDLFetchBytes sets the byte ceiling applied when fetching an SDL +// document from sdlUrl — reuses cfg.Server.OpenAPISpecMaxFetchBytes, the same +// generic external-document-fetch limit already used for LLM provider +// templates' openapiSpecUrl, rather than introducing a GraphQL-only config +// key for what is the same kind of bounded fetch. Zero/unset falls back to +// FetchOpenAPISpecFromURL's own built-in default. +func (s *GraphQLAPIService) SetMaxSDLFetchBytes(n int64) { + s.maxSDLFetchBytes = n +} + +// toGraphQLAPI converts m via mapGraphQLAPIModelToAPI, resolves its stored +// project UUID back to the project's handle for the response's projectId +// field (mirrors internal/service/api.go's modelToRESTAPI), and resolves its +// createdBy/updatedBy UUIDs to their raw external identity. +func (s *GraphQLAPIService) toGraphQLAPI(m *model.GraphQLAPI) (*api.GraphQLAPI, error) { + resp := mapGraphQLAPIModelToAPI(m) + if resp == nil { + return nil, nil + } + if s.projectRepo != nil { + project, err := s.projectRepo.GetProjectByUUID(resp.ProjectId) + if err != nil { + return nil, err + } + if project != nil { + resp.ProjectId = project.Handle + } + } + if err := s.identity.ResolveIdentityField(&resp.CreatedBy); err != nil { + return nil, err + } + if err := s.identity.ResolveIdentityField(&resp.UpdatedBy); err != nil { + return nil, err + } + return resp, nil +} + +// toGraphQLAPIDetail is toGraphQLAPI's counterpart for the sdl-less detail +// response (GET /graphql-apis/{graphqlApiId}) — same project-handle and +// identity resolution, built from mapGraphQLAPIModelToDetail instead. +func (s *GraphQLAPIService) toGraphQLAPIDetail(m *model.GraphQLAPI) (*api.GraphQLAPIDetail, error) { + resp := mapGraphQLAPIModelToDetail(m) + if resp == nil { + return nil, nil + } + if s.projectRepo != nil { + project, err := s.projectRepo.GetProjectByUUID(resp.ProjectId) + if err != nil { + return nil, err + } + if project != nil { + resp.ProjectId = project.Handle + } + } + if err := s.identity.ResolveIdentityField(&resp.CreatedBy); err != nil { + return nil, err + } + if err := s.identity.ResolveIdentityField(&resp.UpdatedBy); err != nil { + return nil, err + } + return resp, nil +} + +// Create creates a new GraphQL API. Supply either req.Sdl directly or +// req.Upstream.Main.Url — exactly one schema-resolution path runs. +func (s *GraphQLAPIService) Create(orgUUID, createdBy string, req *api.CreateGraphQLAPIRequest) (*api.GraphQLAPI, error) { + if req == nil { + return nil, apperror.ValidationFailed.New("A request body is required.") + } + if req.DisplayName == "" || req.Version == "" || req.Context == "" { + return nil, apperror.ValidationFailed.New("The displayName, context and version fields are required.") + } + if req.ProjectId == "" { + return nil, apperror.ValidationFailed.New("The projectId field is required.") + } + + // Validate {{ secret "..." }} placeholders anywhere in the request — the + // gateway-controller's template engine resolves placeholders generically + // across the whole artifact (upstream auth and policies alike), so + // validation must cover the same surface as REST's CreateAPI does. + if s.secretService != nil { + configJSON, err := marshalUpstreamForValidation(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request for secret validation: %w", err) + } + if err := s.secretService.ValidateSecretRefs(orgUUID, configJSON); err != nil { + return nil, err + } + } + + // Resolve the project by handle (req.ProjectId is actually the project's + // user-facing handle, e.g. "default-project", not its internal UUID — + // mirrors internal/service/api.go's CreateAPI). GO-AUTH-005: org scoping + // is enforced here, never trusted from the request. + projectUUID := req.ProjectId + if s.projectRepo != nil { + project, err := s.projectRepo.GetProjectByHandleAndOrgID(req.ProjectId, orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to validate project: %w", err) + } + if project == nil || project.OrganizationID != orgUUID { + return nil, apperror.ProjectRefNotFound.New() + } + projectUUID = project.ID + } + + // Handle (user-facing identifier): use the supplied one, or generate from + // displayName with collision detection (mirrors internal/service/api.go's + // CreateAPI). + var handle string + if req.Id != nil && *req.Id != "" { + handle = *req.Id + } else { + generated, err := utils.GenerateHandle(req.DisplayName, s.handleExistsCheck(orgUUID)) + if err != nil { + s.slogger.Error("Failed to generate GraphQL API handle", "apiName", req.DisplayName, "error", err) + return nil, err + } + handle = generated + } + + exists, err := s.repo.Exists(handle, orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to check GraphQL API exists: %w", err) + } + if exists { + return nil, apperror.GraphQLAPIExists.New() + } + + upstream := mapUpstreamAPIToModel(req.Upstream) + var schemaSource string + if req.SchemaSource != nil { + schemaSource = string(*req.SchemaSource) + } + // A blank schemaSource is not defaulted here — resolveSchema infers it + // from which of sdl/sdlUrl is populated, for backward compatibility with + // a caller that predates this field. + resolution, err := s.resolveSchema(schemaSource, utils.ValueOrEmpty(req.Sdl), utils.ValueOrEmpty(req.SdlUrl), upstream) + if err != nil { + return nil, err + } + // resolution.Resolved false is not an error on create — there is no + // previous schema to fall back to, so an unresolved schema just means the + // new API's sdl/introspectionMode start out empty (see resolveSchema). + + var subscriptionPlans []string + if req.SubscriptionPlans != nil { + subscriptionPlans = *req.SubscriptionPlans + } + + context := req.Context + m := &model.GraphQLAPI{ + Handle: handle, + OrganizationID: orgUUID, + ProjectID: projectUUID, + Name: req.DisplayName, + Description: utils.ValueOrEmpty(req.Description), + CreatedBy: createdBy, + UpdatedBy: createdBy, + Version: req.Version, + Configuration: model.GraphQLAPIConfig{ + Name: req.DisplayName, + Version: req.Version, + Context: &context, + SDL: resolution.SDL, + IntrospectionMode: resolution.IntrospectionMode, + Upstream: *upstream, + Policies: mapMCPPoliciesAPIToModel(req.Policies), + SubscriptionPlans: subscriptionPlans, + }, + Origin: constants.OriginCP, + } + + if err := s.repo.Create(m); err != nil { + if isSQLiteUniqueConstraint(err) { + return nil, apperror.GraphQLAPIExists.Wrap(err) + } + return nil, fmt.Errorf("failed to create GraphQL API: %w", err) + } + + if s.auditRepo != nil { + _ = s.auditRepo.Record("CREATE", m.ID, "graphql_api", orgUUID, createdBy) + } + return s.Get(orgUUID, handle) +} + +// ValidateSchema is the non-mutating dry-run counterpart to resolveSchema, +// backing POST /graphql-apis/validate-schema. It shares the exact same +// structural-vs-best-effort semantics Create/Update use (see resolveSchema's +// doc comment) but never persists anything and takes no org/handle — there +// is nothing org-scoped to check here, only the caller-supplied +// schema-source fields themselves. +func (s *GraphQLAPIService) ValidateSchema(req api.ValidateGraphQLSchemaRequest) (graphQLSchemaResolution, error) { + var schemaSource string + if req.SchemaSource != nil { + schemaSource = string(*req.SchemaSource) + } + var upstream *model.UpstreamConfig + if req.Upstream != nil { + upstream = mapUpstreamAPIToModel(*req.Upstream) + } + return s.resolveSchema(schemaSource, utils.ValueOrEmpty(req.Sdl), utils.ValueOrEmpty(req.SdlUrl), upstream) +} + +// graphQLSchemaResolution is resolveSchema's outcome. Resolved is false when +// the declared schemaSource was structurally valid but the actual content +// couldn't be turned into a usable schema (bad SDL text, an unreachable +// sdlUrl, introspection failing) — that is never an error (see resolveSchema). +type graphQLSchemaResolution struct { + SDL string + IntrospectionMode string + Resolved bool +} + +// resolveSchema validates the request's declared schemaSource against which +// of sdl/sdlUrl/upstream.main.url was actually supplied, then attempts to +// resolve the schema that way. It returns two independent kinds of outcome: +// +// - A non-nil error means the request itself is structurally inconsistent +// with its declared schemaSource — a field not matching it was supplied +// (or more than one was), the field/part its value requires is missing, +// or introspection was declared against an upstream.main.ref (which has +// no URL to introspect). These are request-shape problems the caller can +// fix by changing what they sent, reported via the "%s"-templated +// ValidationFailed catalog entry (400) with a specific, actionable +// message — same as the pre-existing sdl/sdlUrl mutual-exclusivity check +// this replaces. This is deliberately not GraphQLAPISchemaResolveFailed: +// that entry's message is a fixed sterile string with no format verb, by +// design (error-handling.md — never reveal which resolution-quality +// reason applied), so passing it a specific reason for a *structural* +// mismatch would either be silently dropped or (worse) surface as a +// literal Go fmt "%!(EXTRA ...)" artifact — a structural problem is safe +// to explain to the caller precisely because it's about their own +// request shape, not about the upstream/parser internals that entry +// exists to hide. +// - A nil error with Resolved=false means the request was well-formed but +// resolving the schema didn't actually work this time (invalid SDL text, +// an unreachable sdlUrl, introspection failing/disabled). This never +// blocks the request — see the doc comments on Create and Update for how +// each handles it. +func (s *GraphQLAPIService) resolveSchema(schemaSource, suppliedSDL, sdlURL string, upstream *model.UpstreamConfig) (graphQLSchemaResolution, error) { + schemaSource = strings.TrimSpace(schemaSource) + suppliedSDL = strings.TrimSpace(suppliedSDL) + sdlURL = strings.TrimSpace(sdlURL) + + // A caller that doesn't set schemaSource at all predates the field (or + // simply doesn't need to be explicit); infer it from whichever field is + // actually populated, the same way this resolved before schemaSource + // existed. Defaulting unconditionally to "introspection" here would + // reject that caller's own sdl/sdlUrl as an "introspection but sdl was + // also provided" structural error — a real caller sending exactly what + // they always sent. Only when schemaSource is explicitly set does an + // unmatched field become a structural error (see below) — that's the + // whole reason to set it explicitly, and inferring around a stated intent + // would defeat it. + if schemaSource == "" { + switch { + case suppliedSDL != "": + schemaSource = string(api.GraphQLAPISchemaSourceInline) + case sdlURL != "": + schemaSource = string(api.GraphQLAPISchemaSourceUrl) + default: + schemaSource = string(api.GraphQLAPISchemaSourceIntrospection) + } + } + + // Structural validation: exactly the field(s) matching the declared + // schemaSource may be populated — a mismatch is a request-shape problem, + // reported immediately rather than silently falling through to whatever + // happens to be non-empty. + switch schemaSource { + case string(api.GraphQLAPISchemaSourceInline): + if sdlURL != "" { + return graphQLSchemaResolution{}, apperror.ValidationFailed.New("schemaSource is 'inline' but sdlUrl was also provided.") + } + if suppliedSDL == "" { + return graphQLSchemaResolution{}, apperror.ValidationFailed.New("schemaSource is 'inline' but no sdl was provided.") + } + case string(api.GraphQLAPISchemaSourceUrl): + if suppliedSDL != "" { + return graphQLSchemaResolution{}, apperror.ValidationFailed.New("schemaSource is 'url' but sdl was also provided.") + } + if sdlURL == "" { + return graphQLSchemaResolution{}, apperror.ValidationFailed.New("schemaSource is 'url' but no sdlUrl was provided.") + } + case string(api.GraphQLAPISchemaSourceFile): + // The handler copies an uploaded sdlFile's content into suppliedSDL — + // from here a file is just inline text; only the structural + // expectation (no sdlUrl) differs from schemaSource "inline". + if sdlURL != "" { + return graphQLSchemaResolution{}, apperror.ValidationFailed.New("schemaSource is 'file' but sdlUrl was also provided.") + } + if suppliedSDL == "" { + return graphQLSchemaResolution{}, apperror.ValidationFailed.New("schemaSource is 'file' but no sdlFile was uploaded.") + } + case string(api.GraphQLAPISchemaSourceIntrospection): + if suppliedSDL != "" || sdlURL != "" { + return graphQLSchemaResolution{}, apperror.ValidationFailed.New("schemaSource is 'introspection' but sdl/sdlUrl was also provided.") + } + if upstream == nil || upstream.Main == nil || strings.TrimSpace(upstream.Main.URL) == "" { + if upstream != nil && upstream.Main != nil && strings.TrimSpace(upstream.Main.Ref) != "" { + return graphQLSchemaResolution{}, apperror.ValidationFailed.New("schemaSource is 'introspection' but upstream.main is a ref, not a literal url — introspection has nothing to call.") + } + return graphQLSchemaResolution{}, apperror.ValidationFailed.New("schemaSource is 'introspection' but upstream.main.url is not set.") + } + default: + return graphQLSchemaResolution{}, apperror.ValidationFailed.New(fmt.Sprintf("Invalid schemaSource %q — must be one of inline, url, file, introspection.", schemaSource)) + } + + // Resolution: best-effort from here — a failure never returns an error, + // it just means Resolved is false. + switch schemaSource { + case string(api.GraphQLAPISchemaSourceInline), string(api.GraphQLAPISchemaSourceFile): + if err := validateGraphQLSDL(suppliedSDL); err != nil { + s.slogger.Warn("Supplied GraphQL SDL failed validation", "schemaSource", schemaSource, "error", err) + return graphQLSchemaResolution{}, nil + } + return graphQLSchemaResolution{SDL: suppliedSDL, IntrospectionMode: "SDL", Resolved: true}, nil + case string(api.GraphQLAPISchemaSourceUrl): + fetched, err := utils.FetchOpenAPISpecFromURL(context.Background(), sdlURL, s.maxSDLFetchBytes) + if err != nil { + s.slogger.Warn("Failed to fetch GraphQL SDL from sdlUrl", "error", err) + return graphQLSchemaResolution{}, nil + } + fetched = strings.TrimSpace(fetched) + if err := validateGraphQLSDL(fetched); err != nil { + s.slogger.Warn("Fetched GraphQL SDL failed validation", "error", err) + return graphQLSchemaResolution{}, nil + } + return graphQLSchemaResolution{SDL: fetched, IntrospectionMode: "SDL", Resolved: true}, nil + default: // introspection + derived, err := fetchAndConvertGraphQLSchema(upstream.Main.URL) + if err != nil { + s.slogger.Warn("GraphQL introspection failed", "error", err) + return graphQLSchemaResolution{}, nil + } + return graphQLSchemaResolution{SDL: derived, IntrospectionMode: "ENDPOINT", Resolved: true}, nil + } +} + +// handleExistsCheck returns a function that checks if a GraphQL API handle +// exists in the organization, for use with utils.GenerateHandle. +func (s *GraphQLAPIService) handleExistsCheck(orgUUID string) func(string) bool { + return func(handle string) bool { + exists, err := s.repo.Exists(handle, orgUUID) + if err != nil { + // On error, assume it exists to be safe (triggers a retry with a + // different suffix rather than risking a collision). + return true + } + return exists + } +} + +// Get retrieves a GraphQL API by its handle. +func (s *GraphQLAPIService) Get(orgUUID, handle string) (*api.GraphQLAPI, error) { + if handle == "" { + return nil, apperror.ValidationFailed.New("The GraphQL API id is required.") + } + + m, err := s.repo.GetByHandle(handle, orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to get GraphQL API: %w", err) + } + if m == nil { + return nil, apperror.GraphQLAPINotFound.New() + } + + return s.toGraphQLAPI(m) +} + +// GetDetail is Get's counterpart for GET /graphql-apis/{graphqlApiId}, which +// deliberately omits sdl from its response — see GetSDL to fetch it +// separately. +func (s *GraphQLAPIService) GetDetail(orgUUID, handle string) (*api.GraphQLAPIDetail, error) { + if handle == "" { + return nil, apperror.ValidationFailed.New("The GraphQL API id is required.") + } + + m, err := s.repo.GetByHandle(handle, orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to get GraphQL API: %w", err) + } + if m == nil { + return nil, apperror.GraphQLAPINotFound.New() + } + + return s.toGraphQLAPIDetail(m) +} + +// GetSDL retrieves a GraphQL API's resolved SDL text for +// GET /graphql-apis/{graphqlApiId}/sdl — the counterpart to GetDetail +// omitting it. +func (s *GraphQLAPIService) GetSDL(orgUUID, handle string) (string, error) { + if handle == "" { + return "", apperror.ValidationFailed.New("The GraphQL API id is required.") + } + + m, err := s.repo.GetByHandle(handle, orgUUID) + if err != nil { + return "", fmt.Errorf("failed to get GraphQL API: %w", err) + } + if m == nil { + return "", apperror.GraphQLAPINotFound.New() + } + + return m.Configuration.SDL, nil +} + +// List retrieves GraphQL APIs for an organization, filtered by project. +func (s *GraphQLAPIService) List(orgUUID, projectHandle string, opts repository.ListOptions) (*api.GraphQLAPIListResponse, error) { + projectUUID := "" + // If a project handle is provided, resolve it and validate that it belongs + // to the organization (mirrors internal/service/api.go's + // GetAPIsByOrganization) — projectHandle is the caller-facing slug (e.g. + // "default-project"), never the internal UUID rows are actually keyed on. + if projectHandle != "" && s.projectRepo != nil { + project, err := s.projectRepo.GetProjectByHandleAndOrgID(projectHandle, orgUUID) + if err != nil { + return nil, err + } + if project == nil { + return nil, apperror.ProjectRefNotFound.New() + } + projectUUID = project.ID + } + + apis, err := s.repo.List(orgUUID, projectUUID, opts) + if err != nil { + return nil, fmt.Errorf("failed to list GraphQL APIs: %w", err) + } + + var totalCount int + if projectUUID != "" { + totalCount, err = s.repo.CountByProject(orgUUID, projectUUID, opts.Search) + } else { + totalCount, err = s.repo.Count(orgUUID) + } + if err != nil { + return nil, fmt.Errorf("failed to count GraphQL APIs: %w", err) + } + + resp := &api.GraphQLAPIListResponse{ + Count: len(apis), + Pagination: api.Pagination{ + Limit: opts.Limit, + Offset: opts.Offset, + Total: totalCount, + }, + } + + // Resolve each item's stored project UUID back to its handle for display + // (mirrors REST's modelToRESTAPIUnresolved), caching per unique project + // UUID since a filtered list page typically shares one project. + projectHandles := map[string]string{} + if projectHandle != "" { + projectHandles[projectUUID] = projectHandle + } + resolveProjectHandle := func(uuid string) (string, error) { + if handle, ok := projectHandles[uuid]; ok { + return handle, nil + } + if s.projectRepo == nil { + return uuid, nil + } + project, err := s.projectRepo.GetProjectByUUID(uuid) + if err != nil { + return "", err + } + handle := uuid + if project != nil { + handle = project.Handle + } + projectHandles[uuid] = handle + return handle, nil + } + + resp.List = make([]api.GraphQLAPIListItem, 0, len(apis)) + createdByFields := make([]**string, 0, len(apis)) + for _, a := range apis { + item := mapGraphQLAPIModelToListItem(a) + if item == nil { + continue + } + if handle, err := resolveProjectHandle(item.ProjectId); err == nil { + item.ProjectId = handle + } else { + return nil, err + } + resp.List = append(resp.List, *item) + createdByFields = append(createdByFields, &resp.List[len(resp.List)-1].CreatedBy) + } + if err := s.identity.ResolveIdentityFields(createdByFields); err != nil { + return nil, err + } + + return resp, nil +} + +// Update updates an existing GraphQL API. The project association is +// immutable via this endpoint (req.ProjectId is not applied) — a PUT never +// moves an artifact to a different project. +func (s *GraphQLAPIService) Update(orgUUID, handle, updatedBy string, req *api.GraphQLAPI) (*api.GraphQLAPI, error) { + if handle == "" || req == nil { + return nil, apperror.ValidationFailed.New("The GraphQL API id and a request body are required.") + } + if req.DisplayName == "" || req.Version == "" || req.Context == "" { + return nil, apperror.ValidationFailed.New("The displayName, context and version fields are required.") + } + + // Validate {{ secret "..." }} placeholders anywhere in the request — see + // Create for why this covers the whole request, not just upstream. + if s.secretService != nil { + configJSON, err := marshalUpstreamForValidation(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request for secret validation: %w", err) + } + if err := s.secretService.ValidateSecretRefs(orgUUID, configJSON); err != nil { + return nil, err + } + } + + existing, err := s.repo.GetByHandle(handle, orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to get GraphQL API: %w", err) + } + if existing == nil { + return nil, apperror.GraphQLAPINotFound.New() + } + // DP-originated artifacts are read-only in the control plane. + if err := ensureOriginMutable(existing.Origin); err != nil { + return nil, err + } + if req.Id != nil && *req.Id != "" && *req.Id != handle { + return nil, apperror.ValidationFailed.New("The id in the request body must match the path parameter.") + } + + upstream := mapUpstreamAPIToModel(req.Upstream) + var schemaSource string + if req.SchemaSource != nil { + schemaSource = string(*req.SchemaSource) + } + // A blank schemaSource is not defaulted here — resolveSchema infers it + // from which of sdl/sdlUrl is populated, for backward compatibility with + // a caller that predates this field. + resolution, err := s.resolveSchema(schemaSource, utils.ValueOrEmpty(req.Sdl), utils.ValueOrEmpty(req.SdlUrl), upstream) + if err != nil { + return nil, err + } + // Unlike Create, a failed resolution on update keeps the previously-stored + // schema instead of blanking it out — the whole point of best-effort + // resolution is that a metadata-only edit (or a transient upstream issue) + // shouldn't destroy a schema that was working before this request. + sdl, introspectionMode := existing.Configuration.SDL, existing.Configuration.IntrospectionMode + if resolution.Resolved { + sdl, introspectionMode = resolution.SDL, resolution.IntrospectionMode + } + + var subscriptionPlans []string + if req.SubscriptionPlans != nil { + subscriptionPlans = *req.SubscriptionPlans + } + + context := req.Context + existing.Name = req.DisplayName + existing.Version = req.Version + existing.Description = utils.ValueOrEmpty(req.Description) + existing.UpdatedBy = updatedBy + existing.Configuration = model.GraphQLAPIConfig{ + Name: req.DisplayName, + Version: req.Version, + Context: &context, + SDL: sdl, + IntrospectionMode: introspectionMode, + Upstream: *upstream, + Policies: mapMCPPoliciesAPIToModel(req.Policies), + SubscriptionPlans: subscriptionPlans, + } + + if err := s.repo.Update(existing); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, apperror.GraphQLAPINotFound.Wrap(err) + } + return nil, fmt.Errorf("failed to update GraphQL API: %w", err) + } + + if s.auditRepo != nil { + _ = s.auditRepo.Record("UPDATE", existing.ID, "graphql_api", orgUUID, updatedBy) + } + return s.Get(orgUUID, handle) +} + +// Delete deletes a GraphQL API by its handle. +func (s *GraphQLAPIService) Delete(orgUUID, handle, deletedBy string) error { + if handle == "" { + return apperror.ValidationFailed.New("The GraphQL API id is required.") + } + + existing, err := s.repo.GetByHandle(handle, orgUUID) + if err != nil { + return fmt.Errorf("failed to get GraphQL API: %w", err) + } + if existing == nil { + return apperror.GraphQLAPINotFound.New() + } + // DP-originated artifacts may only be deleted once undeployed on all gateways. + if err := ensureOriginDeletable(s.deploymentRepo, existing.Origin, existing.ID, orgUUID); err != nil { + return err + } + + // Get all gateways in the organization to broadcast deletion event. + // We broadcast to all gateways (not just those with active deployments) because + // deployment_status rows may have been cascade-deleted when deployments were removed, + // leaving stale artifacts on gateways that would otherwise never receive the delete event. + var gateways []*model.Gateway + if s.gatewayRepo != nil { + gws, err := s.gatewayRepo.GetByOrganizationID(orgUUID) + if err != nil { + s.slogger.Warn("Failed to get gateways for GraphQL API deletion", "error", err, "apiUUID", existing.ID) + } else { + gateways = gws + } + } + + if err := s.repo.Delete(handle, orgUUID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return apperror.GraphQLAPINotFound.Wrap(err) + } + return fmt.Errorf("failed to delete GraphQL API: %w", err) + } + + if s.auditRepo != nil { + _ = s.auditRepo.Record("DELETE", existing.ID, "graphql_api", orgUUID, deletedBy) + } + + // Send deletion events to all gateways in the organization + if s.gatewayEventsService != nil && len(gateways) > 0 { + for _, gateway := range gateways { + deletionEvent := &model.GraphQLAPIDeletionEvent{ + ApiId: existing.ID, + } + if err := s.gatewayEventsService.BroadcastGraphQLAPIDeletionEvent(gateway.ID, deletionEvent); err != nil { + s.slogger.Warn("Failed to broadcast GraphQL API deletion event", "error", err, "gatewayID", gateway.ID, "apiUUID", existing.ID) + } else { + s.slogger.Info("GraphQL API deletion event sent", "gatewayID", gateway.ID, "apiUUID", existing.ID) + } + } + } + + return nil +} + +// Count returns the total number of GraphQL APIs for an organization. +func (s *GraphQLAPIService) Count(orgUUID string) (int, error) { + return s.repo.Count(orgUUID) +} + +// AddGatewaysToAPI associates multiple gateways with a GraphQL API identified by +// handle. Mirrors APIService.AddGatewaysToAPIByHandle/AddGatewaysToAPI (api.go): +// the underlying artifact_gateway_mappings table and its CRUD methods are +// kind-agnostic (see GraphQLAPIRepository's doc comment), so this is a thin +// wrapper resolving the handle to a UUID and delegating to the same generic +// association helpers, reusing REST's response DTO +// (api.RESTAPIGatewayListResponse) since the shape carries no REST-specific +// fields — See resources/openapi.yaml's +// /graphql-apis/{graphqlApiId}/gateways path. +func (s *GraphQLAPIService) AddGatewaysToAPI(handle string, gatewayIds []string, orgUUID, createdBy string) (*api.RESTAPIGatewayListResponse, error) { + apiModel, err := s.repo.GetByHandle(handle, orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to get GraphQL API: %w", err) + } + if apiModel == nil { + return nil, apperror.GraphQLAPINotFound.New() + } + + var validGateways []*model.Gateway + for _, gatewayId := range gatewayIds { + gateway, err := s.gatewayRepo.GetByHandleAndOrgID(gatewayId, orgUUID) + if err != nil { + return nil, err + } + if gateway == nil { + return nil, apperror.GatewayNotFound.New() + } + validGateways = append(validGateways, gateway) + } + + existingAssociations, err := s.repo.GetAPIAssociations(apiModel.ID, constants.AssociationTypeGateway, orgUUID) + if err != nil { + return nil, err + } + existingGatewayIds := make(map[string]bool) + for _, assoc := range existingAssociations { + existingGatewayIds[assoc.GatewayID] = true + } + for _, gateway := range validGateways { + if existingGatewayIds[gateway.ID] { + if err := s.repo.UpdateAPIAssociation(apiModel.ID, gateway.ID, constants.AssociationTypeGateway, orgUUID, createdBy); err != nil { + return nil, err + } + } else { + association := &model.APIAssociation{ + ArtifactID: apiModel.ID, + OrganizationID: orgUUID, + GatewayID: gateway.ID, + CreatedBy: createdBy, + } + if err := s.repo.CreateAPIAssociation(association); err != nil { + return nil, err + } + existingGatewayIds[gateway.ID] = true + } + } + + return s.getAPIGateways(apiModel.ID, orgUUID) +} + +// GetAPIGateways retrieves a page of gateways associated with a GraphQL API +// identified by handle, applying the requested limit/offset window. Mirrors +// APIService.GetAPIGatewaysByHandle. +func (s *GraphQLAPIService) GetAPIGateways(handle, orgUUID string, limit, offset int) (*api.RESTAPIGatewayListResponse, error) { + apiModel, err := s.repo.GetByHandle(handle, orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to get GraphQL API: %w", err) + } + if apiModel == nil { + return nil, apperror.GraphQLAPINotFound.New() + } + + gatewayDetails, err := s.repo.GetAPIGatewaysWithDetails(apiModel.ID, orgUUID) + if err != nil { + return nil, err + } + org, err := s.orgRepo.GetOrganizationByUUID(orgUUID) + if err != nil { + return nil, err + } + orgHandle := "" + if org != nil { + orgHandle = org.Handle + } + + // The gateways associated with a single API are a small, bounded set, so the + // requested window is applied in memory while the total reflects the full set. + total := len(gatewayDetails) + page := paginateSlice(gatewayDetails, limit, offset) + + response, err := apiGatewayDetailsToAPIList(page, orgHandle) + if err != nil { + return nil, fmt.Errorf("failed to convert API gateway details: %w", err) + } + response.Pagination = api.Pagination{Total: total, Offset: offset, Limit: limit} + return response, nil +} + +// getAPIGateways retrieves all gateways associated with a GraphQL API (by UUID), +// unpaginated — used internally right after a gateway association change so the +// caller sees the full, up-to-date set (mirrors APIService.GetAPIGateways). +func (s *GraphQLAPIService) getAPIGateways(apiUUID, orgUUID string) (*api.RESTAPIGatewayListResponse, error) { + gatewayDetails, err := s.repo.GetAPIGatewaysWithDetails(apiUUID, orgUUID) + if err != nil { + return nil, err + } + org, err := s.orgRepo.GetOrganizationByUUID(orgUUID) + if err != nil { + return nil, err + } + orgHandle := "" + if org != nil { + orgHandle = org.Handle + } + response, err := apiGatewayDetailsToAPIList(gatewayDetails, orgHandle) + if err != nil { + return nil, fmt.Errorf("failed to convert API gateway details: %w", err) + } + return response, nil +} diff --git a/platform-api/internal/service/graphql_api_test.go b/platform-api/internal/service/graphql_api_test.go new file mode 100644 index 0000000000..cba946630f --- /dev/null +++ b/platform-api/internal/service/graphql_api_test.go @@ -0,0 +1,1777 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/wso2/api-platform/common/eventhub" + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/dto" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" +) + +// --- test doubles ----------------------------------------------------- + +// mockGraphQLAPIRepo is a configurable in-memory-ish fake satisfying +// repository.GraphQLAPIRepository, mirroring the mocking style used across +// this repo's service-layer tests (see internal/service/api_test.go). +type mockGraphQLAPIRepo struct { + existsResult bool + existsErr error + + created *model.GraphQLAPI + createErr error + + getByHandleFunc func(handle, orgUUID string) (*model.GraphQLAPI, error) + getByUUIDFunc func(uuid, orgUUID string) (*model.GraphQLAPI, error) + + updated *model.GraphQLAPI + updateErr error + + deleted bool + deleteErr error + + listResult []*model.GraphQLAPI + listErr error + + countResult int + countErr error + countByProjectResult int + countByProjectErr error + countByProjectCapture struct{ orgUUID, projectUUID, search string } + listCapture struct { + orgUUID, projectUUID string + opts repository.ListOptions + } + + gatewayDetails []*model.APIGatewayWithDetails + getGatewaysFunc func(apiUUID, orgUUID string) ([]*model.APIGatewayWithDetails, error) + associations []*model.APIAssociation + createdAssociations []*model.APIAssociation + createAssociationErr error + updatedAssociation bool + + ensureGatewayAssociationFunc func(apiUUID, gatewayUUID, orgUUID, createdBy, deployMetadata string, metadataProvided bool) (string, error) +} + +func (m *mockGraphQLAPIRepo) Create(a *model.GraphQLAPI) error { + if m.createErr != nil { + return m.createErr + } + a.ID = "generated-uuid" + m.created = a + return nil +} + +func (m *mockGraphQLAPIRepo) GetByHandle(handle, orgUUID string) (*model.GraphQLAPI, error) { + if m.getByHandleFunc != nil { + return m.getByHandleFunc(handle, orgUUID) + } + return m.created, nil +} + +func (m *mockGraphQLAPIRepo) GetByUUID(uuid, orgUUID string) (*model.GraphQLAPI, error) { + if m.getByUUIDFunc != nil { + return m.getByUUIDFunc(uuid, orgUUID) + } + return nil, nil +} + +func (m *mockGraphQLAPIRepo) List(orgUUID, projectUUID string, opts repository.ListOptions) ([]*model.GraphQLAPI, error) { + m.listCapture.orgUUID = orgUUID + m.listCapture.projectUUID = projectUUID + m.listCapture.opts = opts + return m.listResult, m.listErr +} + +func (m *mockGraphQLAPIRepo) Count(orgUUID string) (int, error) { return m.countResult, m.countErr } + +func (m *mockGraphQLAPIRepo) CountByProject(orgUUID, projectUUID, search string) (int, error) { + m.countByProjectCapture.orgUUID = orgUUID + m.countByProjectCapture.projectUUID = projectUUID + m.countByProjectCapture.search = search + return m.countByProjectResult, m.countByProjectErr +} + +func (m *mockGraphQLAPIRepo) Update(a *model.GraphQLAPI) error { + if m.updateErr != nil { + return m.updateErr + } + m.updated = a + return nil +} + +func (m *mockGraphQLAPIRepo) Delete(handle, orgUUID string) error { + if m.deleteErr != nil { + return m.deleteErr + } + m.deleted = true + return nil +} + +func (m *mockGraphQLAPIRepo) Exists(handle, orgUUID string) (bool, error) { + return m.existsResult, m.existsErr +} + +func (m *mockGraphQLAPIRepo) GetAPIGatewaysWithDetails(apiUUID, orgUUID string) ([]*model.APIGatewayWithDetails, error) { + if m.getGatewaysFunc != nil { + return m.getGatewaysFunc(apiUUID, orgUUID) + } + return m.gatewayDetails, nil +} + +func (m *mockGraphQLAPIRepo) CreateAPIAssociation(association *model.APIAssociation) error { + if m.createAssociationErr != nil { + return m.createAssociationErr + } + m.createdAssociations = append(m.createdAssociations, association) + return nil +} + +func (m *mockGraphQLAPIRepo) GetAPIAssociations(apiUUID, associationType, orgUUID string) ([]*model.APIAssociation, error) { + return m.associations, nil +} + +func (m *mockGraphQLAPIRepo) UpdateAPIAssociation(apiUUID, resourceId, associationType, orgUUID, updatedBy string) error { + m.updatedAssociation = true + return nil +} + +func (m *mockGraphQLAPIRepo) EnsureGatewayAssociation(apiUUID, gatewayUUID, orgUUID, createdBy, deployMetadata string, metadataProvided bool) (string, error) { + if m.ensureGatewayAssociationFunc != nil { + return m.ensureGatewayAssociationFunc(apiUUID, gatewayUUID, orgUUID, createdBy, deployMetadata, metadataProvided) + } + return deployMetadata, nil +} + +var _ repository.GraphQLAPIRepository = (*mockGraphQLAPIRepo)(nil) + +// mockGraphQLProjectRepo embeds the interface so only the methods a test +// needs are implemented; everything else panics if accidentally called. +type mockGraphQLProjectRepo struct { + repository.ProjectRepository + project *model.Project +} + +func (m *mockGraphQLProjectRepo) GetProjectByUUID(projectId string) (*model.Project, error) { + return m.project, nil +} + +func (m *mockGraphQLProjectRepo) GetProjectByHandleAndOrgID(handle, orgID string) (*model.Project, error) { + return m.project, nil +} + +// newGraphQLTestService wires a GraphQLAPIService for tests, reusing the +// package's shared noopAuditRepo (llm_test.go) and newTestIdentityService +// (identity_test_helpers_test.go) test doubles. Gateway/org repos are wired +// with empty defaults — use newGraphQLTestServiceWithGateways for tests that +// exercise AddGatewaysToAPI/GetAPIGateways. +func newGraphQLTestService(repo *mockGraphQLAPIRepo, project *model.Project) *GraphQLAPIService { + return newGraphQLTestServiceWithGateways(repo, project, &mockGatewayRepository{}, &mockOrganizationRepo{}) +} + +// newGraphQLTestServiceWithGateways is newGraphQLTestService with caller-supplied +// gateway/org repo mocks, for tests exercising the gateway-association methods. +func newGraphQLTestServiceWithGateways(repo *mockGraphQLAPIRepo, project *model.Project, gatewayRepo repository.GatewayRepository, orgRepo repository.OrganizationRepository) *GraphQLAPIService { + return NewGraphQLAPIService( + repo, + &mockGraphQLProjectRepo{project: project}, + &noopAuditRepo{}, + nil, // deploymentRepo — not needed unless exercising Delete's origin-deletable guard + gatewayRepo, + orgRepo, + nil, // gatewayEventsService — not needed unless exercising deletion-event broadcast + newTestIdentityService(), + slog.Default(), + ) +} + +func graphQLCatalogCode(t *testing.T, err error) string { + t.Helper() + var appErr *apperror.Error + if !errors.As(err, &appErr) { + t.Fatalf("expected an *apperror.Error, got %T: %v", err, err) + } + return appErr.Code +} + +func graphQLStrPtr(s string) *string { return &s } + +const validCountriesGraphQLSDL = `type Query { + countries: [String] +}` + +// --- tests -------------------------------------------------------------- + +func TestGraphQLCreate_WithSDL_Success(t *testing.T) { + repo := &mockGraphQLAPIRepo{} + project := &model.Project{ID: "project-uuid", OrganizationID: "org-1"} + svc := newGraphQLTestService(repo, project) + + req := &api.CreateGraphQLAPIRequest{ + DisplayName: "Countries GraphQL API", + Context: "/countries", + Version: "v1.0", + ProjectId: "project-uuid", + Sdl: graphQLStrPtr(validCountriesGraphQLSDL), + Upstream: api.Upstream{ + Main: api.UpstreamDefinition{Url: graphQLStrPtr("https://countries.example.com/graphql")}, + }, + } + + resp, err := svc.Create("org-1", "creator-uuid", req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp == nil { + t.Fatal("expected a response, got nil") + } + if repo.created == nil { + t.Fatal("expected repo.Create to be called") + } + if repo.created.Configuration.IntrospectionMode != "SDL" { + t.Errorf("expected introspectionMode SDL, got %q", repo.created.Configuration.IntrospectionMode) + } + if repo.created.Configuration.SDL != validCountriesGraphQLSDL { + t.Errorf("expected stored SDL to match the supplied SDL verbatim") + } + if repo.created.OrganizationID != "org-1" { + t.Errorf("expected organization to come from the authenticated context, got %q", repo.created.OrganizationID) + } +} + +func TestGraphQLCreate_WithIntrospection_Success(t *testing.T) { + introspectionJSON := `{ + "data": { + "__schema": { + "queryType": {"name": "Query"}, + "mutationType": null, + "subscriptionType": null, + "types": [ + { + "kind": "OBJECT", + "name": "Query", + "description": "", + "fields": [ + { + "name": "hello", + "description": "", + "args": [], + "type": {"kind": "SCALAR", "name": "String", "ofType": null} + } + ] + } + ] + } + } + }` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(introspectionJSON)) + })) + defer server.Close() + + repo := &mockGraphQLAPIRepo{} + project := &model.Project{ID: "project-uuid", OrganizationID: "org-1"} + svc := newGraphQLTestService(repo, project) + + req := &api.CreateGraphQLAPIRequest{ + DisplayName: "Introspected API", + Context: "/introspected", + Version: "v1.0", + ProjectId: "project-uuid", + Upstream: api.Upstream{ + Main: api.UpstreamDefinition{Url: graphQLStrPtr(server.URL)}, + }, + } + + resp, err := svc.Create("org-1", "creator-uuid", req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp == nil { + t.Fatal("expected a response, got nil") + } + if repo.created.Configuration.IntrospectionMode != "ENDPOINT" { + t.Errorf("expected introspectionMode ENDPOINT, got %q", repo.created.Configuration.IntrospectionMode) + } + if !strings.Contains(repo.created.Configuration.SDL, "type Query") { + t.Errorf("expected derived SDL to contain a Query type, got: %s", repo.created.Configuration.SDL) + } + if !strings.Contains(repo.created.Configuration.SDL, "hello") { + t.Errorf("expected derived SDL to contain the introspected field, got: %s", repo.created.Configuration.SDL) + } +} + +// TestGraphQLCreate_IntrospectionFailure_UnprocessableEntity covers +// "introspection endpoint unreachable/malformed" — the counterpart to +// TestGraphQLCreate_MalformedSDL_UnprocessableEntity's "SDL fails to parse." +// fetchAndConvertGraphQLSchema's upstream client intentionally allows +// private/in-cluster addresses (it's the tenant's own configured backend, +// same shared-client policy as MCP and as sdlUrl's own fetcher), so a local +// httptest.Server genuinely exercises this path rather than tripping an SSRF +// block first. +// TestGraphQLCreate_IntrospectionFailure_SucceedsWithEmptySchema guards +// resolveSchema's best-effort posture: introspection failing (upstream +// unreachable, disabled, or misbehaving) is a resolution-quality problem, not +// a structural one, so it must never block creation — the API is created with +// an empty sdl instead, fetchable/refreshable later. +func TestGraphQLCreate_IntrospectionFailure_SucceedsWithEmptySchema(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("not json at all")) + })) + defer server.Close() + + repo := &mockGraphQLAPIRepo{} + project := &model.Project{ID: "project-uuid", OrganizationID: "org-1"} + svc := newGraphQLTestService(repo, project) + + req := &api.CreateGraphQLAPIRequest{ + DisplayName: "Unreachable Introspection API", + Context: "/unreachable", + Version: "v1.0", + ProjectId: "project-uuid", + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: graphQLStrPtr(server.URL)}}, + } + + resp, err := svc.Create("org-1", "creator-uuid", req) + if err != nil { + t.Fatalf("expected creation to succeed despite a failed introspection, got: %v", err) + } + if resp == nil { + t.Fatal("expected a response, got nil") + } + if repo.created == nil { + t.Fatal("expected repo.Create to be called") + } + if repo.created.Configuration.SDL != "" { + t.Errorf("expected empty SDL when introspection fails, got %q", repo.created.Configuration.SDL) + } +} + +// TestGraphQLCreate_SchemaResolveFailure_IdenticalShapeRegardlessOfCause pins +// resolveSchema's best-effort posture uniformly across both resolution- +// failure causes it can hit: a malformed inline SDL and a failed +// introspection must both succeed with an empty schema — neither is a +// structural problem, so neither may block the request, and the outcome +// shouldn't depend on which cause produced it. +func TestGraphQLCreate_SchemaResolveFailure_IdenticalShapeRegardlessOfCause(t *testing.T) { + introspectionServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer introspectionServer.Close() + + malformedSDLReq := &api.CreateGraphQLAPIRequest{ + DisplayName: "Broken API", Context: "/broken", Version: "v1.0", ProjectId: "project-uuid", + Sdl: graphQLStrPtr("this is not { valid SDL at all"), + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: graphQLStrPtr("https://example.com/graphql")}}, + } + introspectionFailureReq := &api.CreateGraphQLAPIRequest{ + DisplayName: "Unreachable API", Context: "/unreachable", Version: "v1.0", ProjectId: "project-uuid", + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: graphQLStrPtr(introspectionServer.URL)}}, + } + + project := &model.Project{ID: "project-uuid", OrganizationID: "org-1"} + sdlRepo, introspectRepo := &mockGraphQLAPIRepo{}, &mockGraphQLAPIRepo{} + sdlResp, sdlErr := newGraphQLTestService(sdlRepo, project).Create("org-1", "creator-uuid", malformedSDLReq) + introspectResp, introspectErr := newGraphQLTestService(introspectRepo, project).Create("org-1", "creator-uuid", introspectionFailureReq) + + if sdlErr != nil || introspectErr != nil { + t.Fatalf("expected both to succeed, got sdlErr=%v introspectErr=%v", sdlErr, introspectErr) + } + if sdlResp == nil || introspectResp == nil { + t.Fatal("expected both responses to be non-nil") + } + if sdlRepo.created.Configuration.SDL != "" || introspectRepo.created.Configuration.SDL != "" { + t.Errorf("expected empty SDL for both causes, got %q and %q", + sdlRepo.created.Configuration.SDL, introspectRepo.created.Configuration.SDL) + } +} + +func TestGraphQLCreate_DuplicateHandle_Conflict(t *testing.T) { + repo := &mockGraphQLAPIRepo{existsResult: true} + project := &model.Project{ID: "project-uuid", OrganizationID: "org-1"} + svc := newGraphQLTestService(repo, project) + + req := &api.CreateGraphQLAPIRequest{ + Id: graphQLStrPtr("countries-graphql-api"), + DisplayName: "Countries GraphQL API", + Context: "/countries", + Version: "v1.0", + ProjectId: "project-uuid", + Sdl: graphQLStrPtr(validCountriesGraphQLSDL), + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: graphQLStrPtr("https://example.com/graphql")}}, + } + + _, err := svc.Create("org-1", "creator-uuid", req) + if err == nil { + t.Fatal("expected an error for a duplicate handle") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPIExists { + t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPIExists, code) + } +} + +func TestGraphQLGet_CrossOrg_NotFound(t *testing.T) { + stored := &model.GraphQLAPI{ + ID: "some-uuid", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + Configuration: model.GraphQLAPIConfig{SDL: validCountriesGraphQLSDL}, + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + // Simulate the real repository's WHERE handle = ? AND organization_uuid = ? + // clause: a lookup under a different org never matches the row. + if orgUUID != stored.OrganizationID { + return nil, nil + } + return stored, nil + }, + } + svc := newGraphQLTestService(repo, nil) + + // Same-org lookup succeeds and returns the full object, including sdl. + resp, err := svc.Get("org-1", "countries-graphql-api") + if err != nil { + t.Fatalf("unexpected error for same-org lookup: %v", err) + } + if resp.Sdl == nil || *resp.Sdl != validCountriesGraphQLSDL { + t.Errorf("expected Get to return the full object including sdl, got Sdl=%v", resp.Sdl) + } + + // Cross-org lookup must be indistinguishable from "does not exist" (404, + // never 403) per error-handling.md's existence-hiding convention. + _, err = svc.Get("org-2", "countries-graphql-api") + if err == nil { + t.Fatal("expected an error for a cross-org lookup") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPINotFound { + t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPINotFound, code) + } +} + +// TestGraphQLGet_NotFound covers a handle that simply doesn't exist (as +// opposed to TestGraphQLGet_CrossOrg_NotFound's wrong-org case) — both must +// produce the identical 404, never leaking which reason applied. +func TestGraphQLGet_NotFound(t *testing.T) { + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + return nil, nil + }, + } + svc := newGraphQLTestService(repo, nil) + + _, err := svc.Get("org-1", "does-not-exist") + if err == nil { + t.Fatal("expected an error for a nonexistent handle") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPINotFound { + t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPINotFound, code) + } +} + +// TestGraphQLGetDetail_OmitsSDL guards GetDetail's whole reason for existing: +// GET /graphql-apis/{graphqlApiId} must return everything Get does except +// sdl, which moved to GetSDL/GET .../sdl. +func TestGraphQLGetDetail_OmitsSDL(t *testing.T) { + stored := &model.GraphQLAPI{ + ID: "some-uuid", + Handle: "countries-graphql-api", + Name: "Countries GraphQL API", + OrganizationID: "org-1", + Configuration: model.GraphQLAPIConfig{SDL: validCountriesGraphQLSDL}, + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + return stored, nil + }, + } + svc := newGraphQLTestService(repo, nil) + + resp, err := svc.GetDetail("org-1", "countries-graphql-api") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp == nil { + t.Fatal("expected a response, got nil") + } + if resp.DisplayName != stored.Name { + t.Errorf("expected displayName %q, got %q", stored.Name, resp.DisplayName) + } + // GraphQLAPIDetail has no Sdl field at all — the compiler enforces the + // omission; this test guards that GetDetail otherwise returns the same + // metadata Get does. +} + +func TestGraphQLGetDetail_NotFound(t *testing.T) { + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + return nil, nil + }, + } + svc := newGraphQLTestService(repo, nil) + + _, err := svc.GetDetail("org-1", "does-not-exist") + if err == nil { + t.Fatal("expected an error for a nonexistent handle") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPINotFound { + t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPINotFound, code) + } +} + +// TestGraphQLGetSDL_ReturnsSDL guards GetSDL — the counterpart endpoint that +// now serves what GetDetail omits. +func TestGraphQLGetSDL_ReturnsSDL(t *testing.T) { + stored := &model.GraphQLAPI{ + ID: "some-uuid", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + Configuration: model.GraphQLAPIConfig{SDL: validCountriesGraphQLSDL}, + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + if orgUUID != stored.OrganizationID { + return nil, nil + } + return stored, nil + }, + } + svc := newGraphQLTestService(repo, nil) + + sdl, err := svc.GetSDL("org-1", "countries-graphql-api") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sdl != validCountriesGraphQLSDL { + t.Errorf("expected the stored SDL, got %q", sdl) + } + + // Cross-org lookup must 404 exactly like Get/GetDetail. + if _, err := svc.GetSDL("org-2", "countries-graphql-api"); err == nil { + t.Fatal("expected an error for a cross-org lookup") + } else if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPINotFound { + t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPINotFound, code) + } +} + +func TestGraphQLGetSDL_NotFound(t *testing.T) { + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + return nil, nil + }, + } + svc := newGraphQLTestService(repo, nil) + + _, err := svc.GetSDL("org-1", "does-not-exist") + if err == nil { + t.Fatal("expected an error for a nonexistent handle") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPINotFound { + t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPINotFound, code) + } +} + +// TestGraphQLUpstreamAuth_RedactedAcrossAllResponseShapes guards Get, +// GetDetail, and List (the three response shapes that carry upstream auth — +// GraphQLAPIListItem's Upstream field, GraphQLAPIDetail, and GraphQLAPI +// itself) against ever echoing back a raw upstream credential. All three +// previously ran through the non-redacting mapUpstreamModelToAPI, which +// leaked main/sandbox upstream.*.auth.value verbatim; they must instead use +// mapUpstreamConfigToDTO, the same redacting mapper LLM/MCP's own upstream +// responses use — Type/Header survive, Value never does. +func TestGraphQLUpstreamAuth_RedactedAcrossAllResponseShapes(t *testing.T) { + stored := &model.GraphQLAPI{ + ID: "some-uuid", + Handle: "countries-graphql-api", + Name: "Countries GraphQL API", + OrganizationID: "org-1", + Configuration: model.GraphQLAPIConfig{ + SDL: validCountriesGraphQLSDL, + Upstream: model.UpstreamConfig{ + Main: &model.UpstreamEndpoint{ + URL: "https://countries.example.com/graphql", + Auth: &model.UpstreamAuth{ + Type: "apiKey", + Header: "X-Api-Key", + Value: "super-secret-main-credential", + }, + }, + Sandbox: &model.UpstreamEndpoint{ + URL: "https://sandbox.countries.example.com/graphql", + Auth: &model.UpstreamAuth{ + Type: "bearer", + Header: "Authorization", + Value: "super-secret-sandbox-credential", + }, + }, + }, + }, + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + return stored, nil + }, + listResult: []*model.GraphQLAPI{stored}, + } + svc := newGraphQLTestService(repo, nil) + + assertRedacted := func(t *testing.T, label string, up *api.Upstream) { + t.Helper() + if up == nil { + t.Fatalf("%s: expected an upstream, got nil", label) + } + if up.Main.Auth == nil { + t.Fatalf("%s: expected main auth to survive redaction (type/header), got nil", label) + } + if up.Main.Auth.Value != nil { + t.Errorf("%s: expected main auth value to be redacted, got %q", label, *up.Main.Auth.Value) + } + if up.Main.Auth.Header == nil || *up.Main.Auth.Header != "X-Api-Key" { + t.Errorf("%s: expected main auth header to survive redaction, got %v", label, up.Main.Auth.Header) + } + if up.Sandbox == nil || up.Sandbox.Auth == nil { + t.Fatalf("%s: expected sandbox auth to survive redaction (type/header), got nil", label) + } + if up.Sandbox.Auth.Value != nil { + t.Errorf("%s: expected sandbox auth value to be redacted, got %q", label, *up.Sandbox.Auth.Value) + } + if up.Sandbox.Auth.Header == nil || *up.Sandbox.Auth.Header != "Authorization" { + t.Errorf("%s: expected sandbox auth header to survive redaction, got %v", label, up.Sandbox.Auth.Header) + } + } + + full, err := svc.Get("org-1", "countries-graphql-api") + if err != nil { + t.Fatalf("Get: unexpected error: %v", err) + } + assertRedacted(t, "Get", &full.Upstream) + + detail, err := svc.GetDetail("org-1", "countries-graphql-api") + if err != nil { + t.Fatalf("GetDetail: unexpected error: %v", err) + } + assertRedacted(t, "GetDetail", &detail.Upstream) + + list, err := svc.List("org-1", "", repository.ListOptions{Limit: 25, Offset: 0}) + if err != nil { + t.Fatalf("List: unexpected error: %v", err) + } + if len(list.List) != 1 { + t.Fatalf("expected 1 list item, got %d", len(list.List)) + } + assertRedacted(t, "List", list.List[0].Upstream) +} + +// TestGraphQLList_NoProjectFilter_ReturnsAllAndResolvesHandles guards the +// no-project-filter path (Count, not CountByProject) and the per-item +// project-UUID -> handle resolution (mirrors REST's modelToRESTAPIUnresolved, +// see List's doc comment). +func TestGraphQLList_NoProjectFilter_ReturnsAllAndResolvesHandles(t *testing.T) { + stored := []*model.GraphQLAPI{ + { + ID: "uuid-1", Handle: "countries-graphql-api", Name: "Countries", Version: "v1.0", + OrganizationID: "org-1", ProjectID: "project-uuid", CreatedBy: "creator-uuid", + Configuration: model.GraphQLAPIConfig{SDL: validCountriesGraphQLSDL}, + }, + { + ID: "uuid-2", Handle: "weather-graphql-api", Name: "Weather", Version: "v1.0", + OrganizationID: "org-1", ProjectID: "project-uuid", CreatedBy: "creator-uuid", + Configuration: model.GraphQLAPIConfig{SDL: validCountriesGraphQLSDL}, + }, + } + repo := &mockGraphQLAPIRepo{listResult: stored, countResult: 2} + project := &model.Project{ID: "project-uuid", Handle: "default-project", OrganizationID: "org-1"} + svc := newGraphQLTestService(repo, project) + + resp, err := svc.List("org-1", "", repository.ListOptions{Limit: 100, Offset: 0}) + if err != nil { + t.Fatalf("List failed: %v", err) + } + if resp.Count != 2 || resp.Pagination.Total != 2 { + t.Fatalf("expected count/total 2, got count=%d total=%d", resp.Count, resp.Pagination.Total) + } + if len(resp.List) != 2 { + t.Fatalf("expected 2 list items, got %d", len(resp.List)) + } + for _, item := range resp.List { + if item.ProjectId != "default-project" { + t.Errorf("expected ProjectId resolved to handle %q, got %q", "default-project", item.ProjectId) + } + } +} + +// TestGraphQLList_ProjectFilter_ResolvesHandleToUUIDBeforeFiltering guards the +// bug found during the live smoke test: a caller-supplied projectId is a +// handle, not the internal UUID rows are keyed on, and must be resolved via +// GetProjectByHandleAndOrgID before being used to filter/count. +func TestGraphQLList_ProjectFilter_ResolvesHandleToUUIDBeforeFiltering(t *testing.T) { + repo := &mockGraphQLAPIRepo{listResult: nil, countByProjectResult: 0} + project := &model.Project{ID: "project-uuid", Handle: "default-project", OrganizationID: "org-1"} + svc := newGraphQLTestService(repo, project) + + if _, err := svc.List("org-1", "default-project", repository.ListOptions{Limit: 100, Offset: 0}); err != nil { + t.Fatalf("List failed: %v", err) + } + + if repo.countByProjectCapture.projectUUID != "project-uuid" { + t.Errorf("expected repo.CountByProject to be called with the resolved UUID %q, got %q", "project-uuid", repo.countByProjectCapture.projectUUID) + } +} + +// TestGraphQLList_UnknownProjectHandle_NotFound guards against silently +// falling back to an unfiltered (org-wide) list when the caller-supplied +// project handle doesn't resolve to any project in this org. +func TestGraphQLList_UnknownProjectHandle_NotFound(t *testing.T) { + repo := &mockGraphQLAPIRepo{} + svc := newGraphQLTestService(repo, nil) // mockGraphQLProjectRepo.project == nil => "not found" + + _, err := svc.List("org-1", "does-not-exist", repository.ListOptions{Limit: 100, Offset: 0}) + if err == nil { + t.Fatal("expected an error for an unknown project handle") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeProjectRefNotFound { + t.Errorf("expected %s, got %s", apperror.CodeProjectRefNotFound, code) + } +} + +// TestGraphQLCreate_MalformedSDL_SucceedsWithEmptySchema guards resolveSchema's +// best-effort posture for the "inline" source: invalid SDL text is a +// resolution-quality problem (like a failed introspection or sdlUrl fetch), +// not a structural one, so it must not block creation. +func TestGraphQLCreate_MalformedSDL_SucceedsWithEmptySchema(t *testing.T) { + repo := &mockGraphQLAPIRepo{} + project := &model.Project{ID: "project-uuid", OrganizationID: "org-1"} + svc := newGraphQLTestService(repo, project) + + req := &api.CreateGraphQLAPIRequest{ + DisplayName: "Broken API", + Context: "/broken", + Version: "v1.0", + ProjectId: "project-uuid", + Sdl: graphQLStrPtr("this is not { valid SDL at all"), + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: graphQLStrPtr("https://example.com/graphql")}}, + } + + resp, err := svc.Create("org-1", "creator-uuid", req) + if err != nil { + t.Fatalf("expected creation to succeed despite malformed SDL, got: %v", err) + } + if resp == nil { + t.Fatal("expected a response, got nil") + } + if repo.created == nil { + t.Fatal("expected repo.Create to be called") + } + if repo.created.Configuration.SDL != "" { + t.Errorf("expected empty SDL for a schema that failed validation, got %q", repo.created.Configuration.SDL) + } +} + +// TestGraphQLCreate_SDLWithNoQueryRoot_SucceedsWithEmptySchema covers the +// schema.Query == nil branch in validateGraphQLSDL — syntactically valid SDL +// that nonetheless never defines a Query root type. Distinct from the +// malformed-syntax case above, which never reaches that check. Like any other +// resolution-quality failure, this must not block creation. +func TestGraphQLCreate_SDLWithNoQueryRoot_SucceedsWithEmptySchema(t *testing.T) { + repo := &mockGraphQLAPIRepo{} + project := &model.Project{ID: "project-uuid", OrganizationID: "org-1"} + svc := newGraphQLTestService(repo, project) + + req := &api.CreateGraphQLAPIRequest{ + DisplayName: "No Query Root API", + Context: "/no-query-root", + Version: "v1.0", + ProjectId: "project-uuid", + Sdl: graphQLStrPtr("type Mutation { addCountry(name: String!): String }"), + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: graphQLStrPtr("https://example.com/graphql")}}, + } + + resp, err := svc.Create("org-1", "creator-uuid", req) + if err != nil { + t.Fatalf("expected creation to succeed despite a schema with no Query root type, got: %v", err) + } + if resp == nil { + t.Fatal("expected a response, got nil") + } + if repo.created == nil { + t.Fatal("expected repo.Create to be called") + } + if repo.created.Configuration.SDL != "" { + t.Errorf("expected empty SDL for a schema with no Query root type, got %q", repo.created.Configuration.SDL) + } +} + +// TestGraphQLCreate_SDLTakesPrecedenceOverIntrospection guards resolveSchema's +// ordering: when both sdl and upstream.main.url are supplied, sdl must win and +// introspection must never be attempted — asserted here by failing the test if +// the introspection endpoint receives any request at all. +func TestGraphQLCreate_SDLTakesPrecedenceOverIntrospection(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("introspection endpoint must not be called when sdl is supplied") + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + repo := &mockGraphQLAPIRepo{} + project := &model.Project{ID: "project-uuid", OrganizationID: "org-1"} + svc := newGraphQLTestService(repo, project) + + req := &api.CreateGraphQLAPIRequest{ + DisplayName: "SDL Precedence API", + Context: "/sdl-precedence", + Version: "v1.0", + ProjectId: "project-uuid", + Sdl: graphQLStrPtr(validCountriesGraphQLSDL), + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: graphQLStrPtr(server.URL)}}, + } + + if _, err := svc.Create("org-1", "creator-uuid", req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if repo.created.Configuration.IntrospectionMode != "SDL" { + t.Errorf("expected introspectionMode SDL when sdl is supplied alongside upstream.main.url, got %q", repo.created.Configuration.IntrospectionMode) + } + if repo.created.Configuration.SDL != validCountriesGraphQLSDL { + t.Errorf("expected the supplied sdl to be used verbatim, got %q", repo.created.Configuration.SDL) + } +} + +// TestGraphQLCreate_SDLAndSDLUrlMutuallyExclusive guards resolveSchema's +// structural validation — sdl and sdlUrl must never both be honored silently. +// With no explicit schemaSource, the presence of sdl infers "inline", so this +// is a schemaSource mismatch — a request-shape problem reported via the +// specific-message ValidationFailed entry (400), not the sterile +// GraphQLAPISchemaResolveFailed entry, which has no format verb for a +// specific reason and is reserved for resolution-quality failures. +func TestGraphQLCreate_SDLAndSDLUrlMutuallyExclusive(t *testing.T) { + repo := &mockGraphQLAPIRepo{} + project := &model.Project{ID: "project-uuid", OrganizationID: "org-1"} + svc := newGraphQLTestService(repo, project) + + req := &api.CreateGraphQLAPIRequest{ + DisplayName: "Both SDL Sources API", + Context: "/both-sdl-sources", + Version: "v1.0", + ProjectId: "project-uuid", + Sdl: graphQLStrPtr(validCountriesGraphQLSDL), + SdlUrl: graphQLStrPtr("https://example.com/schema.graphql"), + } + + _, err := svc.Create("org-1", "creator-uuid", req) + if err == nil { + t.Fatal("expected an error when both sdl and sdlUrl are supplied") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeCommonValidationFailed { + t.Errorf("expected %s, got %s", apperror.CodeCommonValidationFailed, code) + } + if repo.created != nil { + t.Error("expected no repository write when sdl and sdlUrl are both supplied") + } +} + +// TestGraphQLCreate_SDLUrlFetchFailure_SucceedsWithEmptySchema covers the +// sdlUrl fetch path: a URL the SSRF guard refuses (loopback, standing in for +// "unreachable/disallowed") is a resolution-quality failure, not a structural +// one, so — like a failed introspection or malformed inline SDL — it must not +// block creation. The successful-fetch path is covered by +// utils.TestFetchOpenAPISpecFromURL_*, mirroring TestResolveTemplateOpenAPISpec's +// convention for the identical LLM-provider-template case — ipIsAllowed can't +// be overridden from this package, so a real successful fetch isn't +// exercisable here. +func TestGraphQLCreate_SDLUrlFetchFailure_SucceedsWithEmptySchema(t *testing.T) { + repo := &mockGraphQLAPIRepo{} + project := &model.Project{ID: "project-uuid", OrganizationID: "org-1"} + svc := newGraphQLTestService(repo, project) + + req := &api.CreateGraphQLAPIRequest{ + DisplayName: "SDL URL Blocked API", + Context: "/sdl-url-blocked", + Version: "v1.0", + ProjectId: "project-uuid", + SdlUrl: graphQLStrPtr("http://127.0.0.1:9/schema.graphql"), + } + + resp, err := svc.Create("org-1", "creator-uuid", req) + if err != nil { + t.Fatalf("expected creation to succeed despite a blocked sdlUrl, got: %v", err) + } + if resp == nil { + t.Fatal("expected a response, got nil") + } + if repo.created == nil { + t.Fatal("expected repo.Create to be called") + } + if repo.created.Configuration.SDL != "" { + t.Errorf("expected empty SDL when sdlUrl fetch fails, got %q", repo.created.Configuration.SDL) + } +} + +// TestGraphQLCreate_SDLUrlFetchFailure_DoesNotFallBackToIntrospection locks in +// a real design decision in resolveSchema: schemaSource "url" (inferred here +// from sdlUrl being the only schema field populated) only ever attempts the +// URL fetch — it does NOT silently fall back to introspecting +// upstream.main.url just because that upstream is present and reachable. The +// introspection endpoint must never be called in this case, and the failed +// fetch still succeeds with an empty schema rather than erroring. +func TestGraphQLCreate_SDLUrlFetchFailure_DoesNotFallBackToIntrospection(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("introspection endpoint must not be called when sdlUrl was supplied and failed") + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + repo := &mockGraphQLAPIRepo{} + project := &model.Project{ID: "project-uuid", OrganizationID: "org-1"} + svc := newGraphQLTestService(repo, project) + + req := &api.CreateGraphQLAPIRequest{ + DisplayName: "SDL URL Blocked With Upstream API", + Context: "/sdl-url-blocked-with-upstream", + Version: "v1.0", + ProjectId: "project-uuid", + SdlUrl: graphQLStrPtr("http://127.0.0.1:9/schema.graphql"), + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: graphQLStrPtr(server.URL)}}, + } + + resp, err := svc.Create("org-1", "creator-uuid", req) + if err != nil { + t.Fatalf("expected creation to succeed despite a blocked sdlUrl, even with a reachable upstream present, got: %v", err) + } + if resp == nil { + t.Fatal("expected a response, got nil") + } + if repo.created == nil { + t.Fatal("expected repo.Create to be called") + } + if repo.created.Configuration.SDL != "" { + t.Errorf("expected empty SDL when sdlUrl fetch fails, got %q", repo.created.Configuration.SDL) + } +} + +// TestGraphQLCreate_MissingSDLAndUpstream_ValidationFailed covers the case +// where nothing to derive a schema from was supplied at all. With no +// explicit schemaSource, this infers "introspection" (the same default as +// before schemaSource existed), and introspection has no upstream.main.url to +// call — that's a structural problem (there's nothing to even attempt), not a +// resolution-quality one, so it's reported immediately via the +// specific-message ValidationFailed entry (400), not the sterile +// GraphQLAPISchemaResolveFailed entry reserved for resolution-quality +// failures. +func TestGraphQLCreate_MissingSDLAndUpstream_ValidationFailed(t *testing.T) { + repo := &mockGraphQLAPIRepo{} + project := &model.Project{ID: "project-uuid", OrganizationID: "org-1"} + svc := newGraphQLTestService(repo, project) + + req := &api.CreateGraphQLAPIRequest{ + DisplayName: "No Schema Source API", + Context: "/no-schema", + Version: "v1.0", + ProjectId: "project-uuid", + // Neither Sdl nor Upstream.Main.Url supplied. + } + + _, err := svc.Create("org-1", "creator-uuid", req) + if err == nil { + t.Fatal("expected an error when neither sdl nor upstream.main.url is supplied") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeCommonValidationFailed { + t.Errorf("expected %s, got %s", apperror.CodeCommonValidationFailed, code) + } +} + +// TestGraphQLValidateSchema_Inline_Success guards ValidateSchema's happy path +// for schemaSource "inline" — it should behave identically to Create's own +// resolution, just without persisting anything. +func TestGraphQLValidateSchema_Inline_Success(t *testing.T) { + svc := newGraphQLTestService(&mockGraphQLAPIRepo{}, nil) + + req := api.ValidateGraphQLSchemaRequest{ + Sdl: graphQLStrPtr(validCountriesGraphQLSDL), + } + + resolution, err := svc.ValidateSchema(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !resolution.Resolved { + t.Fatal("expected the schema to resolve") + } + if resolution.SDL != validCountriesGraphQLSDL { + t.Errorf("expected the resolved sdl to match the supplied sdl verbatim, got %q", resolution.SDL) + } + if resolution.IntrospectionMode != "SDL" { + t.Errorf("expected introspectionMode SDL, got %q", resolution.IntrospectionMode) + } +} + +// TestGraphQLValidateSchema_Introspection_Success guards the schemaSource +// "introspection" (default) happy path via a real httptest introspection +// endpoint. +func TestGraphQLValidateSchema_Introspection_Success(t *testing.T) { + introspectionJSON := `{ + "data": { + "__schema": { + "queryType": {"name": "Query"}, + "mutationType": null, + "subscriptionType": null, + "types": [ + { + "kind": "OBJECT", + "name": "Query", + "description": "", + "fields": [ + { + "name": "hello", + "description": "", + "args": [], + "type": {"kind": "SCALAR", "name": "String", "ofType": null} + } + ] + } + ] + } + } + }` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(introspectionJSON)) + })) + defer server.Close() + + svc := newGraphQLTestService(&mockGraphQLAPIRepo{}, nil) + + req := api.ValidateGraphQLSchemaRequest{ + Upstream: &api.Upstream{Main: api.UpstreamDefinition{Url: graphQLStrPtr(server.URL)}}, + } + + resolution, err := svc.ValidateSchema(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !resolution.Resolved { + t.Fatal("expected the schema to resolve via introspection") + } + if resolution.IntrospectionMode != "ENDPOINT" { + t.Errorf("expected introspectionMode ENDPOINT, got %q", resolution.IntrospectionMode) + } + if !strings.Contains(resolution.SDL, "type Query") || !strings.Contains(resolution.SDL, "hello") { + t.Errorf("expected derived SDL to contain a Query type and the introspected field, got: %s", resolution.SDL) + } +} + +// TestGraphQLValidateSchema_ResolutionFailure_ReturnsUnresolved guards +// ValidateSchema's best-effort posture: a resolution-quality failure (here, +// malformed SDL) is never an error — it comes back as Resolved: false, the +// same shape Create/Update treat as "no schema, but the request succeeds." +func TestGraphQLValidateSchema_ResolutionFailure_ReturnsUnresolved(t *testing.T) { + svc := newGraphQLTestService(&mockGraphQLAPIRepo{}, nil) + + req := api.ValidateGraphQLSchemaRequest{ + Sdl: graphQLStrPtr("type Query { countries: [Country "), // unterminated brace + } + + resolution, err := svc.ValidateSchema(req) + if err != nil { + t.Fatalf("expected no error for a resolution-quality failure, got: %v", err) + } + if resolution.Resolved { + t.Fatal("expected the malformed SDL to fail to resolve") + } + if resolution.SDL != "" { + t.Errorf("expected an empty sdl when resolution fails, got %q", resolution.SDL) + } +} + +// TestGraphQLValidateSchema_StructuralMismatch_ValidationFailed guards the +// structural side of resolveSchema still applying to ValidateSchema — a +// schemaSource/field mismatch is a real error, not a soft "unresolved" +// outcome, mirroring TestGraphQLCreate_SDLAndSDLUrlMutuallyExclusive. +func TestGraphQLValidateSchema_StructuralMismatch_ValidationFailed(t *testing.T) { + svc := newGraphQLTestService(&mockGraphQLAPIRepo{}, nil) + + req := api.ValidateGraphQLSchemaRequest{ + Sdl: graphQLStrPtr(validCountriesGraphQLSDL), + SdlUrl: graphQLStrPtr("https://example.com/schema.graphql"), + } + + _, err := svc.ValidateSchema(req) + if err == nil { + t.Fatal("expected an error when both sdl and sdlUrl are supplied") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeCommonValidationFailed { + t.Errorf("expected %s, got %s", apperror.CodeCommonValidationFailed, code) + } +} + +// TestGraphQLValidateSchema_NoFieldsSupplied_ValidationFailed covers the case +// where nothing is supplied at all: schemaSource infers "introspection" (the +// default), which then has no upstream.main.url to call — a structural +// problem, not a resolution-quality one, matching Create's equivalent check. +func TestGraphQLValidateSchema_NoFieldsSupplied_ValidationFailed(t *testing.T) { + svc := newGraphQLTestService(&mockGraphQLAPIRepo{}, nil) + + _, err := svc.ValidateSchema(api.ValidateGraphQLSchemaRequest{}) + if err == nil { + t.Fatal("expected an error when no schema-source fields are supplied at all") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeCommonValidationFailed { + t.Errorf("expected %s, got %s", apperror.CodeCommonValidationFailed, code) + } +} + +// TestGraphQLCreate_MissingContext_ValidationFailed covers the +// displayName/version/context required-fields check with context specifically +// omitted, matching the test-scenarios sheet's "context omitted" case. +func TestGraphQLCreate_MissingContext_ValidationFailed(t *testing.T) { + repo := &mockGraphQLAPIRepo{} + project := &model.Project{ID: "project-uuid", OrganizationID: "org-1"} + svc := newGraphQLTestService(repo, project) + + req := &api.CreateGraphQLAPIRequest{ + DisplayName: "Countries GraphQL API", + Version: "v1.0", + ProjectId: "project-uuid", + Sdl: graphQLStrPtr(validCountriesGraphQLSDL), + // Context omitted. + } + + _, err := svc.Create("org-1", "creator-uuid", req) + if err == nil { + t.Fatal("expected an error when context is omitted") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeCommonValidationFailed { + t.Errorf("expected %s, got %s", apperror.CodeCommonValidationFailed, code) + } + if repo.created != nil { + t.Error("expected no repository write when a required field is missing") + } +} + +func TestGraphQLCreate_ProjectRefNotFound_CrossOrgProject(t *testing.T) { + repo := &mockGraphQLAPIRepo{} + // Project belongs to a different organization than the caller. + project := &model.Project{ID: "project-uuid", OrganizationID: "other-org"} + svc := newGraphQLTestService(repo, project) + + req := &api.CreateGraphQLAPIRequest{ + DisplayName: "Countries GraphQL API", + Context: "/countries", + Version: "v1.0", + ProjectId: "project-uuid", + Sdl: graphQLStrPtr(validCountriesGraphQLSDL), + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: graphQLStrPtr("https://example.com/graphql")}}, + } + + _, err := svc.Create("org-1", "creator-uuid", req) + if err == nil { + t.Fatal("expected an error for a project belonging to a different organization") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeProjectRefNotFound { + t.Errorf("expected %s, got %s", apperror.CodeProjectRefNotFound, code) + } +} + +// TestGraphQLUpdate_Success covers the happy path Update never had a test for +// (only the DP-originated-blocked case existed) — a CP-originated artifact's +// displayName/version/sdl are replaced and persisted, and the response +// reflects the new values. +func TestGraphQLUpdate_Success(t *testing.T) { + stored := &model.GraphQLAPI{ + ID: "some-uuid", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + ProjectID: "project-uuid", + Origin: "control_plane", + // Started life via introspection — Update below supplies sdl directly, + // which must flip introspectionMode back to SDL. + Configuration: model.GraphQLAPIConfig{SDL: validCountriesGraphQLSDL, IntrospectionMode: "ENDPOINT"}, + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + return stored, nil + }, + } + svc := newGraphQLTestService(repo, nil) + + updatedSDL := `type Query { + countries: [String] + country(code: ID!): String +}` + req := &api.GraphQLAPI{ + DisplayName: "Countries GraphQL API v2", + Context: "/countries", + Version: "v1.1", + Sdl: graphQLStrPtr(updatedSDL), + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: graphQLStrPtr("https://example.com/graphql")}}, + } + + resp, err := svc.Update("org-1", "countries-graphql-api", "updater-uuid", req) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + if repo.updated == nil { + t.Fatal("expected the repository Update to be called") + } + if repo.updated.Name != "Countries GraphQL API v2" || repo.updated.Version != "v1.1" { + t.Errorf("repo.Update was not given the new displayName/version: %+v", repo.updated) + } + if repo.updated.Configuration.SDL != updatedSDL { + t.Errorf("repo.Update was not given the new sdl: %q", repo.updated.Configuration.SDL) + } + if repo.updated.Configuration.IntrospectionMode != "SDL" { + t.Errorf("expected introspectionMode to flip to SDL when sdl is supplied directly, got %q", repo.updated.Configuration.IntrospectionMode) + } + if resp.IntrospectionMode == nil || *resp.IntrospectionMode != api.GraphQLIntrospectionMode("SDL") { + t.Errorf("expected the response introspectionMode to be SDL, got %v", resp.IntrospectionMode) + } + if repo.updated.UpdatedBy != "updater-uuid" { + t.Errorf("expected UpdatedBy to be set to the caller, got %q", repo.updated.UpdatedBy) + } + if resp.DisplayName != "Countries GraphQL API v2" || resp.Version != "v1.1" { + t.Errorf("Update response did not reflect the new values: %+v", resp) + } +} + +// TestGraphQLUpdate_IDMismatch_400 pins Update's body-vs-path handle guard +// (graphql_api.go: "if req.Id != nil && *req.Id != "" && *req.Id != handle"), +// which had no test at all despite being a real, already-shipped check — +// the same convention REST API update uses. +func TestGraphQLUpdate_IDMismatch_400(t *testing.T) { + stored := &model.GraphQLAPI{ + ID: "some-uuid", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + Configuration: model.GraphQLAPIConfig{SDL: validCountriesGraphQLSDL}, + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + return stored, nil + }, + } + svc := newGraphQLTestService(repo, nil) + + req := &api.GraphQLAPI{ + Id: graphQLStrPtr("a-different-handle"), + DisplayName: "Countries GraphQL API", + Context: "/countries", + Version: "v1.0", + Sdl: graphQLStrPtr(validCountriesGraphQLSDL), + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: graphQLStrPtr("https://example.com/graphql")}}, + } + + _, err := svc.Update("org-1", "countries-graphql-api", "updater-uuid", req) + if err == nil { + t.Fatal("expected an error when the body id does not match the path handle") + } + var appErr *apperror.Error + if !errors.As(err, &appErr) { + t.Fatalf("expected an *apperror.Error, got %T: %v", err, err) + } + if appErr.HTTPStatus != http.StatusBadRequest { + t.Errorf("expected 400, got %d", appErr.HTTPStatus) + } + if repo.updated != nil { + t.Error("expected no repository write when the id mismatches the path handle") + } +} + +// TestGraphQLUpdate_ReIntrospect_RefreshesSchema pins Update's re-introspection +// path: omitting both sdl and sdlUrl while upstream.main.url is set makes +// resolveSchema re-derive the schema via introspection, exactly like Create's +// introspection flow — Update has no separate "re-introspect" code path, it +// reuses resolveSchema unmodified, but this behavior had no test of its own. +func TestGraphQLUpdate_ReIntrospect_RefreshesSchema(t *testing.T) { + introspectionJSON := `{ + "data": { + "__schema": { + "queryType": {"name": "Query"}, + "mutationType": null, + "subscriptionType": null, + "types": [ + { + "kind": "OBJECT", + "name": "Query", + "description": "", + "fields": [ + { + "name": "updatedField", + "description": "", + "args": [], + "type": {"kind": "SCALAR", "name": "String", "ofType": null} + } + ] + } + ] + } + } + }` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(introspectionJSON)) + })) + defer server.Close() + + stored := &model.GraphQLAPI{ + ID: "some-uuid", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + Configuration: model.GraphQLAPIConfig{SDL: validCountriesGraphQLSDL, IntrospectionMode: "SDL"}, + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + return stored, nil + }, + } + svc := newGraphQLTestService(repo, nil) + + req := &api.GraphQLAPI{ + DisplayName: "Countries GraphQL API", + Context: "/countries", + Version: "v1.0", + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: graphQLStrPtr(server.URL)}}, + } + + if _, err := svc.Update("org-1", "countries-graphql-api", "updater-uuid", req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if repo.updated == nil { + t.Fatal("expected the repository Update to be called") + } + if repo.updated.Configuration.IntrospectionMode != "ENDPOINT" { + t.Errorf("expected introspectionMode to flip to ENDPOINT, got %q", repo.updated.Configuration.IntrospectionMode) + } + if !strings.Contains(repo.updated.Configuration.SDL, "updatedField") { + t.Errorf("expected the re-introspected SDL to reflect the backend's current schema, got: %s", repo.updated.Configuration.SDL) + } +} + +// TestGraphQLUpdate_ReIntrospectFails_PreservesExistingSchema pins Update's +// soft-fail fallback: unlike Create (which has no previous schema to fall +// back to), a failed re-introspection on Update must not blank out a schema +// that was working before this request — the update still succeeds, and the +// previously-stored sdl/introspectionMode are carried forward unchanged. +func TestGraphQLUpdate_ReIntrospectFails_PreservesExistingSchema(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + server.Close() // closed immediately — guarantees connection failure, not just a non-200 + + stored := &model.GraphQLAPI{ + ID: "some-uuid", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + Configuration: model.GraphQLAPIConfig{SDL: validCountriesGraphQLSDL, IntrospectionMode: "SDL"}, + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + return stored, nil + }, + } + svc := newGraphQLTestService(repo, nil) + + req := &api.GraphQLAPI{ + DisplayName: "Countries GraphQL API", + Context: "/countries", + Version: "v1.0", + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: graphQLStrPtr(server.URL)}}, + } + + if _, err := svc.Update("org-1", "countries-graphql-api", "updater-uuid", req); err != nil { + t.Fatalf("expected update to succeed despite a failed re-introspection, got: %v", err) + } + if repo.updated == nil { + t.Fatal("expected the repository Update to be called") + } + if repo.updated.Configuration.SDL != validCountriesGraphQLSDL { + t.Errorf("expected the previously-stored SDL to be preserved, got: %q", repo.updated.Configuration.SDL) + } + if repo.updated.Configuration.IntrospectionMode != "SDL" { + t.Errorf("expected the previously-stored introspectionMode to be preserved, got: %q", repo.updated.Configuration.IntrospectionMode) + } +} + +// TestGraphQLUpdate_MalformedSDL_PreservesExistingSchema is Update's +// counterpart to TestGraphQLCreate_MalformedSDL_SucceedsWithEmptySchema — +// resolveSchema's SDL parse validation is shared by both entry points, but +// Update's failure fallback differs from Create's: instead of ending up with +// an empty schema, a broken update keeps whatever schema was already stored. +func TestGraphQLUpdate_MalformedSDL_PreservesExistingSchema(t *testing.T) { + stored := &model.GraphQLAPI{ + ID: "some-uuid", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + Configuration: model.GraphQLAPIConfig{SDL: validCountriesGraphQLSDL}, + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + return stored, nil + }, + } + svc := newGraphQLTestService(repo, nil) + + req := &api.GraphQLAPI{ + DisplayName: "Countries GraphQL API", + Context: "/countries", + Version: "v1.0", + Sdl: graphQLStrPtr("this is not { valid SDL at all"), + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: graphQLStrPtr("https://example.com/graphql")}}, + } + + if _, err := svc.Update("org-1", "countries-graphql-api", "updater-uuid", req); err != nil { + t.Fatalf("expected update to succeed despite malformed SDL, got: %v", err) + } + if repo.updated == nil { + t.Fatal("expected the repository Update to be called") + } + if repo.updated.Configuration.SDL != validCountriesGraphQLSDL { + t.Errorf("expected the previously-stored SDL to be preserved, got: %q", repo.updated.Configuration.SDL) + } +} + +// TestGraphQLUpdate_SDLWithNoQueryRoot_PreservesExistingSchema is Update's +// counterpart to TestGraphQLCreate_SDLWithNoQueryRoot_SucceedsWithEmptySchema. +func TestGraphQLUpdate_SDLWithNoQueryRoot_PreservesExistingSchema(t *testing.T) { + stored := &model.GraphQLAPI{ + ID: "some-uuid", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + Configuration: model.GraphQLAPIConfig{SDL: validCountriesGraphQLSDL}, + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + return stored, nil + }, + } + svc := newGraphQLTestService(repo, nil) + + req := &api.GraphQLAPI{ + DisplayName: "Countries GraphQL API", + Context: "/countries", + Version: "v1.0", + Sdl: graphQLStrPtr("type Mutation { addCountry(name: String!): String }"), + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: graphQLStrPtr("https://example.com/graphql")}}, + } + + if _, err := svc.Update("org-1", "countries-graphql-api", "updater-uuid", req); err != nil { + t.Fatalf("expected update to succeed despite a schema with no Query root type, got: %v", err) + } + if repo.updated == nil { + t.Fatal("expected the repository Update to be called") + } + if repo.updated.Configuration.SDL != validCountriesGraphQLSDL { + t.Errorf("expected the previously-stored SDL to be preserved, got: %q", repo.updated.Configuration.SDL) + } +} + +// TestGraphQLUpdate_SDLAndSDLUrlMutuallyExclusive is Update's counterpart to +// TestGraphQLCreate_SDLAndSDLUrlMutuallyExclusive — the same resolveSchema +// structural validation is shared by both entry points, so this is still a +// hard failure, reported via the specific-message ValidationFailed entry +// (400). +func TestGraphQLUpdate_SDLAndSDLUrlMutuallyExclusive(t *testing.T) { + stored := &model.GraphQLAPI{ + ID: "some-uuid", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + Origin: "control_plane", + Configuration: model.GraphQLAPIConfig{SDL: validCountriesGraphQLSDL}, + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + return stored, nil + }, + } + svc := newGraphQLTestService(repo, nil) + + req := &api.GraphQLAPI{ + DisplayName: "Countries GraphQL API", + Context: "/countries", + Version: "v1.0", + Sdl: graphQLStrPtr(validCountriesGraphQLSDL), + SdlUrl: graphQLStrPtr("https://example.com/schema.graphql"), + } + + _, err := svc.Update("org-1", "countries-graphql-api", "updater-uuid", req) + if err == nil { + t.Fatal("expected an error when both sdl and sdlUrl are supplied") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeCommonValidationFailed { + t.Errorf("expected %s, got %s", apperror.CodeCommonValidationFailed, code) + } + if repo.updated != nil { + t.Error("expected no repository write when sdl and sdlUrl are both supplied") + } +} + +// TestGraphQLUpdate_SDLUrlFetchFailure_PreservesExistingSchema is Update's +// counterpart to TestGraphQLCreate_SDLUrlFetchFailure_SucceedsWithEmptySchema. +func TestGraphQLUpdate_SDLUrlFetchFailure_PreservesExistingSchema(t *testing.T) { + stored := &model.GraphQLAPI{ + ID: "some-uuid", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + Origin: "control_plane", + Configuration: model.GraphQLAPIConfig{SDL: validCountriesGraphQLSDL}, + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + return stored, nil + }, + } + svc := newGraphQLTestService(repo, nil) + + req := &api.GraphQLAPI{ + DisplayName: "Countries GraphQL API", + Context: "/countries", + Version: "v1.0", + SdlUrl: graphQLStrPtr("http://127.0.0.1:9/schema.graphql"), + } + + if _, err := svc.Update("org-1", "countries-graphql-api", "updater-uuid", req); err != nil { + t.Fatalf("expected update to succeed despite a blocked sdlUrl, got: %v", err) + } + if repo.updated == nil { + t.Fatal("expected the repository Update to be called") + } + if repo.updated.Configuration.SDL != validCountriesGraphQLSDL { + t.Errorf("expected the previously-stored SDL to be preserved, got: %q", repo.updated.Configuration.SDL) + } +} + +func TestGraphQLUpdate_DPOriginated_Blocked(t *testing.T) { + stored := &model.GraphQLAPI{ + ID: "some-uuid", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + Origin: "gateway_api", + Configuration: model.GraphQLAPIConfig{SDL: validCountriesGraphQLSDL}, + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + return stored, nil + }, + } + svc := newGraphQLTestService(repo, nil) + + req := &api.GraphQLAPI{ + DisplayName: "Countries GraphQL API", + Context: "/countries", + Version: "v1.0", + Sdl: graphQLStrPtr(validCountriesGraphQLSDL), + Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: graphQLStrPtr("https://example.com/graphql")}}, + } + + _, err := svc.Update("org-1", "countries-graphql-api", "updater-uuid", req) + if err == nil { + t.Fatal("expected an error updating a DP-originated (gateway_api) GraphQL API") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeArtifactReadOnly { + t.Errorf("expected %s, got %s", apperror.CodeArtifactReadOnly, code) + } + if repo.updated != nil { + t.Error("expected no repository write for a DP-originated artifact update") + } +} + +func TestGraphQLDelete_NotFound(t *testing.T) { + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + return nil, nil + }, + } + svc := newGraphQLTestService(repo, nil) + + err := svc.Delete("org-1", "does-not-exist", "deleter-uuid") + if err == nil { + t.Fatal("expected an error deleting a nonexistent GraphQL API") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPINotFound { + t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPINotFound, code) + } + if repo.deleted { + t.Error("expected the repository Delete to never be called for a 404") + } +} + +// stubOrgGatewaysRepo returns a fixed gateway list from GetByOrganizationID, +// for tests exercising deletion's fan-out broadcast (which reads every +// gateway in the org, not just associated ones — see GraphQLAPIService.Delete's +// comment on why: deployment_status rows may already be gone). +type stubOrgGatewaysRepo struct { + repository.GatewayRepository + gateways []*model.Gateway +} + +func (r *stubOrgGatewaysRepo) GetByOrganizationID(orgID string) ([]*model.Gateway, error) { + return r.gateways, nil +} + +// decodeGraphQLDeletionEvent extracts the ApiId from a captured +// "graphqlapi.deleted" event, mirroring decodeKeyName's envelope-unwrap +// pattern (deployment_apikey_backfill_test.go). +func decodeGraphQLDeletionEvent(t *testing.T, e eventhub.Event) string { + t.Helper() + var envelope dto.GatewayEventDTO + if err := json.Unmarshal([]byte(e.EventData), &envelope); err != nil { + t.Fatalf("failed to decode event envelope: %v", err) + } + if envelope.Type != EventTypeGraphQLAPIDeleted { + t.Fatalf("unexpected event type %q, want %q", envelope.Type, EventTypeGraphQLAPIDeleted) + } + payloadBytes, err := json.Marshal(envelope.Payload) + if err != nil { + t.Fatalf("failed to re-marshal payload: %v", err) + } + var deletion model.GraphQLAPIDeletionEvent + if err := json.Unmarshal(payloadBytes, &deletion); err != nil { + t.Fatalf("failed to decode deletion payload: %v", err) + } + return deletion.ApiId +} + +// TestGraphQLDelete_BroadcastsDeletionEventToAllOrgGateways pins the fix for +// the gap found auditing deployments/gateways/api-keys wiring for GraphQL: +// GraphQLAPIService.Delete previously deleted the row and audited it but +// never notified any gateway, leaving a stale artifact behind — unlike +// APIService.DeleteAPI (api.go) and MCPProxyService.Delete (mcp.go), which +// both fan out a deletion event to every gateway in the org. +func TestGraphQLDelete_BroadcastsDeletionEventToAllOrgGateways(t *testing.T) { + stored := &model.GraphQLAPI{ + ID: "graphql-uuid", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + Origin: "control_plane", + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + return stored, nil + }, + } + gatewayRepo := &stubOrgGatewaysRepo{gateways: []*model.Gateway{{ID: "gw-1"}, {ID: "gw-2"}}} + hub := &capturingEventHub{} + events := NewGatewayEventsService(hub, newTestIdentityService(), newTestLogger()) + + svc := NewGraphQLAPIService(repo, &mockGraphQLProjectRepo{}, &noopAuditRepo{}, nil, + gatewayRepo, &mockOrganizationRepo{}, events, newTestIdentityService(), slog.Default()) + + if err := svc.Delete("org-1", "countries-graphql-api", "deleter-uuid"); err != nil { + t.Fatalf("Delete() = %v, want success", err) + } + if !repo.deleted { + t.Fatal("expected the repository Delete to be called") + } + if len(hub.published) != 2 { + t.Fatalf("expected 2 broadcasts (one per org gateway), got %d", len(hub.published)) + } + for _, e := range hub.published { + if apiID := decodeGraphQLDeletionEvent(t, e); apiID != "graphql-uuid" { + t.Errorf("expected deletion event apiId %q, got %q", "graphql-uuid", apiID) + } + } +} + +// TestGraphQLDelete_DPOriginated_BlockedWhileDeployed pins the other half of +// the same fix: Delete now uses ensureOriginDeletable (same guard +// APIService.DeleteAPI/MCPProxyService.Delete use), not the stricter +// ensureOriginMutable — a DP-originated GraphQL API can be deleted from the +// control plane once undeployed everywhere, not never. +func TestGraphQLDelete_DPOriginated_BlockedWhileDeployed(t *testing.T) { + stored := &model.GraphQLAPI{ + ID: "graphql-uuid", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + Origin: "gateway_api", + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + return stored, nil + }, + } + deploymentRepo := &stubActiveDeploymentRepo{active: true} + + svc := NewGraphQLAPIService(repo, &mockGraphQLProjectRepo{}, &noopAuditRepo{}, deploymentRepo, + &mockGatewayRepository{}, &mockOrganizationRepo{}, nil, newTestIdentityService(), slog.Default()) + + err := svc.Delete("org-1", "countries-graphql-api", "deleter-uuid") + if err == nil { + t.Fatal("expected an error deleting a DP-originated GraphQL API that is still deployed") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeArtifactDeployed { + t.Errorf("expected %s, got %s", apperror.CodeArtifactDeployed, code) + } + if repo.deleted { + t.Error("expected the repository Delete to never be called while still deployed") + } +} + +// TestGraphQLDelete_DPOriginated_SucceedsOnceUndeployed is the other half of +// ensureOriginDeletable's contract, alongside +// TestGraphQLDelete_DPOriginated_BlockedWhileDeployed: a DP-originated +// artifact CAN be deleted from the control plane once it's undeployed on +// every gateway — the guard blocks deletion only while actively deployed, not +// unconditionally like the ensureOriginMutable guard Update still uses. +func TestGraphQLDelete_DPOriginated_SucceedsOnceUndeployed(t *testing.T) { + stored := &model.GraphQLAPI{ + ID: "graphql-uuid", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + Origin: "gateway_api", + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + return stored, nil + }, + } + deploymentRepo := &stubActiveDeploymentRepo{active: false} + + svc := NewGraphQLAPIService(repo, &mockGraphQLProjectRepo{}, &noopAuditRepo{}, deploymentRepo, + &stubOrgGatewaysRepo{}, &mockOrganizationRepo{}, nil, newTestIdentityService(), slog.Default()) + + if err := svc.Delete("org-1", "countries-graphql-api", "deleter-uuid"); err != nil { + t.Fatalf("Delete() = %v, want success for a DP-originated artifact with no active deployment", err) + } + if !repo.deleted { + t.Error("expected the repository Delete to be called once undeployed") + } +} + +// stubActiveDeploymentRepo reports a fixed HasActiveDeployment result, for +// exercising ensureOriginDeletable without a real DeploymentRepository. +type stubActiveDeploymentRepo struct { + repository.DeploymentRepository + active bool +} + +func (r *stubActiveDeploymentRepo) HasActiveDeployment(artifactUUID, orgID string) (bool, error) { + return r.active, nil +} diff --git a/platform-api/internal/service/graphql_apikey_test.go b/platform-api/internal/service/graphql_apikey_test.go new file mode 100644 index 0000000000..a3a31a6594 --- /dev/null +++ b/platform-api/internal/service/graphql_apikey_test.go @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +// GraphQL API keys reuse the existing generic APIKeyService (see +// internal/handler/graphql_apikey.go's doc comment for why a dedicated +// GraphQLAPIKeyService was NOT introduced) — these tests pin that the shared +// service works correctly end-to-end when called with constants.GraphQLApi, +// the same way the eventgateway plugin already calls it with +// constants.WebSubApi/constants.WebBrokerApi. + +import ( + "context" + "testing" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" +) + +// gqlKeyArtifactRepo is a minimal ArtifactRepository resolving one GraphQL API +// handle to a fixed UUID via GetAPIMetadataByHandleAndKind — the only method +// APIKeyService.CreateAPIKey/RevokeAPIKey actually call on it. The interface +// is embedded (mirroring guardStubArtifactRepo's approach in +// deployment_undeploy_guard_test.go) so every other method panics if +// accidentally invoked, rather than silently returning a zero value. +type gqlKeyArtifactRepo struct { + repository.ArtifactRepository + metadata *model.APIMetadata +} + +func (g *gqlKeyArtifactRepo) GetAPIMetadataByHandleAndKind(handle, kind, orgUUID string) (*model.APIMetadata, error) { + if handle == g.metadata.Handle && kind == constants.GraphQLApi { + return g.metadata, nil + } + return nil, nil +} + +// TestGraphQLAPIKey_CreateAndRevoke_Success exercises the shared APIKeyService +// with kind=constants.GraphQLApi end-to-end: create persists and broadcasts to +// every associated gateway, then revoke looks the key back up (ownership +// check passes since the same caller created it) and broadcasts a revocation. +func TestGraphQLAPIKey_CreateAndRevoke_Success(t *testing.T) { + apiUUID := "gql-uuid-1" + artifactRepo := &gqlKeyArtifactRepo{metadata: &model.APIMetadata{ID: apiUUID, Handle: "countries-graphql-api"}} + apiRepo := dpKeyAPIRepo{} // GetAPIGatewaysWithDetails returns one gateway — see artifact_dp_apikey_test.go + keyRepo := &dpCapturingAPIKeyRepo{} + events := NewGatewayEventsService(dpNoopEventHub{}, newTestIdentityService(), newTestLogger()) + + svc := NewAPIKeyService(apiRepo, artifactRepo, keyRepo, events, &noopAuditRepo{}, nil, newTestLogger()) + + plaintextKey := "test-plaintext-key" + createReq := &api.CreateAPIKeyRequest{ + ApiKey: &plaintextKey, + DisplayName: "My GraphQL Key", + } + if _, err := svc.CreateAPIKey(context.Background(), "countries-graphql-api", constants.GraphQLApi, "org-1", "creator-uuid", createReq); err != nil { + t.Fatalf("CreateAPIKey for GraphQL API = %v, want success", err) + } + if keyRepo.created == nil { + t.Fatal("expected the API key to be persisted") + } + if keyRepo.created.ArtifactUUID != apiUUID { + t.Errorf("persisted key ArtifactUUID = %q, want %q", keyRepo.created.ArtifactUUID, apiUUID) + } + keyName := keyRepo.created.Name + + if err := svc.RevokeAPIKey(context.Background(), "countries-graphql-api", constants.GraphQLApi, "org-1", keyName, "creator-uuid", false, false); err != nil { + t.Fatalf("RevokeAPIKey for GraphQL API = %v, want success", err) + } +} + +// TestGraphQLAPIKey_Revoke_NotCreator_Forbidden verifies the shared ownership +// predicate (canManageAPIKey) is enforced for GraphQL API keys exactly as it +// is for REST/WebSub/WebBroker: a caller who isn't the key's creator, and +// doesn't hold ap:api_key:all:manage, is denied. +func TestGraphQLAPIKey_Revoke_NotCreator_Forbidden(t *testing.T) { + apiUUID := "gql-uuid-1" + artifactRepo := &gqlKeyArtifactRepo{metadata: &model.APIMetadata{ID: apiUUID, Handle: "countries-graphql-api"}} + apiRepo := dpKeyAPIRepo{} + keyRepo := &dpCapturingAPIKeyRepo{} + events := NewGatewayEventsService(dpNoopEventHub{}, newTestIdentityService(), newTestLogger()) + + svc := NewAPIKeyService(apiRepo, artifactRepo, keyRepo, events, &noopAuditRepo{}, nil, newTestLogger()) + + plaintextKey := "test-plaintext-key" + createReq := &api.CreateAPIKeyRequest{ApiKey: &plaintextKey, DisplayName: "My GraphQL Key"} + if _, err := svc.CreateAPIKey(context.Background(), "countries-graphql-api", constants.GraphQLApi, "org-1", "creator-uuid", createReq); err != nil { + t.Fatalf("CreateAPIKey for GraphQL API = %v, want success", err) + } + keyName := keyRepo.created.Name + + err := svc.RevokeAPIKey(context.Background(), "countries-graphql-api", constants.GraphQLApi, "org-1", keyName, "someone-else", false, false) + if err == nil { + t.Fatal("expected an error revoking another user's GraphQL API key without ap:api_key:all:manage") + } + if code := graphQLCatalogCode(t, err); code != "REST_API_API_KEY_FORBIDDEN" { + t.Errorf("expected REST_API_API_KEY_FORBIDDEN (the shared ownership-forbidden code every kind currently returns), got %s", code) + } +} diff --git a/platform-api/internal/service/graphql_deployment.go b/platform-api/internal/service/graphql_deployment.go new file mode 100644 index 0000000000..425f3e026c --- /dev/null +++ b/platform-api/internal/service/graphql_deployment.go @@ -0,0 +1,606 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +import ( + "fmt" + "log/slog" + "strings" + "time" + + commonconstants "github.com/wso2/api-platform/common/constants" + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/config" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/dto" + "github.com/wso2/api-platform/platform-api/internal/gatewaytranslator" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/utils" + + "gopkg.in/yaml.v3" +) + +// GraphQLAPIDeploymentService handles business logic for GraphQL API deployment +// operations, using the shared deployments table and status model. +// +// This is a dedicated per-kind deployment service, following the precedent set +// by LLMProviderDeploymentService/LLMProxyDeploymentService (llm_deployment.go) +// rather than generalizing the REST-only DeploymentService (deployment.go). +// DeploymentService's core deploy logic is genuinely REST-typed — it calls +// s.apiRepo.GetAPIByUUID (returns *model.API, reads the REST-only `apis` table) +// and s.apiUtil.BuildAPIDeploymentYAML(*model.API) — so a GraphQL artifact UUID +// would 404 against it today. The generic pieces (DeploymentRepository, +// GatewayRepository, APIKeyRepository, the deployments/deployment_status +// tables) are reused as-is; only the REST-specific artifact lookup and YAML +// builder are kind-specific, exactly as they are for LLM Provider/Proxy. +type GraphQLAPIDeploymentService struct { + graphqlRepo repository.GraphQLAPIRepository + deploymentRepo repository.DeploymentRepository + gatewayRepo repository.GatewayRepository + orgRepo repository.OrganizationRepository + apiKeyRepo repository.APIKeyRepository + gatewayEventsService *GatewayEventsService + builds *BuildService + cfg *config.Server + slogger *slog.Logger +} + +// NewGraphQLAPIDeploymentService creates a new GraphQL API deployment service. +func NewGraphQLAPIDeploymentService( + graphqlRepo repository.GraphQLAPIRepository, + deploymentRepo repository.DeploymentRepository, + gatewayRepo repository.GatewayRepository, + orgRepo repository.OrganizationRepository, + apiKeyRepo repository.APIKeyRepository, + artifactRepo repository.ArtifactRepository, + gatewayEventsService *GatewayEventsService, + definitions ArtifactDefinitions, + cfg *config.Server, + slogger *slog.Logger, +) *GraphQLAPIDeploymentService { + return &GraphQLAPIDeploymentService{ + graphqlRepo: graphqlRepo, + deploymentRepo: deploymentRepo, + gatewayRepo: gatewayRepo, + orgRepo: orgRepo, + apiKeyRepo: apiKeyRepo, + gatewayEventsService: gatewayEventsService, + builds: NewBuildService(artifactRepo, deploymentRepo, definitions, cfg, slogger), + cfg: cfg, + slogger: slogger, + } +} + +// generateGraphQLAPIDeploymentYAML builds the deployment YAML struct for a +// GraphQL API. Mirrors APIUtil.BuildAPIDeploymentYAML (internal/utils/api.go) +// in shape — REST's simple struct-building approach, not LLM's +// policy-transformation pipeline, since GraphQL's configuration shape +// (policies + subscriptionPlans + a single upstream) is much closer to REST's +// than to LLM's rate-limit/guardrail model. +func generateGraphQLAPIDeploymentYAML(apiModel *model.GraphQLAPI) (dto.GraphQLAPIDeploymentYAML, error) { + if apiModel == nil { + return dto.GraphQLAPIDeploymentYAML{}, apperror.Internal.New().WithLogMessage("generateGraphQLAPIDeploymentYAML: apiModel is nil") + } + + var upstream *dto.GraphQLUpstream + if apiModel.Configuration.Upstream.Main != nil { + main := apiModel.Configuration.Upstream.Main + upstream = &dto.GraphQLUpstream{ + Main: &dto.GraphQLUpstreamTarget{ + URL: main.URL, + Ref: main.Ref, + Auth: main.Auth, // raw model.UpstreamAuth — the gateway needs the real credential, unlike API read responses + }, + } + if sandbox := apiModel.Configuration.Upstream.Sandbox; sandbox != nil { + upstream.Sandbox = &dto.GraphQLUpstreamTarget{ + URL: sandbox.URL, + Ref: sandbox.Ref, + Auth: sandbox.Auth, + } + } + } + + contextValue := "" + if apiModel.Configuration.Context != nil { + contextValue = *apiModel.Configuration.Context + } + + policies := make([]dto.Policy, 0, len(apiModel.Configuration.Policies)) + for _, p := range apiModel.Configuration.Policies { + policies = append(policies, dto.Policy{ + Name: p.Name, + Version: p.Version, + Params: p.Params, + ExecutionCondition: p.ExecutionCondition, + }) + } + + return dto.GraphQLAPIDeploymentYAML{ + ApiVersion: constants.GatewayApiVersion, + Kind: constants.GraphQLApi, + Metadata: dto.DeploymentMetadata{ + Name: apiModel.Handle, + Annotations: map[string]string{ + commonconstants.AnnotationProjectID: apiModel.ProjectID, + }, + Labels: map[string]string{ + commonconstants.DeprecatedLabelProjectID: apiModel.ProjectID, + }, + }, + Spec: dto.GraphQLAPIYAMLData{ + DisplayName: apiModel.Name, + Version: apiModel.Version, + Context: contextValue, + SubscriptionPlans: apiModel.Configuration.SubscriptionPlans, + Upstream: upstream, + Policies: policies, + }, + }, nil +} + +// DeployGraphQLAPI creates a new immutable deployment artifact and deploys it to a +// gateway. Mirrors LLMProviderDeploymentService.DeployLLMProvider. +func (s *GraphQLAPIDeploymentService) DeployGraphQLAPI(apiID string, req *api.DeployRequest, orgUUID, createdBy string) (*api.DeploymentResponse, error) { + if req == nil { + return nil, apperror.GraphQLAPIDeploymentValidationFailed.New("A request body is required.") + } + base, requestedBuild, err := ValidateDeployBase(req.Base, req.BuildId, apperror.GraphQLAPIDeploymentValidationFailed) + if err != nil { + return nil, err + } + gatewayHandle := strings.TrimSpace(req.GatewayId) + if gatewayHandle == "" { + return nil, apperror.GraphQLAPIDeploymentValidationFailed.New("Gateway ID is required.") + } + metadata := utils.MapValueOrEmpty(req.Metadata) + + gateway, err := s.gatewayRepo.GetByHandleAndOrgID(gatewayHandle, orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to get gateway: %w", err) + } + if gateway == nil { + return nil, apperror.GatewayNotFound.New() + } + gatewayID := gateway.ID + + apiModel, err := s.graphqlRepo.GetByHandle(apiID, orgUUID) + if err != nil { + return nil, err + } + if apiModel == nil { + return nil, apperror.GraphQLAPINotFound.New() + } + + // DP-originated artifacts are read-only in the control plane and cannot be + // (re)deployed from the CP. + if err := ensureOriginMutable(apiModel.Origin); err != nil { + return nil, err + } + + if req.Name == "" { + return nil, apperror.GraphQLAPIDeploymentValidationFailed.New("Deployment name is required.") + } + + // Ensure a gateway association exists for the target gateway before deploying, and + // resolve the deployment metadata — see APIService/LLMProviderDeploymentService for + // the full semantics of this pattern. + metadataProvided := req.Metadata != nil + deployMetaJSON, err := marshalDeploymentMetadata(metadata) + if err != nil { + return nil, err + } + effectiveMetaJSON, err := s.graphqlRepo.EnsureGatewayAssociation(apiModel.ID, gatewayID, orgUUID, createdBy, deployMetaJSON, metadataProvided) + if err != nil { + return nil, fmt.Errorf("failed to ensure gateway association: %w", err) + } + if metadata, err = unmarshalDeploymentMetadata(effectiveMetaJSON); err != nil { + return nil, err + } + + // What this deploy ships: a build prepared earlier, or a snapshot of the + // API as it stands now. A snapshot comes back unstored so it commits with + // the deployment below. + source, err := s.builds.SourceForDeploy(apiModel.ID, orgUUID, constants.GraphQLApi, createdBy, base, requestedBuild) + if err != nil { + return nil, err + } + apiDeployment, ok := source.Definition.(*dto.GraphQLAPIDeploymentYAML) + if !ok { + return nil, fmt.Errorf("artifact %s did not render as a GraphQL API definition", apiModel.ID) + } + sourceDataVersion := gatewaytranslator.PlatformDataVersion(source.DataVersion) + targetDataVersion := gatewaytranslator.GatewayDataVersionForGateway(gateway.Version) + if err := gatewaytranslator.Translate(constants.GraphQLApi, sourceDataVersion, targetDataVersion, apiDeployment); err != nil { + return nil, fmt.Errorf("failed to transform GraphQL API deployment for gateway %s: %w", gateway.Version, err) + } + contentBytes, err := yaml.Marshal(apiDeployment) + if err != nil { + return nil, fmt.Errorf("failed to marshal GraphQL API deployment YAML: %w", err) + } + + deploymentID, err := utils.GenerateUUID() + if err != nil { + return nil, fmt.Errorf("failed to generate deployment ID: %w", err) + } + deployed := model.DeploymentStatusDeployed + + deployment := &model.Deployment{ + DeploymentID: deploymentID, + Name: req.Name, + ArtifactID: apiModel.ID, + OrganizationID: orgUUID, + GatewayID: gatewayID, + BuildUUID: source.BuildUUID, + BuildID: source.BuildID, + Content: contentBytes, + Metadata: metadata, + Status: &deployed, + } + + if s.cfg.Deployments.MaxPerAPIGateway < 1 { + return nil, fmt.Errorf("MaxPerAPIGateway limit config must be at least 1, got %d", s.cfg.Deployments.MaxPerAPIGateway) + } + hardLimit := s.cfg.Deployments.MaxPerAPIGateway + constants.DeploymentLimitBuffer + // A build rendered for this deploy is stored with the deployment, in one + // transaction, so a recorded deployment always has the build it runs. + if source.NewBuild != nil { + err = s.deploymentRepo.CreateWithBuild(deployment, source.NewBuild, s.cfg.Deployments.MaxBuildsPerAPI, hardLimit) + } else { + err = s.deploymentRepo.CreateWithLimitEnforcement(deployment, hardLimit) + } + if err != nil { + if limitErr := s.builds.LimitError(err); limitErr != err { + return nil, limitErr + } + return nil, fmt.Errorf("failed to create deployment: %w", err) + } + + initialStatus := model.DeploymentStatusDeploying + performedAt := time.Now().UTC().Truncate(time.Millisecond) + if _, err := s.deploymentRepo.SetCurrentWithDetails( + apiModel.ID, orgUUID, gatewayID, deploymentID, + initialStatus, string(model.DeploymentStatusDeployed), + &performedAt, "", + ); err != nil { + return nil, fmt.Errorf("failed to set deployment status for GraphQL API: %w", err) + } + + if s.gatewayEventsService != nil { + deploymentEvent := &model.GraphQLAPIDeploymentEvent{ + ApiId: apiModel.ID, + DeploymentID: deploymentID, + PerformedAt: performedAt, + } + if err := s.gatewayEventsService.BroadcastGraphQLAPIDeploymentEvent(gatewayID, deploymentEvent); err != nil { + s.slogger.Warn("Failed to broadcast GraphQL API deployment event", "error", err) + } + + // Push existing active API keys for this artifact to the (possibly newly + // associated) gateway — see BackfillAPIKeysToGateway. + BackfillAPIKeysToGateway(s.apiKeyRepo, s.gatewayRepo, s.gatewayEventsService, s.slogger, apiModel.ID, gatewayID, createdBy) + } + + return toAPIDeploymentResponse( + s.gatewayRepo, + deployment.DeploymentID, + deployment.Name, + deployment.GatewayID, + initialStatus, + deployment.BaseDeploymentID, + deployment.Metadata, + deployment.CreatedAt, + deployment.UpdatedAt, + nil, + ) +} + +// RestoreGraphQLAPIDeployment restores a previous deployment (ARCHIVED or +// UNDEPLOYED). Mirrors LLMProviderDeploymentService.RestoreLLMProviderDeployment. +func (s *GraphQLAPIDeploymentService) RestoreGraphQLAPIDeployment(apiID, deploymentID, gatewayID, orgUUID string) (*api.DeploymentResponse, error) { + apiModel, err := s.graphqlRepo.GetByHandle(apiID, orgUUID) + if err != nil { + return nil, err + } + if apiModel == nil { + return nil, apperror.GraphQLAPINotFound.New() + } + if err := ensureOriginMutable(apiModel.Origin); err != nil { + return nil, err + } + + targetDeployment, err := s.deploymentRepo.GetWithContent(deploymentID, apiModel.ID, orgUUID) + if err != nil { + return nil, err + } + if targetDeployment == nil { + return nil, apperror.DeploymentNotFound.New() + } + resolvedGateway, err := s.gatewayRepo.GetByHandleAndOrgID(strings.TrimSpace(gatewayID), orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to get gateway: %w", err) + } + if resolvedGateway == nil { + return nil, apperror.GatewayNotFound.New() + } + if targetDeployment.GatewayID != resolvedGateway.ID { + return nil, apperror.DeploymentGatewayMismatch.New() + } + + currentDeploymentID, status, _, err := s.deploymentRepo.GetStatus(apiModel.ID, orgUUID, targetDeployment.GatewayID) + if err != nil { + return nil, fmt.Errorf("failed to get deployment status: %w", err) + } + if currentDeploymentID == deploymentID && status.IsDeployedOrDeploying() { + return nil, apperror.DeploymentRestoreConflict.New() + } + + gateway, err := s.gatewayRepo.GetByUUID(targetDeployment.GatewayID) + if err != nil { + return nil, fmt.Errorf("failed to get gateway: %w", err) + } + if gateway == nil || gateway.OrganizationID != orgUUID { + return nil, apperror.GatewayNotFound.New() + } + + initialStatus := model.DeploymentStatusDeploying + performedAt := time.Now().UTC().Truncate(time.Millisecond) + updatedAt, err := s.deploymentRepo.SetCurrentWithDetails( + apiModel.ID, orgUUID, targetDeployment.GatewayID, deploymentID, + initialStatus, string(model.DeploymentStatusDeployed), + &performedAt, "", + ) + if err != nil { + return nil, fmt.Errorf("failed to set current deployment: %w", err) + } + + if s.gatewayEventsService != nil { + deploymentEvent := &model.GraphQLAPIDeploymentEvent{ + ApiId: apiModel.ID, + DeploymentID: deploymentID, + PerformedAt: performedAt, + } + if err := s.gatewayEventsService.BroadcastGraphQLAPIDeploymentEvent(targetDeployment.GatewayID, deploymentEvent); err != nil { + s.slogger.Warn("Failed to broadcast GraphQL API deployment event", "error", err) + } + BackfillAPIKeysToGateway(s.apiKeyRepo, s.gatewayRepo, s.gatewayEventsService, s.slogger, apiModel.ID, targetDeployment.GatewayID, "") + } + + return toAPIDeploymentResponse( + s.gatewayRepo, + targetDeployment.DeploymentID, + targetDeployment.Name, + targetDeployment.GatewayID, + initialStatus, + targetDeployment.BaseDeploymentID, + targetDeployment.Metadata, + targetDeployment.CreatedAt, + &updatedAt, + nil, + ) +} + +// UndeployGraphQLAPIDeployment undeploys an active deployment. Mirrors +// LLMProviderDeploymentService.UndeployLLMProviderDeployment. +func (s *GraphQLAPIDeploymentService) UndeployGraphQLAPIDeployment(apiID, deploymentID, gatewayID, orgUUID string) (*api.DeploymentResponse, error) { + apiModel, err := s.graphqlRepo.GetByHandle(apiID, orgUUID) + if err != nil { + return nil, err + } + if apiModel == nil { + return nil, apperror.GraphQLAPINotFound.New() + } + if err := ensureOriginMutable(apiModel.Origin); err != nil { + return nil, err + } + + deployment, err := s.deploymentRepo.GetWithState(deploymentID, apiModel.ID, orgUUID) + if err != nil { + return nil, err + } + if deployment == nil { + return nil, apperror.DeploymentNotFound.New() + } + resolvedGateway, err := s.gatewayRepo.GetByHandleAndOrgID(strings.TrimSpace(gatewayID), orgUUID) + if err != nil { + return nil, fmt.Errorf("failed to get gateway: %w", err) + } + if resolvedGateway == nil { + return nil, apperror.GatewayNotFound.New() + } + if deployment.GatewayID != resolvedGateway.ID { + return nil, apperror.DeploymentGatewayMismatch.New() + } + if deployment.Status == nil || !deployment.Status.IsDeployedOrDeploying() { + return nil, apperror.DeploymentNotActive.New("GraphQL API") + } + + gateway, err := s.gatewayRepo.GetByUUID(deployment.GatewayID) + if err != nil { + return nil, fmt.Errorf("failed to get gateway: %w", err) + } + if gateway == nil || gateway.OrganizationID != orgUUID { + return nil, apperror.GatewayNotFound.New() + } + + initialStatus := model.DeploymentStatusUndeploying + performedAt := time.Now().UTC().Truncate(time.Millisecond) + newUpdatedAt, err := s.deploymentRepo.SetCurrentWithDetails( + apiModel.ID, orgUUID, deployment.GatewayID, deploymentID, + initialStatus, string(model.DeploymentStatusUndeployed), + &performedAt, "", + ) + if err != nil { + return nil, fmt.Errorf("failed to update deployment status: %w", err) + } + + if s.gatewayEventsService != nil { + undeploymentEvent := &model.GraphQLAPIUndeploymentEvent{ + ApiId: apiModel.ID, + DeploymentID: deploymentID, + PerformedAt: performedAt, + } + if err := s.gatewayEventsService.BroadcastGraphQLAPIUndeploymentEvent(deployment.GatewayID, undeploymentEvent); err != nil { + s.slogger.Warn("Failed to broadcast GraphQL API undeployment event", "error", err) + } + } + + return toAPIDeploymentResponse( + s.gatewayRepo, + deployment.DeploymentID, + deployment.Name, + deployment.GatewayID, + initialStatus, + deployment.BaseDeploymentID, + deployment.Metadata, + deployment.CreatedAt, + &newUpdatedAt, + nil, + ) +} + +// DeleteGraphQLAPIDeployment permanently deletes an undeployed deployment +// artifact. Mirrors LLMProviderDeploymentService.DeleteLLMProviderDeployment. +func (s *GraphQLAPIDeploymentService) DeleteGraphQLAPIDeployment(apiID, deploymentID, orgUUID string) error { + apiModel, err := s.graphqlRepo.GetByHandle(apiID, orgUUID) + if err != nil { + return err + } + if apiModel == nil { + return apperror.GraphQLAPINotFound.New() + } + + deployment, err := s.deploymentRepo.GetWithState(deploymentID, apiModel.ID, orgUUID) + if err != nil { + return err + } + if deployment == nil { + return apperror.DeploymentNotFound.New() + } + if deployment.Status != nil && deployment.Status.IsDeployedOrDeploying() { + return apperror.DeploymentActive.New() + } + + if err := s.deploymentRepo.Delete(deploymentID, apiModel.ID, orgUUID); err != nil { + return fmt.Errorf("failed to delete deployment: %w", err) + } + + return nil +} + +// GetGraphQLAPIDeployments retrieves all deployments for a GraphQL API with +// optional filters. Mirrors LLMProviderDeploymentService.GetLLMProviderDeployments. +func (s *GraphQLAPIDeploymentService) GetGraphQLAPIDeployments(apiID, orgUUID string, gatewayID *string, status *string) (*api.DeploymentListResponse, error) { + apiModel, err := s.graphqlRepo.GetByHandle(apiID, orgUUID) + if err != nil { + return nil, err + } + if apiModel == nil { + return nil, apperror.GraphQLAPINotFound.New() + } + + if status != nil { + validStatuses := map[string]bool{ + string(model.DeploymentStatusDeployed): true, + string(model.DeploymentStatusUndeployed): true, + string(model.DeploymentStatusDeploying): true, + string(model.DeploymentStatusUndeploying): true, + string(model.DeploymentStatusFailed): true, + string(model.DeploymentStatusArchived): true, + } + if !validStatuses[*status] { + return nil, apperror.DeploymentInvalidStatus.New() + } + } + + gatewayUUID, found, err := resolveGatewayFilter(s.gatewayRepo, gatewayID, orgUUID) + if err != nil { + return nil, err + } + if !found { + return &api.DeploymentListResponse{Count: 0, List: []api.DeploymentResponse{}}, nil + } + + if s.cfg.Deployments.MaxPerAPIGateway < 1 { + return nil, fmt.Errorf("MaxPerAPIGateway config value must be at least 1, got %d", s.cfg.Deployments.MaxPerAPIGateway) + } + deployments, err := s.deploymentRepo.GetDeploymentsWithState(apiModel.ID, orgUUID, gatewayUUID, status, s.cfg.Deployments.MaxPerAPIGateway) + if err != nil { + return nil, err + } + + items := make([]api.DeploymentResponse, 0, len(deployments)) + for _, d := range deployments { + mapped, err := toAPIDeploymentResponse( + s.gatewayRepo, + d.DeploymentID, + d.Name, + d.GatewayID, + *d.Status, + d.BaseDeploymentID, + d.Metadata, + d.CreatedAt, + d.UpdatedAt, + d.StatusReason, + ) + if err != nil { + return nil, err + } + items = append(items, *mapped) + } + + return &api.DeploymentListResponse{ + Count: len(items), + List: items, + }, nil +} + +// GetGraphQLAPIDeployment retrieves a specific deployment by ID. Mirrors +// LLMProviderDeploymentService.GetLLMProviderDeployment. +func (s *GraphQLAPIDeploymentService) GetGraphQLAPIDeployment(apiID, deploymentID, orgUUID string) (*api.DeploymentResponse, error) { + apiModel, err := s.graphqlRepo.GetByHandle(apiID, orgUUID) + if err != nil { + return nil, err + } + if apiModel == nil { + return nil, apperror.GraphQLAPINotFound.New() + } + + deployment, err := s.deploymentRepo.GetWithState(deploymentID, apiModel.ID, orgUUID) + if err != nil { + return nil, err + } + if deployment == nil { + return nil, apperror.DeploymentNotFound.New() + } + + return toAPIDeploymentResponse( + s.gatewayRepo, + deployment.DeploymentID, + deployment.Name, + deployment.GatewayID, + *deployment.Status, + deployment.BaseDeploymentID, + deployment.Metadata, + deployment.CreatedAt, + deployment.UpdatedAt, + deployment.StatusReason, + ) +} diff --git a/platform-api/internal/service/graphql_deployment_test.go b/platform-api/internal/service/graphql_deployment_test.go new file mode 100644 index 0000000000..3d1c80050e --- /dev/null +++ b/platform-api/internal/service/graphql_deployment_test.go @@ -0,0 +1,474 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +import ( + "strings" + "testing" + "time" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/config" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" +) + +// mockGraphQLArtifactRepo resolves any UUID/org pair to a GraphQLApi-kind +// artifact row — the only lookup BuildService.resolve does before handing off +// to graphqlAPIDefinition.Current, which performs the real, kind-specific +// lookup via the GraphQL repo itself. Every other method panics if invoked, +// via the embedded (nil) interface — mirrors gqlKeyArtifactRepo's approach in +// graphql_apikey_test.go. +type mockGraphQLArtifactRepo struct { + repository.ArtifactRepository +} + +func (m *mockGraphQLArtifactRepo) GetByUUID(uuid, orgUUID string) (*model.Artifact, error) { + return &model.Artifact{UUID: uuid, OrganizationUUID: orgUUID, Type: constants.GraphQLApi}, nil +} + +// newGraphQLDeploymentTestService wires a GraphQLAPIDeploymentService for +// tests, reusing the shared mockDeploymentRepo (deployment_test.go) and +// mockGatewayRepository (gateway_properties_test.go) test doubles. +// gatewayEventsService is left nil, which is a supported no-op path (mirrors +// LLMProviderDeploymentService's "if s.gatewayEventsService != nil" guard), +// so tests don't need to stand up an EventHub. +func newGraphQLDeploymentTestService(repo *mockGraphQLAPIRepo, deploymentRepo *mockDeploymentRepo, gatewayRepo *mockGatewayRepository) *GraphQLAPIDeploymentService { + return NewGraphQLAPIDeploymentService( + repo, + deploymentRepo, + gatewayRepo, + &mockOrganizationRepo{}, + nil, + &mockGraphQLArtifactRepo{}, + nil, + NewArtifactDefinitions(NewGraphQLAPIDefinition(repo)), + &config.Server{Deployments: config.Deployments{MaxPerAPIGateway: 20}}, + newTestLogger(), + ) +} + +func graphQLDeploymentTestGateway() *model.Gateway { + return &model.Gateway{ID: "gw-uuid-1", OrganizationID: "org-1", Handle: "prod-gateway", Name: "Prod Gateway"} +} + +// TestGraphQLDeployAPI_Current_Success exercises DeployGraphQLAPI's "current" +// base path end-to-end: resolves the gateway/API, generates the deployment +// YAML, persists the deployment record, and returns a DEPLOYING response. +func TestGraphQLDeployAPI_Current_Success(t *testing.T) { + ctx := "/countries" + stored := &model.GraphQLAPI{ + ID: "gql-uuid-1", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + Name: "Countries GraphQL API", + Version: "v1.0", + Configuration: model.GraphQLAPIConfig{ + SDL: validCountriesGraphQLSDL, + Context: &ctx, + Upstream: model.UpstreamConfig{ + Main: &model.UpstreamEndpoint{URL: "https://countries.example.com/graphql"}, + }, + }, + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, + getByUUIDFunc: func(uuid, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, + } + gateway := graphQLDeploymentTestGateway() + gatewayRepo := &mockGatewayRepository{getByNameResult: gateway, getByUUIDResult: gateway} + deploymentRepo := &mockDeploymentRepo{setCurrentUpdatedAt: time.Now()} + + svc := newGraphQLDeploymentTestService(repo, deploymentRepo, gatewayRepo) + + req := &api.DeployRequest{Name: "prod-deployment", Base: "current", GatewayId: "prod-gateway"} + resp, err := svc.DeployGraphQLAPI("countries-graphql-api", req, "org-1", "creator-uuid") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp == nil { + t.Fatal("expected a deployment response, got nil") + } + if resp.Name != "prod-deployment" { + t.Errorf("expected deployment name %q, got %q", "prod-deployment", resp.Name) + } + if string(resp.Status) != string(model.DeploymentStatusDeploying) { + t.Errorf("expected initial status DEPLOYING, got %s", resp.Status) + } + if resp.GatewayId != "prod-gateway" { + t.Errorf("expected gatewayId %q (handle, not UUID), got %q", "prod-gateway", resp.GatewayId) + } + if !deploymentRepo.setCurrentCalled { + t.Error("expected deployment status to be set") + } +} + +// TestGraphQLDeployAPI_LegacyGateway_DownConvertsApiVersion pins the fix for +// the gap found auditing deployments/gateways/api-keys wiring for GraphQL: +// DeployGraphQLAPI previously stamped constants.GatewayApiVersion +// unconditionally and never called gatewaytranslator.Translate, so a +// gateway older than gatewaytranslator.MinGatewayV1Version ("1.2.0") would +// silently receive a v1 artifact it can't parse — unlike RestApi, MCP, and +// LLM Provider/Proxy, which all down-convert via Translate before deploying. +func TestGraphQLDeployAPI_LegacyGateway_DownConvertsApiVersion(t *testing.T) { + ctx := "/countries" + stored := &model.GraphQLAPI{ + ID: "gql-uuid-1", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + Name: "Countries GraphQL API", + Version: "v1.0", + Configuration: model.GraphQLAPIConfig{ + SDL: validCountriesGraphQLSDL, + Context: &ctx, + Upstream: model.UpstreamConfig{ + Main: &model.UpstreamEndpoint{URL: "https://countries.example.com/graphql"}, + }, + }, + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, + getByUUIDFunc: func(uuid, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, + } + // Below gatewaytranslator.MinGatewayV1Version ("1.2.0") — must down-convert. + legacyGateway := &model.Gateway{ID: "gw-uuid-1", OrganizationID: "org-1", Handle: "prod-gateway", Name: "Prod Gateway", Version: "1.1.0"} + gatewayRepo := &mockGatewayRepository{getByNameResult: legacyGateway, getByUUIDResult: legacyGateway} + deploymentRepo := &mockDeploymentRepo{setCurrentUpdatedAt: time.Now()} + + svc := newGraphQLDeploymentTestService(repo, deploymentRepo, gatewayRepo) + + req := &api.DeployRequest{Name: "prod-deployment", Base: "current", GatewayId: "prod-gateway"} + if _, err := svc.DeployGraphQLAPI("countries-graphql-api", req, "org-1", "creator-uuid"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if deploymentRepo.createdDeployment == nil { + t.Fatal("expected a deployment to be created") + } + content := string(deploymentRepo.createdDeployment.Content) + if !strings.Contains(content, constants.GatewayApiVersionV1Alpha1) { + t.Errorf("expected deployment content to use %q for a legacy gateway, got:\n%s", constants.GatewayApiVersionV1Alpha1, content) + } + if strings.Contains(content, constants.GatewayApiVersion+"\n") { + t.Errorf("expected deployment content NOT to use latest %q for a legacy gateway, got:\n%s", constants.GatewayApiVersion, content) + } +} + +// TestGraphQLDeployAPI_APINotFound verifies deploying a nonexistent GraphQL +// API returns GRAPHQL_API_NOT_FOUND rather than a generic error. +func TestGraphQLDeployAPI_APINotFound(t *testing.T) { + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { return nil, nil }, + } + gateway := graphQLDeploymentTestGateway() + gatewayRepo := &mockGatewayRepository{getByNameResult: gateway, getByUUIDResult: gateway} + svc := newGraphQLDeploymentTestService(repo, &mockDeploymentRepo{}, gatewayRepo) + + req := &api.DeployRequest{Name: "prod-deployment", Base: "current", GatewayId: "prod-gateway"} + _, err := svc.DeployGraphQLAPI("does-not-exist", req, "org-1", "creator-uuid") + if err == nil { + t.Fatal("expected an error deploying a nonexistent GraphQL API") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPINotFound { + t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPINotFound, code) + } +} + +// TestGenerateGraphQLAPIDeploymentYAML_CarriesUpstreamAuth pins the fix the +// design doc explicitly calls for: REST's BuildAPIDeploymentYAML has a known, +// still-unfixed bug where dto.UpstreamTarget has no Auth field at all, so +// upstream.main.auth is silently dropped before the YAML ever reaches the +// gateway. GraphQLUpstreamTarget was built with an Auth field from day one to +// avoid copying that gap — this test is the regression guard proving the +// generator actually carries it through, not just that the field exists on +// the struct. +func TestGenerateGraphQLAPIDeploymentYAML_CarriesUpstreamAuth(t *testing.T) { + ctx := "/countries" + apiModel := &model.GraphQLAPI{ + ID: "gql-uuid-1", + Handle: "countries-graphql-api", + Name: "Countries GraphQL API", + Version: "v1.0", + Configuration: model.GraphQLAPIConfig{ + Context: &ctx, + Upstream: model.UpstreamConfig{ + Main: &model.UpstreamEndpoint{ + URL: "https://countries.example.com/graphql", + Auth: &model.UpstreamAuth{ + Type: "apiKey", + Header: "X-API-Key", + Value: "super-secret-value", + }, + }, + }, + }, + } + + yamlData, err := generateGraphQLAPIDeploymentYAML(apiModel) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if yamlData.Spec.Upstream == nil || yamlData.Spec.Upstream.Main == nil { + t.Fatal("expected spec.upstream.main to be populated") + } + auth := yamlData.Spec.Upstream.Main.Auth + if auth == nil { + t.Fatal("expected upstream.main.auth to be carried through into the deployment YAML, got nil") + } + if auth.Type != "apiKey" || auth.Header != "X-API-Key" || auth.Value != "super-secret-value" { + t.Errorf("upstream.main.auth was not carried through unmodified: %+v", auth) + } +} + +// TestGenerateGraphQLAPIDeploymentYAML_CarriesSandboxAuth is the sandbox +// counterpart to TestGenerateGraphQLAPIDeploymentYAML_CarriesUpstreamAuth, +// asserting the generator itself (not just the full deploy flow) populates +// spec.upstream.sandbox from apiModel.Configuration.Upstream.Sandbox. +func TestGenerateGraphQLAPIDeploymentYAML_CarriesSandboxAuth(t *testing.T) { + ctx := "/countries" + apiModel := &model.GraphQLAPI{ + ID: "gql-uuid-1", + Handle: "countries-graphql-api", + Name: "Countries GraphQL API", + Version: "v1.0", + Configuration: model.GraphQLAPIConfig{ + Context: &ctx, + Upstream: model.UpstreamConfig{ + Main: &model.UpstreamEndpoint{URL: "https://countries.example.com/graphql"}, + Sandbox: &model.UpstreamEndpoint{ + URL: "https://sandbox.countries.example.com/graphql", + Auth: &model.UpstreamAuth{ + Type: "bearer", + Header: "Authorization", + Value: "sandbox-secret-value", + }, + }, + }, + }, + } + + yamlData, err := generateGraphQLAPIDeploymentYAML(apiModel) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if yamlData.Spec.Upstream == nil || yamlData.Spec.Upstream.Sandbox == nil { + t.Fatal("expected spec.upstream.sandbox to be populated") + } + sandbox := yamlData.Spec.Upstream.Sandbox + if sandbox.URL != "https://sandbox.countries.example.com/graphql" { + t.Errorf("sandbox.url = %q, want the configured sandbox URL", sandbox.URL) + } + if sandbox.Auth == nil { + t.Fatal("expected upstream.sandbox.auth to be carried through into the deployment YAML, got nil") + } + if sandbox.Auth.Type != "bearer" || sandbox.Auth.Header != "Authorization" || sandbox.Auth.Value != "sandbox-secret-value" { + t.Errorf("upstream.sandbox.auth was not carried through unmodified: %+v", sandbox.Auth) + } +} + +// TestGenerateGraphQLAPIDeploymentYAML_CarriesSandboxUpstream pins the fix for +// the gap where GraphQLUpstream had only a Main field: upstream.sandbox is a +// genuinely supported concept everywhere else (the gateway OpenAPI spec's +// GraphQLAPIConfigData.Upstream.Sandbox, GraphQLAPITransformer's sandbox +// route, and the CP's own read-response round-trip in +// TestGraphQLUpstreamAuth_RedactedAcrossAllResponseShapes), but the +// deployment YAML generator silently dropped it before it ever reached the +// gateway. This exercises the full DeployGraphQLAPI path (not just the +// generator) so it also proves gatewaytranslator.Translate still succeeds +// with a sandbox upstream present. +func TestGenerateGraphQLAPIDeploymentYAML_CarriesSandboxUpstream(t *testing.T) { + ctx := "/countries" + stored := &model.GraphQLAPI{ + ID: "gql-uuid-1", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + Name: "Countries GraphQL API", + Version: "v1.0", + Configuration: model.GraphQLAPIConfig{ + SDL: validCountriesGraphQLSDL, + Context: &ctx, + Upstream: model.UpstreamConfig{ + Main: &model.UpstreamEndpoint{URL: "https://countries.example.com/graphql"}, + Sandbox: &model.UpstreamEndpoint{ + URL: "https://sandbox.countries.example.com/graphql", + Auth: &model.UpstreamAuth{ + Type: "bearer", + Header: "Authorization", + Value: "sandbox-secret", + }, + }, + }, + }, + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, + getByUUIDFunc: func(uuid, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, + } + gateway := graphQLDeploymentTestGateway() + gatewayRepo := &mockGatewayRepository{getByNameResult: gateway, getByUUIDResult: gateway} + deploymentRepo := &mockDeploymentRepo{setCurrentUpdatedAt: time.Now()} + + svc := newGraphQLDeploymentTestService(repo, deploymentRepo, gatewayRepo) + + req := &api.DeployRequest{Name: "prod-deployment", Base: "current", GatewayId: "prod-gateway"} + if _, err := svc.DeployGraphQLAPI("countries-graphql-api", req, "org-1", "creator-uuid"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if deploymentRepo.createdDeployment == nil { + t.Fatal("expected a deployment to be created") + } + content := string(deploymentRepo.createdDeployment.Content) + if !strings.Contains(content, "sandbox.countries.example.com") { + t.Errorf("expected deployment content to contain spec.upstream.sandbox.url, got:\n%s", content) + } +} + +// TestGraphQLUndeployDeployment_Success verifies an active deployment +// transitions to UNDEPLOYING when the bound gateway matches the request. +func TestGraphQLUndeployDeployment_Success(t *testing.T) { + stored := &model.GraphQLAPI{ID: "gql-uuid-1", Handle: "countries-graphql-api", OrganizationID: "org-1"} + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, + } + gateway := graphQLDeploymentTestGateway() + gatewayRepo := &mockGatewayRepository{getByNameResult: gateway, getByUUIDResult: gateway} + deployed := model.DeploymentStatusDeployed + deploymentRepo := &mockDeploymentRepo{ + deploymentWithState: &model.Deployment{ + DeploymentID: "dep-1", + Name: "prod-deployment", + ArtifactID: stored.ID, + GatewayID: gateway.ID, + Status: &deployed, + }, + setCurrentUpdatedAt: time.Now(), + } + + svc := newGraphQLDeploymentTestService(repo, deploymentRepo, gatewayRepo) + + resp, err := svc.UndeployGraphQLAPIDeployment("countries-graphql-api", "dep-1", "prod-gateway", "org-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(resp.Status) != string(model.DeploymentStatusUndeploying) { + t.Errorf("expected initial status UNDEPLOYING, got %s", resp.Status) + } + if deploymentRepo.setCurrentStatus != model.DeploymentStatusUndeploying { + t.Errorf("expected repo to be asked to set status UNDEPLOYING, got %s", deploymentRepo.setCurrentStatus) + } +} + +// TestGraphQLUndeployDeployment_GatewayMismatch_Rejected verifies a gatewayId +// that doesn't match the deployment's bound gateway is rejected — this +// prevents an unintended undeploy against the wrong gateway. +func TestGraphQLUndeployDeployment_GatewayMismatch_Rejected(t *testing.T) { + stored := &model.GraphQLAPI{ID: "gql-uuid-1", Handle: "countries-graphql-api", OrganizationID: "org-1"} + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, + } + boundGateway := graphQLDeploymentTestGateway() + otherGateway := &model.Gateway{ID: "gw-uuid-2", OrganizationID: "org-1", Handle: "staging-gateway"} + gatewayRepo := &mockGatewayRepository{getByNameResult: otherGateway, getByUUIDResult: boundGateway} + deployed := model.DeploymentStatusDeployed + deploymentRepo := &mockDeploymentRepo{ + deploymentWithState: &model.Deployment{ + DeploymentID: "dep-1", + ArtifactID: stored.ID, + GatewayID: boundGateway.ID, + Status: &deployed, + }, + } + + svc := newGraphQLDeploymentTestService(repo, deploymentRepo, gatewayRepo) + + _, err := svc.UndeployGraphQLAPIDeployment("countries-graphql-api", "dep-1", "staging-gateway", "org-1") + if err == nil { + t.Fatal("expected an error for a gateway mismatch") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeDeploymentGatewayMismatch { + t.Errorf("expected %s, got %s", apperror.CodeDeploymentGatewayMismatch, code) + } + if deploymentRepo.setCurrentCalled { + t.Error("expected no status change for a rejected gateway mismatch") + } +} + +// TestGraphQLRestoreDeployment_Success verifies restoring an UNDEPLOYED +// deployment transitions it back to DEPLOYING. +func TestGraphQLRestoreDeployment_Success(t *testing.T) { + stored := &model.GraphQLAPI{ID: "gql-uuid-1", Handle: "countries-graphql-api", OrganizationID: "org-1"} + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, + } + gateway := graphQLDeploymentTestGateway() + gatewayRepo := &mockGatewayRepository{getByNameResult: gateway, getByUUIDResult: gateway} + deploymentRepo := &mockDeploymentRepo{ + deploymentWithContent: &model.Deployment{ + DeploymentID: "dep-1", + Name: "prod-deployment", + ArtifactID: stored.ID, + GatewayID: gateway.ID, + Content: []byte("apiVersion: v1"), + }, + currentDeploymentID: "dep-0", + currentStatus: model.DeploymentStatusUndeployed, + setCurrentUpdatedAt: time.Now(), + } + + svc := newGraphQLDeploymentTestService(repo, deploymentRepo, gatewayRepo) + + resp, err := svc.RestoreGraphQLAPIDeployment("countries-graphql-api", "dep-1", "prod-gateway", "org-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(resp.Status) != string(model.DeploymentStatusDeploying) { + t.Errorf("expected initial status DEPLOYING, got %s", resp.Status) + } +} + +// TestGraphQLRestoreDeployment_AlreadyDeployed_Conflict verifies restoring the +// deployment that is already the gateway's current, deployed one is rejected. +func TestGraphQLRestoreDeployment_AlreadyDeployed_Conflict(t *testing.T) { + stored := &model.GraphQLAPI{ID: "gql-uuid-1", Handle: "countries-graphql-api", OrganizationID: "org-1"} + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, + } + gateway := graphQLDeploymentTestGateway() + gatewayRepo := &mockGatewayRepository{getByNameResult: gateway, getByUUIDResult: gateway} + deploymentRepo := &mockDeploymentRepo{ + deploymentWithContent: &model.Deployment{ + DeploymentID: "dep-1", + ArtifactID: stored.ID, + GatewayID: gateway.ID, + Content: []byte("apiVersion: v1"), + }, + currentDeploymentID: "dep-1", + currentStatus: model.DeploymentStatusDeployed, + } + + svc := newGraphQLDeploymentTestService(repo, deploymentRepo, gatewayRepo) + + _, err := svc.RestoreGraphQLAPIDeployment("countries-graphql-api", "dep-1", "prod-gateway", "org-1") + if err == nil { + t.Fatal("expected an error restoring an already-deployed deployment") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeDeploymentRestoreConflict { + t.Errorf("expected %s, got %s", apperror.CodeDeploymentRestoreConflict, code) + } +} diff --git a/platform-api/internal/service/graphql_gateway_test.go b/platform-api/internal/service/graphql_gateway_test.go new file mode 100644 index 0000000000..eeafd7e8d7 --- /dev/null +++ b/platform-api/internal/service/graphql_gateway_test.go @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +import ( + "testing" + + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/model" +) + +// TestGraphQLAddGatewaysToAPI_CreatesAssociationAndReturnsList verifies +// AddGatewaysToAPI resolves the handle, validates the gateway, creates a new +// association (via the shared artifact_gateway_mappings helpers — see +// GraphQLAPIRepository's doc comment), and returns the up-to-date gateway +// list, mirroring APIService.AddGatewaysToAPI's behavior for REST. +func TestGraphQLAddGatewaysToAPI_CreatesAssociationAndReturnsList(t *testing.T) { + stored := &model.GraphQLAPI{ + ID: "gql-uuid-1", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + Configuration: model.GraphQLAPIConfig{SDL: validCountriesGraphQLSDL}, + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { + if handle == stored.Handle && orgUUID == stored.OrganizationID { + return stored, nil + } + return nil, nil + }, + gatewayDetails: []*model.APIGatewayWithDetails{ + {ID: "gw-uuid-1", Handle: "prod-gateway", Name: "Prod Gateway"}, + }, + } + gatewayRepo := &mockGatewayRepository{ + getByNameResult: &model.Gateway{ID: "gw-uuid-1", Handle: "prod-gateway", Name: "Prod Gateway", OrganizationID: "org-1"}, + } + orgRepo := &mockOrganizationRepo{org: &model.Organization{ID: "org-1", Handle: "acme"}} + + svc := newGraphQLTestServiceWithGateways(repo, nil, gatewayRepo, orgRepo) + + resp, err := svc.AddGatewaysToAPI("countries-graphql-api", []string{"prod-gateway"}, "org-1", "creator-uuid") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp == nil { + t.Fatal("expected a response, got nil") + } + if len(repo.createdAssociations) != 1 { + t.Fatalf("expected exactly one association to be created, got %d", len(repo.createdAssociations)) + } + assoc := repo.createdAssociations[0] + if assoc.ArtifactID != stored.ID { + t.Errorf("expected association ArtifactID %q, got %q", stored.ID, assoc.ArtifactID) + } + if assoc.GatewayID != "gw-uuid-1" { + t.Errorf("expected association GatewayID %q, got %q", "gw-uuid-1", assoc.GatewayID) + } + if len(resp.List) != 1 || resp.List[0].Id == nil || *resp.List[0].Id != "prod-gateway" { + t.Errorf("expected the returned gateway list to include prod-gateway, got: %+v", resp.List) + } +} + +// TestGraphQLAddGatewaysToAPI_UnknownGateway_NotFound verifies a gateway handle +// that doesn't resolve within the org is rejected before any association is +// written. +func TestGraphQLAddGatewaysToAPI_UnknownGateway_NotFound(t *testing.T) { + stored := &model.GraphQLAPI{ID: "gql-uuid-1", Handle: "countries-graphql-api", OrganizationID: "org-1"} + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, + } + gatewayRepo := &mockGatewayRepository{getByNameResult: nil} + orgRepo := &mockOrganizationRepo{} + + svc := newGraphQLTestServiceWithGateways(repo, nil, gatewayRepo, orgRepo) + + _, err := svc.AddGatewaysToAPI("countries-graphql-api", []string{"does-not-exist"}, "org-1", "creator-uuid") + if err == nil { + t.Fatal("expected an error for an unknown gateway handle") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeGatewayNotFound { + t.Errorf("expected %s, got %s", apperror.CodeGatewayNotFound, code) + } + if len(repo.createdAssociations) != 0 { + t.Error("expected no association to be created for an unknown gateway") + } +} + +// TestGraphQLGetAPIGateways_ReturnsAssociatedGateways verifies GetAPIGateways +// resolves the handle and returns the paginated gateway list for the artifact. +func TestGraphQLGetAPIGateways_ReturnsAssociatedGateways(t *testing.T) { + stored := &model.GraphQLAPI{ID: "gql-uuid-1", Handle: "countries-graphql-api", OrganizationID: "org-1"} + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, + gatewayDetails: []*model.APIGatewayWithDetails{ + {ID: "gw-uuid-1", Handle: "prod-gateway", Name: "Prod Gateway"}, + {ID: "gw-uuid-2", Handle: "staging-gateway", Name: "Staging Gateway"}, + }, + } + orgRepo := &mockOrganizationRepo{org: &model.Organization{ID: "org-1", Handle: "acme"}} + svc := newGraphQLTestServiceWithGateways(repo, nil, &mockGatewayRepository{}, orgRepo) + + resp, err := svc.GetAPIGateways("countries-graphql-api", "org-1", 25, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp == nil || len(resp.List) != 2 { + t.Fatalf("expected 2 associated gateways, got: %+v", resp) + } + if resp.Pagination.Total != 2 { + t.Errorf("expected pagination total 2, got %d", resp.Pagination.Total) + } +} + +// TestGraphQLGetAPIGateways_NotFound verifies a nonexistent GraphQL API handle +// returns GRAPHQL_API_NOT_FOUND rather than an empty gateway list. +func TestGraphQLGetAPIGateways_NotFound(t *testing.T) { + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { return nil, nil }, + } + svc := newGraphQLTestServiceWithGateways(repo, nil, &mockGatewayRepository{}, &mockOrganizationRepo{}) + + _, err := svc.GetAPIGateways("does-not-exist", "org-1", 25, 0) + if err == nil { + t.Fatal("expected an error for a nonexistent GraphQL API") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPINotFound { + t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPINotFound, code) + } +} diff --git a/platform-api/internal/service/graphql_introspection.go b/platform-api/internal/service/graphql_introspection.go new file mode 100644 index 0000000000..95f1ccd4e4 --- /dev/null +++ b/platform-api/internal/service/graphql_introspection.go @@ -0,0 +1,439 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "github.com/vektah/gqlparser/v2/ast" + "github.com/vektah/gqlparser/v2/formatter" + + "github.com/wso2/api-platform/platform-api/internal/utils" +) + +// graphQLIntrospectionTimeout bounds the outbound introspection call end to +// end — this is a one-shot onboarding-time probe against a +// tenant-configured upstream, not a proxied request in the data path, so a +// generous-but-bounded timeout is appropriate. +const graphQLIntrospectionTimeout = 15 * time.Second + +// standardGraphQLIntrospectionQuery is the standard GraphQL introspection +// query (the same shape graphql-js's getIntrospectionQuery() emits), sent +// verbatim to the tenant's upstream so any spec-compliant GraphQL server +// can answer it. +const standardGraphQLIntrospectionQuery = ` +query IntrospectionQuery { + __schema { + queryType { name } + mutationType { name } + subscriptionType { name } + types { + ...FullType + } + } +} + +fragment FullType on __Type { + kind + name + description + fields(includeDeprecated: true) { + name + description + args { + ...InputValue + } + type { + ...TypeRef + } + isDeprecated + deprecationReason + } + inputFields { + ...InputValue + } + interfaces { + ...TypeRef + } + enumValues(includeDeprecated: true) { + name + description + isDeprecated + deprecationReason + } + possibleTypes { + ...TypeRef + } +} + +fragment InputValue on __InputValue { + name + description + type { ...TypeRef } + defaultValue +} + +fragment TypeRef on __Type { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + } + } + } + } + } + } +} +` + +// graphQLIntrospectionRequestBody is the JSON body sent to the upstream endpoint. +type graphQLIntrospectionRequestBody struct { + Query string `json:"query"` +} + +// graphQLIntrospectionResponse is the minimal shape of a standard GraphQL +// introspection response this converter understands. +type graphQLIntrospectionResponse struct { + Data *struct { + Schema graphQLIntrospectionSchema `json:"__schema"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors,omitempty"` +} + +type graphQLIntrospectionSchema struct { + QueryType *graphQLIntrospectionTypeRef `json:"queryType"` + MutationType *graphQLIntrospectionTypeRef `json:"mutationType"` + SubscriptionType *graphQLIntrospectionTypeRef `json:"subscriptionType"` + Types []graphQLIntrospectionType `json:"types"` +} + +type graphQLIntrospectionTypeRef struct { + Kind string `json:"kind"` + Name string `json:"name"` + OfType *graphQLIntrospectionTypeRef `json:"ofType"` +} + +type graphQLIntrospectionType struct { + Kind string `json:"kind"` + Name string `json:"name"` + Description string `json:"description"` + Fields []graphQLIntrospectionField `json:"fields"` + InputFields []graphQLIntrospectionInputValue `json:"inputFields"` + Interfaces []graphQLIntrospectionTypeRef `json:"interfaces"` + EnumValues []graphQLIntrospectionEnumValue `json:"enumValues"` + PossibleTypes []graphQLIntrospectionTypeRef `json:"possibleTypes"` +} + +type graphQLIntrospectionField struct { + Name string `json:"name"` + Description string `json:"description"` + Args []graphQLIntrospectionInputValue `json:"args"` + Type graphQLIntrospectionTypeRef `json:"type"` +} + +type graphQLIntrospectionInputValue struct { + Name string `json:"name"` + Description string `json:"description"` + Type graphQLIntrospectionTypeRef `json:"type"` + // DefaultValue is intentionally not converted — see convertGraphQLIntrospectionToSDL. +} + +type graphQLIntrospectionEnumValue struct { + Name string `json:"name"` + Description string `json:"description"` +} + +// graphQLBuiltinScalarNames are the five GraphQL scalars every server +// implicitly defines; introspection always lists them, but re-declaring +// them in SDL is both unnecessary and (for String/Int/Float/Boolean/ID) +// invalid. +var graphQLBuiltinScalarNames = map[string]bool{ + "String": true, "Int": true, "Float": true, "Boolean": true, "ID": true, +} + +// fetchAndConvertGraphQLSchema runs the standard introspection query +// against upstreamURL through the SSRF-hardened upstream client, converts +// the JSON result into SDL text, and validates the result defines a Query +// type. The returned error is for internal logging only — callers map it +// to the sterile GraphQLAPISchemaResolveFailed response (ssrf-prevention.md +// / error-handling.md — never echo the resolved IP or the specific failure +// reason to the client). +func fetchAndConvertGraphQLSchema(upstreamURL string) (string, error) { + body, err := json.Marshal(graphQLIntrospectionRequestBody{Query: standardGraphQLIntrospectionQuery}) + if err != nil { + return "", fmt.Errorf("failed to build introspection request: %w", err) + } + + // The shared client's own Timeouts.Overall is a safety net only (see + // NewUpstreamFetchClient's doc comment) — this call's real budget is enforced + // via the request context here, the same self-contained + // context.WithTimeout(context.Background(), ...) pattern CheckURLReachability + // uses for its own one-shot outbound probe (common.go). + ctx, cancel := context.WithTimeout(context.Background(), graphQLIntrospectionTimeout) + defer cancel() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("failed to build introspection request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + + // The GraphQL endpoint URL is tenant-supplied — dial through the + // SSRF-guarded client (ssrf-prevention.md directive 6: reuse the shared + // upstream-fetch helper rather than a one-off client). upstream.main.url + // is the tenant's own configured backend (analogous to REST/MCP's + // upstream), so NewUpstreamFetchClient's private/in-cluster-permitting + // policy is the correct one here. FetchOpenAPISpecFromURL (used to + // resolve sdlUrl) goes through this same shared client and policy — it + // is not a stricter public-only fetch; only isPublicIP/ValidateExternalURL + // (used for LLM provider endpoint validation) enforce that bar. + client, err := utils.NewUpstreamFetchClient(graphQLIntrospectionTimeout) + if err != nil { + return "", fmt.Errorf("failed to create HTTP client: %w", err) + } + resp, err := client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("failed to reach GraphQL endpoint for introspection: %w", err) + } + defer resp.Body.Close() + + const maxIntrospectionResponseBytes = 5 << 20 // 5 MiB ceiling on the introspection response + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxIntrospectionResponseBytes+1)) + if err != nil { + return "", fmt.Errorf("failed to read introspection response: %w", err) + } + if len(respBody) > maxIntrospectionResponseBytes { + // Reject outright rather than silently parsing a truncated body — a cut + // that happens to land on a JSON boundary could otherwise produce a + // subtly incomplete (but parseable) derived schema (file-access.md + // directive 5). + return "", fmt.Errorf("introspection response exceeds the maximum allowed size of %d bytes", maxIntrospectionResponseBytes) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("introspection request failed with status %d", resp.StatusCode) + } + + var parsed graphQLIntrospectionResponse + if err := json.Unmarshal(respBody, &parsed); err != nil { + return "", fmt.Errorf("failed to parse introspection response: %w", err) + } + if len(parsed.Errors) > 0 { + return "", fmt.Errorf("introspection query returned %d error(s)", len(parsed.Errors)) + } + if parsed.Data == nil { + return "", fmt.Errorf("introspection response has no data") + } + + sdl, err := convertGraphQLIntrospectionToSDL(parsed.Data.Schema) + if err != nil { + return "", err + } + if err := validateGraphQLSDL(sdl); err != nil { + return "", fmt.Errorf("derived schema failed validation: %w", err) + } + return sdl, nil +} + +// convertGraphQLIntrospectionToSDL converts a standard introspection +// __schema result into SDL text via gqlparser's AST + formatter. This is a +// reasonably complete converter (object/interface/union/enum/input types, +// scalars, non-null/list wrappers, field arguments) — not byte-perfect for +// every exotic GraphQL feature. Known gaps, left as best-effort omissions +// rather than hard failures: +// - default values on arguments/input fields are not reproduced (would +// require parsing the introspection-supplied literal back into an +// ast.Value); +// - custom directives and directive definitions are not reproduced +// (introspection's `directives` list is not requested/consumed); +// - descriptions are preserved, but deprecation reasons are not rendered +// as `@deprecated(reason: ...)` directives. +func convertGraphQLIntrospectionToSDL(schema graphQLIntrospectionSchema) (string, error) { + if schema.QueryType == nil || schema.QueryType.Name == "" { + return "", fmt.Errorf("introspection response has no queryType") + } + + astSchema := &ast.Schema{Types: map[string]*ast.Definition{}} + for _, t := range schema.Types { + if t.Name == "" || strings.HasPrefix(t.Name, "__") || graphQLBuiltinScalarNames[t.Name] { + continue + } + def, ok := convertGraphQLIntrospectionDefinition(t) + if !ok { + // Best-effort: skip a type we can't faithfully represent rather + // than fail the whole schema derivation. + continue + } + astSchema.Types[t.Name] = def + } + + queryDef, ok := astSchema.Types[schema.QueryType.Name] + if !ok { + return "", fmt.Errorf("query type %q not found among introspected types", schema.QueryType.Name) + } + astSchema.Query = queryDef + + if schema.MutationType != nil { + if def, ok := astSchema.Types[schema.MutationType.Name]; ok { + astSchema.Mutation = def + } + } + if schema.SubscriptionType != nil { + if def, ok := astSchema.Types[schema.SubscriptionType.Name]; ok { + astSchema.Subscription = def + } + } + + var buf bytes.Buffer + formatter.NewFormatter(&buf).FormatSchema(astSchema) + return buf.String(), nil +} + +// convertGraphQLIntrospectionDefinition converts one introspected type into +// an ast.Definition. ok is false for a kind this converter does not +// understand (e.g. a future GraphQL kind), signaling the caller to skip it. +func convertGraphQLIntrospectionDefinition(t graphQLIntrospectionType) (*ast.Definition, bool) { + def := &ast.Definition{ + Name: t.Name, + Description: t.Description, + } + + switch t.Kind { + case "OBJECT": + def.Kind = ast.Object + def.Fields = convertGraphQLIntrospectionFields(t.Fields) + def.Interfaces = convertGraphQLIntrospectionTypeRefNames(t.Interfaces) + case "INTERFACE": + def.Kind = ast.Interface + def.Fields = convertGraphQLIntrospectionFields(t.Fields) + def.Interfaces = convertGraphQLIntrospectionTypeRefNames(t.Interfaces) + case "UNION": + def.Kind = ast.Union + def.Types = convertGraphQLIntrospectionTypeRefNames(t.PossibleTypes) + case "ENUM": + def.Kind = ast.Enum + for _, ev := range t.EnumValues { + def.EnumValues = append(def.EnumValues, &ast.EnumValueDefinition{ + Name: ev.Name, + Description: ev.Description, + }) + } + case "INPUT_OBJECT": + def.Kind = ast.InputObject + for _, f := range t.InputFields { + def.Fields = append(def.Fields, &ast.FieldDefinition{ + Name: f.Name, + Description: f.Description, + Type: convertGraphQLIntrospectionTypeRef(&f.Type), + }) + } + case "SCALAR": + def.Kind = ast.Scalar + default: + return nil, false + } + return def, true +} + +// convertGraphQLIntrospectionFields converts introspected object/interface +// fields, including their arguments. +func convertGraphQLIntrospectionFields(fields []graphQLIntrospectionField) ast.FieldList { + out := make(ast.FieldList, 0, len(fields)) + for _, f := range fields { + fd := &ast.FieldDefinition{ + Name: f.Name, + Description: f.Description, + Type: convertGraphQLIntrospectionTypeRef(&f.Type), + } + for _, a := range f.Args { + fd.Arguments = append(fd.Arguments, &ast.ArgumentDefinition{ + Name: a.Name, + Description: a.Description, + Type: convertGraphQLIntrospectionTypeRef(&a.Type), + }) + } + out = append(out, fd) + } + return out +} + +// convertGraphQLIntrospectionTypeRefNames extracts sorted, de-duplicated +// names from a list of type references (used for interfaces/union +// possibleTypes). +func convertGraphQLIntrospectionTypeRefNames(refs []graphQLIntrospectionTypeRef) []string { + seen := make(map[string]bool, len(refs)) + names := make([]string, 0, len(refs)) + for _, r := range refs { + if r.Name == "" || seen[r.Name] { + continue + } + seen[r.Name] = true + names = append(names, r.Name) + } + sort.Strings(names) + return names +} + +// convertGraphQLIntrospectionTypeRef recursively converts an introspection +// TypeRef (which wraps NON_NULL/LIST around a named type) into an ast.Type. +func convertGraphQLIntrospectionTypeRef(ref *graphQLIntrospectionTypeRef) *ast.Type { + if ref == nil { + return ast.NamedType("String", nil) + } + switch ref.Kind { + case "NON_NULL": + inner := convertGraphQLIntrospectionTypeRef(ref.OfType) + wrapped := *inner + wrapped.NonNull = true + return &wrapped + case "LIST": + return ast.ListType(convertGraphQLIntrospectionTypeRef(ref.OfType), nil) + default: + if ref.Name == "" { + return ast.NamedType("String", nil) + } + return ast.NamedType(ref.Name, nil) + } +} diff --git a/platform-api/internal/service/graphql_mapping.go b/platform-api/internal/service/graphql_mapping.go new file mode 100644 index 0000000000..66f352f4fd --- /dev/null +++ b/platform-api/internal/service/graphql_mapping.go @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +import ( + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/utils" +) + +// mapGraphQLAPIModelToAPI converts a model.GraphQLAPI to api.GraphQLAPI, +// including the full SDL (used for Get/Create/Update responses, never for +// list responses — see mapGraphQLAPIModelToListItem). Upstream/policy +// conversion reuses the same generic helpers LLM/MCP already share +// (mapUpstreamAPIToModel/mapUpstreamModelToAPI in llm.go, +// mapMCPPoliciesAPIToModel/mapMCPPoliciesModelToAPI in mcp.go) since +// GraphQL reuses model.UpstreamConfig/model.Policy unmodified. +func mapGraphQLAPIModelToAPI(m *model.GraphQLAPI) *api.GraphQLAPI { + if m == nil { + return nil + } + + desc := m.Description + createdBy := m.CreatedBy + kind := constants.GraphQLApi + sdl := m.Configuration.SDL + + var introspectionMode *api.GraphQLIntrospectionMode + if m.Configuration.IntrospectionMode != "" { + im := api.GraphQLIntrospectionMode(m.Configuration.IntrospectionMode) + introspectionMode = &im + } + + var subscriptionPlans *[]string + if len(m.Configuration.SubscriptionPlans) > 0 { + subscriptionPlans = &m.Configuration.SubscriptionPlans + } + + upstream := mapUpstreamConfigToDTO(&m.Configuration.Upstream) + + return &api.GraphQLAPI{ + Id: utils.StringPtrIfNotEmpty(m.Handle), + DisplayName: m.Name, + Version: m.Version, + Context: utils.ValueOrEmpty(m.Configuration.Context), + ProjectId: m.ProjectID, + Description: &desc, + CreatedBy: &createdBy, + Kind: &kind, + Sdl: &sdl, + IntrospectionMode: introspectionMode, + Upstream: upstream, + Policies: mapMCPPoliciesModelToAPI(m.Configuration.Policies), + SubscriptionPlans: subscriptionPlans, + ReadOnly: utils.BoolPtr(m.Origin == constants.OriginDP), + CreatedAt: utils.TimePtr(m.CreatedAt), + UpdatedAt: utils.TimePtr(m.UpdatedAt), + UpdatedBy: utils.StringPtrIfNotEmpty(m.UpdatedBy), + } +} + +// mapGraphQLAPIModelToDetail converts a model.GraphQLAPI to +// api.GraphQLAPIDetail — the shape returned by GET +// /graphql-apis/{graphqlApiId}, identical to mapGraphQLAPIModelToAPI's output +// except sdl is omitted (fetch it via GET /graphql-apis/{graphqlApiId}/sdl +// instead). +func mapGraphQLAPIModelToDetail(m *model.GraphQLAPI) *api.GraphQLAPIDetail { + if m == nil { + return nil + } + + desc := m.Description + createdBy := m.CreatedBy + kind := constants.GraphQLApi + + var introspectionMode *api.GraphQLIntrospectionMode + if m.Configuration.IntrospectionMode != "" { + im := api.GraphQLIntrospectionMode(m.Configuration.IntrospectionMode) + introspectionMode = &im + } + + var subscriptionPlans *[]string + if len(m.Configuration.SubscriptionPlans) > 0 { + subscriptionPlans = &m.Configuration.SubscriptionPlans + } + + upstream := mapUpstreamConfigToDTO(&m.Configuration.Upstream) + + return &api.GraphQLAPIDetail{ + Id: utils.StringPtrIfNotEmpty(m.Handle), + DisplayName: m.Name, + Version: m.Version, + Context: utils.ValueOrEmpty(m.Configuration.Context), + ProjectId: m.ProjectID, + Description: &desc, + CreatedBy: &createdBy, + Kind: &kind, + IntrospectionMode: introspectionMode, + Upstream: upstream, + Policies: mapMCPPoliciesModelToAPI(m.Configuration.Policies), + SubscriptionPlans: subscriptionPlans, + ReadOnly: utils.BoolPtr(m.Origin == constants.OriginDP), + CreatedAt: utils.TimePtr(m.CreatedAt), + UpdatedAt: utils.TimePtr(m.UpdatedAt), + UpdatedBy: utils.StringPtrIfNotEmpty(m.UpdatedBy), + } +} + +// mapGraphQLAPIModelToListItem converts a model.GraphQLAPI to +// api.GraphQLAPIListItem. sdl is deliberately omitted (see +// GraphQLAPIListResponse's schema description in resources/openapi.yaml). +func mapGraphQLAPIModelToListItem(m *model.GraphQLAPI) *api.GraphQLAPIListItem { + if m == nil { + return nil + } + + var introspectionMode *api.GraphQLIntrospectionMode + if m.Configuration.IntrospectionMode != "" { + im := api.GraphQLIntrospectionMode(m.Configuration.IntrospectionMode) + introspectionMode = &im + } + + upstream := mapUpstreamConfigToDTO(&m.Configuration.Upstream) + + return &api.GraphQLAPIListItem{ + Id: utils.StringPtrIfNotEmpty(m.Handle), + DisplayName: m.Name, + Version: m.Version, + Context: utils.ValueOrEmpty(m.Configuration.Context), + ProjectId: m.ProjectID, + Description: utils.StringPtrIfNotEmpty(m.Description), + IntrospectionMode: introspectionMode, + Upstream: &upstream, + ReadOnly: utils.BoolPtr(m.Origin == constants.OriginDP), + CreatedBy: utils.StringPtrIfNotEmpty(m.CreatedBy), + CreatedAt: utils.TimePtr(m.CreatedAt), + UpdatedAt: utils.TimePtr(m.UpdatedAt), + } +} diff --git a/platform-api/internal/service/graphql_sdl.go b/platform-api/internal/service/graphql_sdl.go new file mode 100644 index 0000000000..55ebd05f0f --- /dev/null +++ b/platform-api/internal/service/graphql_sdl.go @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +import ( + "fmt" + "strings" + + "github.com/vektah/gqlparser/v2" + "github.com/vektah/gqlparser/v2/ast" +) + +// validateGraphQLSDL parses and validates a directly-supplied GraphQL SDL +// document. It rejects malformed SDL and SDL with no Query type. The +// returned error is for internal logging only — callers must map it to the +// generic GraphQLAPISchemaResolveFailed client response rather than +// surfacing the raw parser message (error-handling.md directive 1: a +// GraphQL parser's error output can be as internals-revealing as a raw DB +// error). +func validateGraphQLSDL(sdl string) error { + if strings.TrimSpace(sdl) == "" { + return fmt.Errorf("SDL must not be empty") + } + schema, err := gqlparser.LoadSchema(&ast.Source{Name: "schema.graphql", Input: sdl}) + if err != nil { + return fmt.Errorf("invalid GraphQL SDL: %w", err) + } + if schema.Query == nil { + return fmt.Errorf("GraphQL SDL must define a Query type") + } + return nil +} diff --git a/platform-api/internal/service/llm.go b/platform-api/internal/service/llm.go index 2a3714d83a..b0f7e76d2c 100644 --- a/platform-api/internal/service/llm.go +++ b/platform-api/internal/service/llm.go @@ -2438,40 +2438,6 @@ func mapUpstreamAPIToModel(in api.Upstream) *model.UpstreamConfig { return out } -func mapUpstreamModelToAPI(in *model.UpstreamConfig) api.Upstream { - main := api.UpstreamDefinition{} - if in != nil && in.Main != nil { - if strings.TrimSpace(in.Main.URL) != "" { - u := in.Main.URL - main.Url = &u - } - if strings.TrimSpace(in.Main.Ref) != "" { - r := in.Main.Ref - main.Ref = &r - } - if in.Main.Auth != nil { - main.Auth = mapUpstreamAuthModelToAPI(in.Main.Auth) - } - } - var sandbox *api.UpstreamDefinition - if in != nil && in.Sandbox != nil { - s := api.UpstreamDefinition{} - if strings.TrimSpace(in.Sandbox.URL) != "" { - u := in.Sandbox.URL - s.Url = &u - } - if strings.TrimSpace(in.Sandbox.Ref) != "" { - r := in.Sandbox.Ref - s.Ref = &r - } - if in.Sandbox.Auth != nil { - s.Auth = mapUpstreamAuthModelToAPI(in.Sandbox.Auth) - } - sandbox = &s - } - return api.Upstream{Main: main, Sandbox: sandbox} -} - // mapUpstreamConfigToDTO maps upstream config to API type with auth values redacted for security func mapUpstreamConfigToDTO(in *model.UpstreamConfig) api.Upstream { main := api.UpstreamDefinition{} @@ -2527,22 +2493,6 @@ func mapUpstreamConfigToDTO(in *model.UpstreamConfig) api.Upstream { return api.Upstream{Main: main, Sandbox: sandbox} } -func mapUpstreamAuthModelToAPI(in *model.UpstreamAuth) *api.UpstreamAuth { - if in == nil { - return nil - } - var authType *api.UpstreamAuthType - if normalized := normalizeUpstreamAuthType(in.Type); normalized != "" { - t := api.UpstreamAuthType(normalized) - authType = &t - } - return &api.UpstreamAuth{ - Type: authType, - Header: utils.StringPtrIfNotEmpty(in.Header), - Value: utils.StringPtrIfNotEmpty(in.Value), - } -} - func mapRateLimitingAPIToModel(in *api.LLMRateLimitingConfig) *model.LLMRateLimitingConfig { if in == nil { return nil diff --git a/platform-api/internal/utils/common.go b/platform-api/internal/utils/common.go index 79967caa54..0699fbfa97 100644 --- a/platform-api/internal/utils/common.go +++ b/platform-api/internal/utils/common.go @@ -179,6 +179,25 @@ func CreateBatchDeploymentTarGz(deploymentContentMap map[string]*model.Deploymen return buf.Bytes(), nil } +// CreateGraphQLAPIYamlZip creates a ZIP file containing GraphQL API YAML files +func CreateGraphQLAPIYamlZip(apiYamlMap map[string]string) ([]byte, error) { + var buf bytes.Buffer + zipWriter := zip.NewWriter(&buf) + + for apiID, yamlContent := range apiYamlMap { + fileName := fmt.Sprintf("graphql-api-%s.yaml", apiID) + if err := addFileToZip(zipWriter, fileName, []byte(yamlContent)); err != nil { + return nil, err + } + } + + if err := zipWriter.Close(); err != nil { + return nil, fmt.Errorf("failed to close zip writer: %w", err) + } + + return buf.Bytes(), nil +} + // CreateWebSubAPIYamlZip creates a ZIP file containing WebSub API YAML files func CreateWebSubAPIYamlZip(apiYamlMap map[string]string) ([]byte, error) { var buf bytes.Buffer diff --git a/platform-api/internal/utils/graphql_multipart.go b/platform-api/internal/utils/graphql_multipart.go new file mode 100644 index 0000000000..db186b16e7 --- /dev/null +++ b/platform-api/internal/utils/graphql_multipart.go @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package utils + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +const ( + // maxGraphQLSDLUploadBytes bounds an uploaded SDL file at 5 MiB, matching + // defaultOpenAPISpecMaxFetchBytes (openapi_spec_fetcher.go) so the ceiling + // is the same whether the schema arrives as a file upload or via sdlUrl. + maxGraphQLSDLUploadBytes = 5 << 20 + + // maxGraphQLMultipartRequestBytes bounds the whole multipart request body + // (sdlFile part plus the metadata JSON field plus multipart + // boundary/header framing) — maxGraphQLSDLUploadBytes alone is only the + // in-memory threshold ParseMultipartForm uses before spilling file parts + // to a temp file, not a ceiling on the request body itself. + maxGraphQLMultipartRequestBytes = maxGraphQLSDLUploadBytes + (1 << 20) // +1 MiB overhead + + // graphQLSDLFileFormField and graphQLMetadataFormField are the + // multipart/form-data field names documented on GraphQLAPIMultipartRequest + // (resources/openapi.yaml). + graphQLSDLFileFormField = "sdlFile" + graphQLMetadataFormField = "metadata" +) + +// ParseGraphQLAPIMultipartRequest extracts the JSON "metadata" field and the +// optional "sdlFile" file part from a multipart/form-data GraphQL API +// create/update request. metadataJSON is always returned non-empty on +// success; sdl is empty when no file part was submitted (the caller falls +// back to metadata's own sdl/sdlUrl/introspection path in that case). +// +// The whole request body is bounded via http.MaxBytesReader independently of +// the reported Content-Length (file-access.md directive 5). A part smaller +// than maxGraphQLSDLUploadBytes is read into memory; ParseMultipartForm may +// still spill a larger part to a temp file (bounded by the same ceiling) — +// MultipartForm.RemoveAll cleans that up once parsing completes. +func ParseGraphQLAPIMultipartRequest(r *http.Request) (metadataJSON []byte, sdl string, err error) { + r.Body = http.MaxBytesReader(nil, r.Body, maxGraphQLMultipartRequestBytes) + if err := r.ParseMultipartForm(maxGraphQLSDLUploadBytes); err != nil { + return nil, "", fmt.Errorf("failed to parse multipart form: %w", err) + } + defer func() { + if r.MultipartForm != nil { + _ = r.MultipartForm.RemoveAll() + } + }() + + metadata := r.FormValue(graphQLMetadataFormField) + if strings.TrimSpace(metadata) == "" { + return nil, "", fmt.Errorf("missing required '%s' field in multipart form", graphQLMetadataFormField) + } + + f, fileHeader, ferr := r.FormFile(graphQLSDLFileFormField) + if ferr != nil { + if !errors.Is(ferr, http.ErrMissingFile) { + return nil, "", fmt.Errorf("failed to read '%s' part: %w", graphQLSDLFileFormField, ferr) + } + // sdlFile is optional — a caller may submit metadata-only over + // multipart (e.g. for a client that always uses one content type), + // relying on metadata's own sdlUrl or upstream introspection. + return []byte(metadata), "", nil + } + defer f.Close() + + if fileHeader.Size > maxGraphQLSDLUploadBytes { + return nil, "", fmt.Errorf("'%s' file exceeds the maximum allowed size of %d bytes", graphQLSDLFileFormField, maxGraphQLSDLUploadBytes) + } + // Bound the read independently of the (client-reported, so untrusted) + // Size header above. + data, rerr := io.ReadAll(io.LimitReader(f, maxGraphQLSDLUploadBytes+1)) + if rerr != nil { + return nil, "", fmt.Errorf("failed to read '%s' file: %w", graphQLSDLFileFormField, rerr) + } + if int64(len(data)) > maxGraphQLSDLUploadBytes { + return nil, "", fmt.Errorf("'%s' file exceeds the maximum allowed size of %d bytes", graphQLSDLFileFormField, maxGraphQLSDLUploadBytes) + } + if strings.TrimSpace(string(data)) == "" { + return nil, "", fmt.Errorf("'%s' file is empty", graphQLSDLFileFormField) + } + + return []byte(metadata), string(data), nil +} + +// IsMultipartFormRequest reports whether r's Content-Type indicates a +// multipart/form-data body (a bare prefix check is correct and sufficient +// here — Content-Type is a same-request header the client sets, not a +// separately-untrusted routing input like a URL path per GO-AUTH-004). +func IsMultipartFormRequest(r *http.Request) bool { + return strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") +} diff --git a/platform-api/internal/utils/graphql_multipart_test.go b/platform-api/internal/utils/graphql_multipart_test.go new file mode 100644 index 0000000000..fde572cc11 --- /dev/null +++ b/platform-api/internal/utils/graphql_multipart_test.go @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package utils + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func newGraphQLMultipartRequest(t *testing.T, metadata, sdlFileContent string, includeFile bool) *http.Request { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + + if metadata != "" { + if err := w.WriteField(graphQLMetadataFormField, metadata); err != nil { + t.Fatalf("failed to write metadata field: %v", err) + } + } + if includeFile { + fw, err := w.CreateFormFile(graphQLSDLFileFormField, "schema.graphql") + if err != nil { + t.Fatalf("failed to create form file: %v", err) + } + if _, err := fw.Write([]byte(sdlFileContent)); err != nil { + t.Fatalf("failed to write form file content: %v", err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("failed to close multipart writer: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/graphql-apis", &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + return req +} + +func TestParseGraphQLAPIMultipartRequest_MetadataAndFile(t *testing.T) { + metadata := `{"displayName":"Countries","context":"/countries","version":"v1.0","projectId":"default-project"}` + sdl := "type Query { countries: [String] }" + req := newGraphQLMultipartRequest(t, metadata, sdl, true) + + gotMetadata, gotSDL, err := ParseGraphQLAPIMultipartRequest(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(gotMetadata) != metadata { + t.Errorf("metadata = %q, want %q", gotMetadata, metadata) + } + if gotSDL != sdl { + t.Errorf("sdl = %q, want %q", gotSDL, sdl) + } +} + +func TestParseGraphQLAPIMultipartRequest_MetadataOnly(t *testing.T) { + metadata := `{"displayName":"Countries","context":"/countries","version":"v1.0","projectId":"default-project"}` + req := newGraphQLMultipartRequest(t, metadata, "", false) + + gotMetadata, gotSDL, err := ParseGraphQLAPIMultipartRequest(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(gotMetadata) != metadata { + t.Errorf("metadata = %q, want %q", gotMetadata, metadata) + } + if gotSDL != "" { + t.Errorf("sdl = %q, want empty (no file part submitted)", gotSDL) + } +} + +func TestParseGraphQLAPIMultipartRequest_MissingMetadata(t *testing.T) { + req := newGraphQLMultipartRequest(t, "", "type Query { x: String }", true) + + _, _, err := ParseGraphQLAPIMultipartRequest(req) + if err == nil { + t.Fatal("expected an error when the metadata field is missing") + } +} + +func TestParseGraphQLAPIMultipartRequest_EmptyFile(t *testing.T) { + metadata := `{"displayName":"Countries"}` + req := newGraphQLMultipartRequest(t, metadata, " \n\t", true) + + _, _, err := ParseGraphQLAPIMultipartRequest(req) + if err == nil { + t.Fatal("expected an error for an empty (whitespace-only) sdlFile") + } +} + +func TestParseGraphQLAPIMultipartRequest_OversizedFile(t *testing.T) { + metadata := `{"displayName":"Countries"}` + oversized := strings.Repeat("a", maxGraphQLSDLUploadBytes+1) + req := newGraphQLMultipartRequest(t, metadata, oversized, true) + + _, _, err := ParseGraphQLAPIMultipartRequest(req) + if err == nil { + t.Fatal("expected an error for an sdlFile exceeding the size ceiling") + } + if !strings.Contains(err.Error(), "exceeds the maximum allowed size") { + t.Errorf("error = %q, want it to mention the size ceiling", err.Error()) + } +} + +func TestIsMultipartFormRequest(t *testing.T) { + jsonReq := httptest.NewRequest(http.MethodPost, "/graphql-apis", nil) + jsonReq.Header.Set("Content-Type", "application/json") + if IsMultipartFormRequest(jsonReq) { + t.Error("expected application/json request to not be detected as multipart") + } + + multipartReq := newGraphQLMultipartRequest(t, `{"a":1}`, "", false) + if !IsMultipartFormRequest(multipartReq) { + t.Error("expected multipart/form-data request to be detected as multipart") + } +} diff --git a/platform-api/internal/utils/import_artifacts.go b/platform-api/internal/utils/import_artifacts.go index bb71c01f10..a31bd2c550 100644 --- a/platform-api/internal/utils/import_artifacts.go +++ b/platform-api/internal/utils/import_artifacts.go @@ -134,6 +134,7 @@ var artifactImportOrder = map[string]int{ constants.RestApi: 4, constants.WebSubApi: 5, constants.WebBrokerApi: 6, + constants.GraphQLApi: 7, } // ArtifactImportRank returns the creation-order rank for a kind; unknown kinds sort last. diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 88d3f725b6..727889c040 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -1379,8 +1379,6 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - # --- API Publication: rollup across portals --------------------------------- - /api-publications: get: summary: List API publications across API Portals @@ -1919,38 +1917,91 @@ paths: '503': $ref: '#/components/responses/PortalUnavailable' - /llm-provider-templates: - post: - summary: Create a new LLM provider template family + /graphql-apis: + get: + summary: Get all GraphQL APIs for an organization description: | - Creates a new template family, starting at v1.0, with an - `LLMProviderTemplate` body. To add a new version to an existing family, - use `POST /llm-provider-templates/copy` instead. Organization is - identified via the JWT token. - operationId: createLLMProviderTemplate + Retrieves all GraphQL APIs belonging to an organization. Requires the + projectId query parameter to filter APIs by project. Access is validated + against the organization in the JWT token. + operationId: ListGraphQLAPIs security: - OAuth2Security: - - ap:llm_template:create - - ap:llm_template:manage + - ap:graphql_api:read + - ap:graphql_api:manage tags: - - LLM Provider Templates + - GraphQL APIs + parameters: + - $ref: '#/components/parameters/projectId-Q' + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' + - $ref: '#/components/parameters/sortBy-Q' + - $ref: '#/components/parameters/sortOrder-Q' + - $ref: '#/components/parameters/query-Q' + responses: + '200': + description: GraphQL APIs retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GraphQLAPIListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + post: + summary: Create a new GraphQL API + description: | + Creates a new GraphQL API in the platform. `schemaSource` declares how the + schema is supplied: `inline` (the `sdl` field), `url` (fetched from + `sdlUrl`), `file` (the `sdlFile` multipart part), or `introspection` (the + default — `upstream.main.url` must expose standard GraphQL introspection). + Only the field matching the declared source may be present — a request + that supplies a field not matching the declared `schemaSource` (or more + than one schema field at once), omits the field/part its declared source + requires, or declares `introspection` against an `upstream.main.ref` + instead of a literal `url`, is a request-shape problem and is rejected + with `400` (`VALIDATION_FAILED`) describing exactly what's inconsistent. + Once the request shape itself is valid, schema resolution is best-effort: + if the declared source can't actually be resolved (unreachable URL, + invalid SDL, introspection failing/disabled), the API is still created + with an empty schema rather than failing — fetch it later via + `GET /graphql-apis/{graphqlApiId}/sdl` once it can be resolved. The API is + associated with a project, which must belong to the organization + specified in the JWT token. + operationId: CreateGraphQLAPI + security: + - OAuth2Security: + - ap:graphql_api:create + - ap:graphql_api:manage + tags: + - GraphQL APIs requestBody: - required: true - description: The template family to create (starts at v1.0). + description: | + GraphQL API object that needs to be added, as `multipart/form-data` — see + GraphQLAPIMultipartRequest. This is the only accepted content type, even + when `schemaSource` is `inline`, `url`, or `introspection` and no file is + being uploaded, so that every schema-source variant is expressed the + same way. content: - application/json: + multipart/form-data: schema: - $ref: '#/components/schemas/LLMProviderTemplate' + $ref: '#/components/schemas/GraphQLAPIMultipartRequest' + required: true responses: '201': - description: LLM provider template created successfully + description: GraphQL API created successfully headers: Location: $ref: '#/components/headers/Location' content: application/json: schema: - $ref: '#/components/schemas/LLMProviderTemplate' + $ref: '#/components/schemas/GraphQLAPI' '400': $ref: '#/components/responses/BadRequest' '401': @@ -1963,57 +2014,38 @@ paths: $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' + + /graphql-apis/{graphqlApiId}: get: - summary: List templates, list a family's versions, or get a single version + summary: Get GraphQL API by ID description: | - Retrieves LLM provider templates based on the `query` parameter: - - no `query` -> list all template versions - - `query=latest:true` -> list the latest version of each template - - `query=groupId:` -> list all versions for the template - - `query=groupId:&version:` -> retrieve a specific template version - - All filters are provided via the URL-encoded `query` parameter (e.g. - `?query=groupId%3Awso2-openai%26version%3Av2.0`). - - Returns a single `LLMProviderTemplate` only when both `groupId` and `version` are specified; - otherwise returns an `LLMProviderTemplateListResponse`. - operationId: listLLMProviderTemplates + Retrieves the GraphQL API's metadata and configuration. The `sdl` field + is deliberately omitted from this response — it can be large, and most + callers only need the metadata — fetch it separately via + `GET /graphql-apis/{graphqlApiId}/sdl`. + operationId: GetGraphQLAPI security: - OAuth2Security: - - ap:llm_template:read - - ap:llm_template:manage + - ap:graphql_api:read + - ap:graphql_api:manage tags: - - LLM Provider Templates + - GraphQL APIs parameters: - - name: query - in: query - required: false - description: >- - URL-encoded search DSL. `query=latest:true` lists only the latest - version of each family; `query=groupId:` lists that family's - versions; adding `&version:` returns the single full template - for that version. Terms are `&`-separated `key:value` pairs and the - whole value is percent-encoded (e.g. - groupId%3Awso2-openai%26version%3Av2.0). + - name: graphqlApiId + in: path + required: true + description: | + **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. schema: type: string - example: groupId:wso2-openai&version:v2.0 - - $ref: '#/components/parameters/limit-Q' - - $ref: '#/components/parameters/offset-Q' + example: countries-graphql-api responses: '200': - description: >- - Without a `version` term: a list response — either templates (all - versions by default, or the latest of each family when - `query=latest:true`) or a family's versions. With - `query=groupId:&version:`: the single full template for that - version. + description: GraphQL API retrieved successfully content: application/json: schema: - oneOf: - - $ref: '#/components/schemas/LLMProviderTemplateListResponse' - - $ref: '#/components/schemas/LLMProviderTemplate' + $ref: '#/components/schemas/GraphQLAPIDetail' '400': $ref: '#/components/responses/BadRequest' '401': @@ -2022,81 +2054,49 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - - /llm-provider-templates/copy: - post: - summary: Create a new version by copying an existing one + put: + summary: Update GraphQL API description: | - Creates a new version within a template family by cloning an existing - version (`fromTemplateId`) and applying optional field overrides from - the body. The new version lands in the same family as the source and - becomes the latest. - - Versions start at v1.0 and only go higher — `toVersion` must match the - `v.` pattern with a major of at least 1 (e.g. v2.0); v0.x - is rejected. `toTemplateId`, when supplied, must equal the handle - derived from the family and `toVersion`. Organization is identified via - the JWT token. - - Built-in (WSO2-managed) template families are immutable: adding a version - to one is rejected with `403`. To base a custom template on a built-in, - create a new template (`POST /llm-provider-templates`) instead — it gets - its own `group_id` and starts at v1.0. - operationId: copyLLMProviderTemplateVersion + Updates an existing GraphQL API's details. `schemaSource` behaves as on + create (see `POST /graphql-apis`), including the same `400` + (`VALIDATION_FAILED`) response for a request shape that's inconsistent + with the declared `schemaSource` — re-supply `sdl`/`sdlUrl`/`sdlFile`, or + leave it as `introspection` to re-query `upstream.main.url` and pick up a + changed backend schema. If resolution fails (the source can't actually be + resolved right now), the previously-stored schema is left unchanged rather + than being cleared. + operationId: UpdateGraphQLAPI security: - OAuth2Security: - - ap:llm_template:create - - ap:llm_template:manage + - ap:graphql_api:update + - ap:graphql_api:manage tags: - - LLM Provider Templates + - GraphQL APIs parameters: - - name: fromTemplateId - in: query - required: true - description: Handle (id) of the source version to copy from. - schema: - type: string - example: openai-v3-0 - - name: toTemplateId - in: query - required: false - description: >- - Expected handle of the new version. Must equal the handle derived - from the source family and toVersion; used to validate the target. - schema: - type: string - example: openai-v4-0 - - name: toVersion - in: query + - name: graphqlApiId + in: path required: true - description: >- - New version identifier, e.g. v4.0. Must match v. with - a major of at least 1, and be unique within the family. + description: | + **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. schema: type: string - example: v4.0 + example: countries-graphql-api requestBody: - required: false - description: >- - Optional overrides applied on top of the copied config. Any field - present replaces the value cloned from the source version. + required: true + description: | + As `multipart/form-data` only — see GraphQLAPIMultipartRequest and the + note on `POST /graphql-apis`. content: - application/json: + multipart/form-data: schema: - $ref: '#/components/schemas/CreateLLMProviderTemplateVersionRequest' + $ref: '#/components/schemas/GraphQLAPIMultipartRequest' responses: - '201': - description: New version created successfully - headers: - Location: - $ref: '#/components/headers/Location' + '200': + description: GraphQL API updated successfully content: application/json: schema: - $ref: '#/components/schemas/LLMProviderTemplate' - examples: - default: - $ref: '#/components/examples/CopiedTemplateVersion' + $ref: '#/components/schemas/GraphQLAPI' '400': $ref: '#/components/responses/BadRequest' '401': @@ -2109,34 +2109,69 @@ paths: $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' + delete: + summary: Delete GraphQL API + operationId: DeleteGraphQLAPI + security: + - OAuth2Security: + - ap:graphql_api:delete + - ap:graphql_api:manage + tags: + - GraphQL APIs + parameters: + - name: graphqlApiId + in: path + required: true + description: | + **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. + schema: + type: string + example: countries-graphql-api + responses: + '204': + description: GraphQL API deleted successfully + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' - /llm-provider-templates/{llmProviderTemplateId}: + /graphql-apis/{graphqlApiId}/sdl: get: - summary: Get LLM provider template by id + summary: Get the SDL for a GraphQL API description: | - Retrieve the complete configuration for a specific LLM provider template. - operationId: getLLMProviderTemplate + Retrieves the GraphQL API's resolved schema in SDL form — the same text + `GET /graphql-apis/{graphqlApiId}` would have returned in its `sdl` field + before that field was split out into this dedicated endpoint (large, and + rarely needed alongside the rest of the metadata). + operationId: GetGraphQLAPISDL security: - OAuth2Security: - - ap:llm_template:read - - ap:llm_template:manage + - ap:graphql_api:read + - ap:graphql_api:manage tags: - - LLM Provider Templates + - GraphQL APIs parameters: - - name: llmProviderTemplateId + - name: graphqlApiId in: path required: true - description: Unique handle of the template. + description: | + **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. schema: type: string - example: openai + example: countries-graphql-api responses: '200': - description: LLM provider template details + description: SDL retrieved successfully content: application/json: schema: - $ref: '#/components/schemas/LLMProviderTemplate' + $ref: '#/components/schemas/GraphQLAPISDLResponse' '401': $ref: '#/components/responses/Unauthorized' '404': @@ -2144,128 +2179,136 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - put: - summary: Update an existing LLM provider template + /graphql-apis/validate-schema: + post: + summary: Dry-run GraphQL schema resolution description: | - Update the configuration of an existing LLM provider template. The template - is identified by its unique handle (id). This operation updates the latest - version of the template. - operationId: updateLLMProviderTemplate + Attempts to resolve a schema exactly as `POST`/`PUT /graphql-apis` + would — the same `schemaSource`-driven structural validation, the + same best-effort resolution (§5.2) — without persisting anything. A + request-shape mismatch (`schemaSource` inconsistent with the fields + supplied) is a `400` (`VALIDATION_FAILED`), same as create/update. An + actual resolution failure (bad SDL, an unreachable `sdlUrl`, a failed + introspection query) is **not** an error here either — the response + reports `resolved: false` so the caller can decide what to do, rather + than having to create a real API just to find out. + operationId: ValidateGraphQLSchema security: - OAuth2Security: - - ap:llm_template:update - - ap:llm_template:manage + - ap:graphql_api:create + - ap:graphql_api:manage tags: - - LLM Provider Templates - parameters: - - name: llmProviderTemplateId - in: path - required: true - description: Unique identifier of the template to update - schema: - type: string - example: openai + - GraphQL APIs requestBody: + description: | + As `multipart/form-data` only, following the same convention as + `POST /graphql-apis` — see `GraphQLAPIMultipartRequest`. required: true content: - application/json: + multipart/form-data: schema: - $ref: '#/components/schemas/LLMProviderTemplate' + $ref: '#/components/schemas/ValidateGraphQLSchemaMultipartRequest' responses: '200': - description: LLM provider template updated successfully + description: Schema resolution attempted — see `resolved` for the outcome. content: application/json: schema: - $ref: '#/components/schemas/LLMProviderTemplate' + $ref: '#/components/schemas/ValidateGraphQLSchemaResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - patch: - summary: Enable or disable a template version + /graphql-apis/{graphqlApiId}/gateways: + get: + summary: Get gateways for GraphQL API description: | - Enable or disable the single template version identified by its handle - (id). Only built-in templates can be toggled; disabling a version in use - by a provider is rejected (409). - operationId: setLLMProviderTemplateVersionEnabled + Retrieves all gateways associated with the specified API, including deployment details. + Returns gateway information along with association timestamps and deployment status. + Access is validated against the organization in the JWT token. + operationId: GetGraphQLAPIGateways security: - OAuth2Security: - - ap:llm_template:update - - ap:llm_template:manage + - ap:graphql_api:gateway:read + - ap:graphql_api:gateway:manage + - ap:graphql_api:manage + - ap:gateway:read + - ap:gateway:manage tags: - - LLM Provider Templates + - GraphQL APIs + - Gateways parameters: - - name: llmProviderTemplateId + - name: graphqlApiId in: path required: true - description: Unique handle of the template version. + description: | + **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. schema: type: string - example: openai - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [enabled] - properties: - enabled: - type: boolean - description: Set to true to enable this template version, false to disable it. - example: true + example: countries-graphql-api + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': - description: Updated template version + description: List of gateways associated with the API, including deployment details content: application/json: schema: - $ref: '#/components/schemas/LLMProviderTemplate' + $ref: '#/components/schemas/RESTAPIGatewayListResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' - delete: - summary: Delete a template version + post: + summary: Add gateways for GraphQL API description: | - Delete the single template version identified by its handle (id). - Built-in versions are read-only (403); a version still in use by a - provider is blocked (409). - operationId: deleteLLMProviderTemplateVersion + Associates gateways to the specified API. If gateways are already associated, + updates the association timestamp. Returns all gateways associated with the API + including deployment details. Access is validated against the organization + in the JWT token. + operationId: AddGatewaysToGraphQLAPI security: - OAuth2Security: - - ap:llm_template:delete - - ap:llm_template:manage + - ap:graphql_api:gateway:create + - ap:graphql_api:gateway:manage + - ap:graphql_api:manage tags: - - LLM Provider Templates + - GraphQL APIs + - Gateways parameters: - - name: llmProviderTemplateId + - name: graphqlApiId in: path required: true - description: Unique handle of the template version. + description: | + **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. schema: type: string - example: deep-seek-v2-0 + example: countries-graphql-api + requestBody: + description: List of gateways to associate with the API + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AddGatewayToRESTAPIRequest' responses: - '204': - description: Version deleted successfully + '200': + description: List of all gateways associated with the API, including deployment details + content: + application/json: + schema: + $ref: '#/components/schemas/RESTAPIGatewayListResponse' '400': $ref: '#/components/responses/BadRequest' '401': @@ -2274,188 +2317,112 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' - /llm-providers: + /graphql-apis/{graphqlApiId}/api-keys: post: - summary: Create a new LLM provider + summary: Create API key description: | - Deploy a new LLM provider configuration. - operationId: createLLMProvider + Creates a new API key for the specified GraphQL API. The API key will be hashed before + storage and broadcasted to all gateways where the API is deployed. This endpoint + allows external platforms to inject API keys to hybrid gateways. + operationId: CreateGraphQLAPIKey security: - OAuth2Security: - - ap:llm_provider:create - - ap:llm_provider:manage + - ap:graphql_api:api_key:create + - ap:graphql_api:api_key:manage + - ap:graphql_api:manage + - ap:api_key:all:manage tags: - - LLM Providers + - GraphQL APIs + - API Keys + parameters: + - name: graphqlApiId + in: path + required: true + description: | + **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. + schema: + type: string + example: countries-graphql-api requestBody: + description: API key creation request required: true content: application/json: schema: - $ref: '#/components/schemas/LLMProvider' - example: - id: wso2-openai-provider - displayName: WSO2 OpenAI Provider - version: v1.0 - template: openai - upstream: - main: - url: https://api.openai.com - accessControl: - mode: deny_all - exceptions: - - path: /chat/completions - methods: [ OPTIONS, PATCH ] - description: Primary OpenAI provider - context: /openai - vhost: api.openai.com - openapi: |- - openapi: 3.0.3 - info: - title: Provider API - version: v1.0 - paths: {} - modelProviders: - - id: openai - name: OpenAI - models: - - id: gpt-4o-mini - name: GPT-4o mini - description: Cost-effective general model - rateLimiting: - providerLevel: - global: - request: - enabled: true - count: 1500 - reset: - duration: 2 - unit: week - token: - enabled: true - count: 1000000 - reset: - duration: 1 - unit: month - associatedGateways: - - id: prod-eu - configurations: - host: prod-eu.platform-gw.local - - id: prod-us - configurations: - host: prod-us.platform-gw.local + $ref: '#/components/schemas/CreateAPIKeyRequest' responses: '201': - description: LLM provider created successfully + description: API key created successfully headers: Location: $ref: '#/components/headers/Location' content: application/json: schema: - $ref: '#/components/schemas/LLMProvider' + $ref: '#/components/schemas/CreateAPIKeyResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '409': - $ref: '#/components/responses/Conflict' - '500': - $ref: '#/components/responses/InternalServerError' - - get: - summary: List all LLM providers - description: Retrieve a list of all LLM providers. - operationId: listLLMProviders - security: - - OAuth2Security: - - ap:llm_provider:read - - ap:llm_provider:manage - tags: - - LLM Providers - parameters: - - $ref: '#/components/parameters/limit-Q' - - $ref: '#/components/parameters/offset-Q' - responses: - '200': - description: List of LLM providers - content: - application/json: - schema: - $ref: '#/components/schemas/LLMProviderListResponse' - '401': - $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/GatewayConnectionUnavailable' '500': $ref: '#/components/responses/InternalServerError' - /llm-providers/{llmProviderId}: - get: - summary: Get LLM provider by identifier - description: Retrieve the complete configuration for a specific LLM provider. - operationId: getLLMProvider + /graphql-apis/{graphqlApiId}/api-keys/{apiKeyId}: + put: + summary: Update API key + description: | + Updates an existing API key for the specified GraphQL API. The new API key value will + be hashed before storage and broadcasted to all gateways where the API is deployed. + This endpoint allows external platforms to rotate API keys on hybrid gateways. + operationId: UpdateGraphQLAPIKey security: - OAuth2Security: - - ap:llm_provider:read - - ap:llm_provider:manage + - ap:graphql_api:api_key:update + - ap:graphql_api:api_key:manage + - ap:graphql_api:manage + - ap:api_key:all:manage tags: - - LLM Providers + - GraphQL APIs + - API Keys parameters: - - name: llmProviderId + - name: graphqlApiId in: path required: true - description: Unique identifier of the LLM provider + description: | + **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. schema: type: string - responses: - '200': - description: LLM provider details - content: - application/json: - schema: - $ref: '#/components/schemas/LLMProvider' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalServerError' - - put: - summary: Update an existing LLM provider - description: Update the configuration of an existing LLM provider. - operationId: updateLLMProvider - security: - - OAuth2Security: - - ap:llm_provider:update - - ap:llm_provider:manage - tags: - - LLM Providers - parameters: - - name: llmProviderId + example: countries-graphql-api + - name: apiKeyId in: path required: true - description: Unique identifier of the LLM provider + description: The unique name/identifier of the API key schema: type: string + example: "my-api-key" requestBody: + description: API key update request required: true content: application/json: schema: - $ref: '#/components/schemas/LLMProvider' + $ref: '#/components/schemas/UpdateAPIKeyRequest' responses: '200': - description: LLM provider updated successfully + description: API key updated successfully content: application/json: schema: - $ref: '#/components/schemas/LLMProvider' + $ref: '#/components/schemas/UpdateAPIKeyResponse' '400': $ref: '#/components/responses/BadRequest' '401': @@ -2464,29 +2431,46 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/GatewayConnectionUnavailable' '500': $ref: '#/components/responses/InternalServerError' delete: - summary: Delete an LLM provider - description: Remove an LLM provider. - operationId: deleteLLMProvider + summary: Revoke API key + description: | + Revokes an API key for the specified GraphQL API. The revocation will be broadcasted + to all gateways where the API is deployed. This endpoint allows external platforms + to revoke API keys on hybrid gateways. + operationId: RevokeGraphQLAPIKey security: - OAuth2Security: - - ap:llm_provider:delete - - ap:llm_provider:manage + - ap:graphql_api:api_key:delete + - ap:graphql_api:api_key:manage + - ap:graphql_api:manage + - ap:api_key:all:manage tags: - - LLM Providers + - GraphQL APIs + - API Keys parameters: - - name: llmProviderId + - name: graphqlApiId in: path required: true - description: Unique identifier of the LLM provider + description: | + **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. + schema: + type: string + example: countries-graphql-api + - name: apiKeyId + in: path + required: true + description: The unique name/identifier of the API key to revoke schema: type: string + example: "my-api-key" responses: '204': - description: LLM provider deleted successfully + description: API key revoked successfully (no content) '400': $ref: '#/components/responses/BadRequest' '401': @@ -2495,243 +2479,47 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/GatewayConnectionUnavailable' '500': $ref: '#/components/responses/InternalServerError' - /llm-providers/{llmProviderId}/builds: + /graphql-apis/{graphqlApiId}/deployments: post: - summary: Prepare a build of a LLM provider + summary: Create and deploy a new deployment description: | - Renders the LLM provider's current definition into an immutable snapshot and stores it, - without deploying it anywhere. - - Preparing and deploying are separate steps so that what reaches a gateway is a - snapshot taken at a known moment: a deploy that names a build cannot silently - pick up edits made to the API since, and the same build can be deployed to any - number of gateways, and promoted onward, without being re-rendered. - - The artifact is stored at the platform's own data version; it is translated to - the target gateway's version when it is deployed. - - A LLM provider keeps at most `deployments.max_builds_per_api` builds. Preparing another - first removes the oldest builds no current deployment is using; if every one is - in use, the request is refused with a `409` and a build has to be deleted to - make room. - + Creates an immutable deployment artifact for a GraphQL API and deploys it to a specified gateway. + Each deployment targets a single gateway. The graphqlApiId parameter is the API handle (identifier), + not the UUID. The operation returns a transitional DEPLOYING status. Final success or failure will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. Access is validated against the organization in the JWT token. - operationId: CreateLLMProviderBuild + operationId: DeployGraphQLAPI security: - OAuth2Security: - - ap:llm_provider:build:create - - ap:llm_provider:build:manage - - ap:llm_provider:manage + - ap:graphql_api:deployment:create + - ap:graphql_api:deployment:manage + - ap:graphql_api:manage tags: - - LLM Provider Deployments + - GraphQL API Deployments - Deployments parameters: - - name: llmProviderId + - name: graphqlApiId in: path required: true + description: | + **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. schema: type: string - description: Identifier of the LLM provider + example: countries-graphql-api requestBody: - required: false + description: Deployment request with gateway ID, base reference, and metadata + required: true content: application/json: schema: - $ref: '#/components/schemas/BuildRequest' + $ref: '#/components/schemas/DeployRequest' responses: '201': - description: Build prepared successfully - headers: - Location: - $ref: '#/components/headers/Location' - content: - application/json: - schema: - $ref: '#/components/schemas/BuildResponse' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' - '500': - $ref: '#/components/responses/InternalServerError' - - get: - summary: Get builds for a LLM provider - description: | - Lists the LLM provider's builds, newest first. The rendered artifact itself is not - included; a listing is for choosing which build to deploy. - Access is validated against the organization in the JWT token. - operationId: GetLLMProviderBuilds - security: - - OAuth2Security: - - ap:llm_provider:build:read - - ap:llm_provider:build:manage - - ap:llm_provider:manage - tags: - - LLM Provider Deployments - - Deployments - parameters: - - name: llmProviderId - in: path - required: true - schema: - type: string - description: Identifier of the LLM provider - - $ref: '#/components/parameters/limit-Q' - responses: - '200': - description: Builds retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/BuildListResponse' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalServerError' - - /llm-providers/{llmProviderId}/builds/{buildId}: - get: - summary: Get build by ID - description: | - Retrieves metadata for a single build. - Access is validated against the organization in the JWT token. - operationId: GetLLMProviderBuild - security: - - OAuth2Security: - - ap:llm_provider:build:read - - ap:llm_provider:build:manage - - ap:llm_provider:manage - tags: - - LLM Provider Deployments - - Deployments - parameters: - - name: llmProviderId - in: path - required: true - schema: - type: string - description: Identifier of the LLM provider - - name: buildId - in: path - required: true - schema: - type: string - description: Identifier of the build - responses: - '200': - description: Build metadata retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/BuildResponse' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalServerError' - - delete: - summary: Delete a build - description: | - Deletes one of the LLM provider's builds, freeing a slot when the API is at its build - limit. - - Refused with a conflict while a gateway is serving the build — that is, while - any `DEPLOYED`, `DEPLOYING` or `UNDEPLOYING` deployment runs it. Undeploy it - first. - - Undeployed, failed and superseded deployments release the build. They keep the - artifact they were created with, so they can still be redeployed, but they stop - reporting a `buildId` and can no longer be promoted to a later environment. - - Access is validated against the organization in the JWT token. - operationId: DeleteLLMProviderBuild - security: - - OAuth2Security: - - ap:llm_provider:build:delete - - ap:llm_provider:build:manage - - ap:llm_provider:manage - tags: - - LLM Provider Deployments - - Deployments - parameters: - - name: llmProviderId - in: path - required: true - schema: - type: string - description: Identifier of the LLM provider - - name: buildId - in: path - required: true - schema: - type: string - description: Identifier of the build - responses: - '204': - description: Build deleted successfully - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' - '500': - $ref: '#/components/responses/InternalServerError' - - /llm-providers/{llmProviderId}/deployments: - post: - summary: Create and deploy a new LLM provider deployment - description: | - Creates an immutable deployment artifact for an LLM provider and deploys it to a specified gateway. - Each deployment targets a single gateway. The providerId parameter is the LLM provider handle (identifier), - not the UUID. The operation returns a transitional DEPLOYING status. Final success or failure will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. - Access is validated against the organization in the JWT token. - operationId: deployLLMProvider - security: - - OAuth2Security: - - ap:llm_provider:deployment:create - - ap:llm_provider:deployment:manage - - ap:llm_provider:manage - tags: - - LLM Provider Deployments - - Deployments - parameters: - - name: llmProviderId - in: path - required: true - description: Unique identifier of the LLM provider - schema: - type: string - requestBody: - description: Deployment request with gateway ID, base reference, and metadata - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DeployRequest' - responses: - '201': - description: LLM provider deployed successfully + description: GraphQL API deployed successfully headers: Location: $ref: '#/components/headers/Location' @@ -2754,27 +2542,29 @@ paths: $ref: '#/components/responses/InternalServerError' get: - summary: Get deployments for an LLM provider + summary: Get deployments for a GraphQL API description: | - Retrieves all deployment artifacts for a specific LLM provider. The providerId parameter is the - LLM provider handle (identifier), not the UUID. Supports filtering by gateway handle and deployment status. + Retrieves all deployment artifacts for a specific API. The graphqlApiId parameter is the API handle (identifier), + not the UUID. Supports filtering by gateway handle and deployment status. Access is validated against the organization in the JWT token. - operationId: getLLMProviderDeployments + operationId: GetGraphQLAPIDeployments security: - OAuth2Security: - - ap:llm_provider:deployment:read - - ap:llm_provider:deployment:manage - - ap:llm_provider:manage + - ap:graphql_api:deployment:read + - ap:graphql_api:deployment:manage + - ap:graphql_api:manage tags: - - LLM Provider Deployments + - GraphQL API Deployments - Deployments parameters: - - name: llmProviderId + - name: graphqlApiId in: path required: true - description: Unique identifier of the LLM provider + description: | + **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. schema: type: string + example: countries-graphql-api - $ref: '#/components/parameters/gatewayId-Q' - $ref: '#/components/parameters/deploymentStatus-Q' - $ref: '#/components/parameters/limit-Q' @@ -2795,28 +2585,30 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /llm-providers/{llmProviderId}/deployments/{deploymentId}: + /graphql-apis/{graphqlApiId}/deployments/{deploymentId}: get: - summary: Get LLM provider deployment by ID + summary: Get deployment by ID description: | - Retrieves metadata for a specific LLM provider deployment artifact including status, gateway association, + Retrieves metadata for a specific deployment artifact including status, gateway association, and timestamps. Access is validated against the organization in the JWT token. - operationId: getLLMProviderDeployment + operationId: GetGraphQLAPIDeployment security: - OAuth2Security: - - ap:llm_provider:deployment:read - - ap:llm_provider:deployment:manage - - ap:llm_provider:manage + - ap:graphql_api:deployment:read + - ap:graphql_api:deployment:manage + - ap:graphql_api:manage tags: - - LLM Provider Deployments + - GraphQL API Deployments - Deployments parameters: - - name: llmProviderId + - name: graphqlApiId in: path required: true - description: Unique identifier of the LLM provider + description: | + **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. schema: type: string + example: countries-graphql-api - $ref: '#/components/parameters/deploymentId' responses: '200': @@ -2832,26 +2624,28 @@ paths: '500': $ref: '#/components/responses/InternalServerError' delete: - summary: Delete LLM provider deployment + summary: Delete deployment description: | Deletes a deployment artifact. Deletion is only allowed when the deployment is in UNDEPLOYED status. Access is validated against the organization in the JWT token. - operationId: deleteLLMProviderDeployment + operationId: DeleteGraphQLAPIDeployment security: - OAuth2Security: - - ap:llm_provider:deployment:delete - - ap:llm_provider:deployment:manage - - ap:llm_provider:manage + - ap:graphql_api:deployment:delete + - ap:graphql_api:deployment:manage + - ap:graphql_api:manage tags: - - LLM Provider Deployments + - GraphQL API Deployments - Deployments parameters: - - name: llmProviderId + - name: graphqlApiId in: path required: true - description: Unique identifier of the LLM provider + description: | + **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. schema: type: string + example: countries-graphql-api - $ref: '#/components/parameters/deploymentId' responses: '204': @@ -2869,32 +2663,34 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /llm-providers/{llmProviderId}/deployments/{deploymentId}/undeploy: + /graphql-apis/{graphqlApiId}/deployments/{deploymentId}/undeploy: post: - summary: Undeploy LLM provider deployment from gateway + summary: Undeploy deployment from gateway description: | - Undeploys an active LLM provider deployment, stopping it from being served on the specified gateway. + Undeploys an active deployment, stopping the API from being served on the specified gateway. The deployment artifact remains in the system and can be restored later. Returns the updated deployment object with initial status UNDEPLOYING. Final status (UNDEPLOYED or FAILED) will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. - The gatewayId query parameter is validated against the deployment's bound gateway to prevent unintended operations. + The gatewayId query parameter is validated against deployment's bound gateway to prevent unintended operations. Access is validated against the organization in the JWT token. - operationId: undeployLLMProviderDeployment + operationId: UndeployGraphQLAPIDeployment security: - OAuth2Security: - - ap:llm_provider:deployment:undeploy - - ap:llm_provider:deployment:manage - - ap:llm_provider:manage + - ap:graphql_api:deployment:undeploy + - ap:graphql_api:deployment:manage + - ap:graphql_api:manage tags: - - LLM Provider Deployments + - GraphQL API Deployments - Deployments parameters: - - name: llmProviderId + - name: graphqlApiId in: path required: true - description: Unique identifier of the LLM provider + description: | + **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. schema: type: string + example: countries-graphql-api - name: deploymentId in: path required: true @@ -2933,9 +2729,9 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /llm-providers/{llmProviderId}/deployments/{deploymentId}/restore: + /graphql-apis/{graphqlApiId}/deployments/{deploymentId}/restore: post: - summary: Restore a previous LLM provider deployment + summary: Restore a previous deployment description: | Initiates restoring a previous deployment (ARCHIVED or UNDEPLOYED) on the specified gateway. Returns the deployment with initial status DEPLOYING. Final success or failure will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. @@ -2943,22 +2739,24 @@ paths: The gatewayId query parameter is validated against the deployment's bound gateway to prevent unintended operations. Access is validated against the organization in the JWT token. - operationId: restoreLLMProviderDeployment + operationId: RestoreGraphQLAPIDeployment security: - OAuth2Security: - - ap:llm_provider:deployment:restore - - ap:llm_provider:deployment:manage - - ap:llm_provider:manage + - ap:graphql_api:deployment:restore + - ap:graphql_api:deployment:manage + - ap:graphql_api:manage tags: - - LLM Provider Deployments + - GraphQL API Deployments - Deployments parameters: - - name: llmProviderId + - name: graphqlApiId in: path required: true - description: Unique identifier of the LLM provider + description: | + **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. schema: type: string + example: countries-graphql-api - name: deploymentId in: path required: true @@ -2997,125 +2795,103 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /llm-providers/{llmProviderId}/llm-proxies: - get: - summary: List LLM proxies by provider - description: Retrieve a list of LLM proxies that use the specified LLM provider. - operationId: listLLMProxiesByProvider - security: - - OAuth2Security: - - ap:llm_proxy:deployment:read - - ap:llm_proxy:deployment:manage - - ap:llm_proxy:manage - tags: - - LLM Providers - parameters: - - name: llmProviderId - in: path - required: true - description: Unique identifier of the LLM provider - schema: - type: string - - $ref: '#/components/parameters/limit-Q' - - $ref: '#/components/parameters/offset-Q' - responses: - '200': - description: List of LLM proxies - content: - application/json: - schema: - $ref: '#/components/schemas/LLMProxyListResponse' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalServerError' - - /llm-providers/{llmProviderId}/api-keys: + /llm-provider-templates: post: - summary: Create a new API key for an LLM provider + summary: Create a new LLM provider template family description: | - Generates a new API key for the specified LLM provider. The generated key - is broadcasted to all gateways in the organization and can be used to - authenticate requests to the LLM provider when API key validation is enabled. - operationId: createLLMProviderAPIKey + Creates a new template family, starting at v1.0, with an + `LLMProviderTemplate` body. To add a new version to an existing family, + use `POST /llm-provider-templates/copy` instead. Organization is + identified via the JWT token. + operationId: createLLMProviderTemplate security: - OAuth2Security: - - ap:llm_provider:api_key:create - - ap:llm_provider:api_key:manage - - ap:llm_provider:manage - - ap:api_key:all:manage + - ap:llm_template:create + - ap:llm_template:manage tags: - - LLM Providers - - API Keys - parameters: - - name: llmProviderId - in: path - required: true - description: Unique identifier of the LLM provider - schema: - type: string - example: wso2-openai-provider + - LLM Provider Templates requestBody: - description: API key creation details required: true + description: The template family to create (starts at v1.0). content: application/json: schema: - $ref: '#/components/schemas/CreateLLMProviderAPIKeyRequest' + $ref: '#/components/schemas/LLMProviderTemplate' responses: '201': - description: API key created successfully + description: LLM provider template created successfully headers: Location: $ref: '#/components/headers/Location' content: application/json: schema: - $ref: '#/components/schemas/CreateLLMProviderAPIKeyResponse' + $ref: '#/components/schemas/LLMProviderTemplate' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - '503': - $ref: '#/components/responses/GatewayConnectionUnavailable' + '409': + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' get: - summary: List API keys for an LLM provider - description: Returns all API keys associated with the specified LLM provider. The plain key value is never returned. - operationId: listLLMProviderAPIKeys + summary: List templates, list a family's versions, or get a single version + description: | + Retrieves LLM provider templates based on the `query` parameter: + - no `query` -> list all template versions + - `query=latest:true` -> list the latest version of each template + - `query=groupId:` -> list all versions for the template + - `query=groupId:&version:` -> retrieve a specific template version + + All filters are provided via the URL-encoded `query` parameter (e.g. + `?query=groupId%3Awso2-openai%26version%3Av2.0`). + + Returns a single `LLMProviderTemplate` only when both `groupId` and `version` are specified; + otherwise returns an `LLMProviderTemplateListResponse`. + operationId: listLLMProviderTemplates security: - OAuth2Security: - - ap:llm_provider:api_key:read - - ap:llm_provider:api_key:manage - - ap:llm_provider:manage - - ap:api_key:all:manage + - ap:llm_template:read + - ap:llm_template:manage tags: - - LLM Providers - - API Keys + - LLM Provider Templates parameters: - - name: llmProviderId - in: path - required: true - description: Unique identifier of the LLM provider + - name: query + in: query + required: false + description: >- + URL-encoded search DSL. `query=latest:true` lists only the latest + version of each family; `query=groupId:` lists that family's + versions; adding `&version:` returns the single full template + for that version. Terms are `&`-separated `key:value` pairs and the + whole value is percent-encoded (e.g. + groupId%3Awso2-openai%26version%3Av2.0). schema: type: string - example: wso2-openai-provider + example: groupId:wso2-openai&version:v2.0 - $ref: '#/components/parameters/limit-Q' - $ref: '#/components/parameters/offset-Q' responses: '200': - description: List of API keys retrieved successfully + description: >- + Without a `version` term: a list response — either templates (all + versions by default, or the latest of each family when + `query=latest:true`) or a family's versions. With + `query=groupId:&version:`: the single full template for that + version. content: application/json: schema: - $ref: '#/components/schemas/LLMProviderAPIKeyListResponse' + oneOf: + - $ref: '#/components/schemas/LLMProviderTemplateListResponse' + - $ref: '#/components/schemas/LLMProviderTemplate' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '404': @@ -3123,76 +2899,80 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /llm-providers/{llmProviderId}/api-keys/{apiKeyId}: - delete: - summary: Delete an API key for an LLM provider + /llm-provider-templates/copy: + post: + summary: Create a new version by copying an existing one description: | - Deletes the key from the database and broadcasts a revoke event to the allowed gateways. - operationId: deleteLLMProviderAPIKey + Creates a new version within a template family by cloning an existing + version (`fromTemplateId`) and applying optional field overrides from + the body. The new version lands in the same family as the source and + becomes the latest. + + Versions start at v1.0 and only go higher — `toVersion` must match the + `v.` pattern with a major of at least 1 (e.g. v2.0); v0.x + is rejected. `toTemplateId`, when supplied, must equal the handle + derived from the family and `toVersion`. Organization is identified via + the JWT token. + + Built-in (WSO2-managed) template families are immutable: adding a version + to one is rejected with `403`. To base a custom template on a built-in, + create a new template (`POST /llm-provider-templates`) instead — it gets + its own `group_id` and starts at v1.0. + operationId: copyLLMProviderTemplateVersion security: - OAuth2Security: - - ap:llm_provider:api_key:delete - - ap:llm_provider:api_key:manage - - ap:llm_provider:manage - - ap:api_key:all:manage + - ap:llm_template:create + - ap:llm_template:manage tags: - - LLM Providers - - API Keys + - LLM Provider Templates parameters: - - name: llmProviderId - in: path + - name: fromTemplateId + in: query required: true - description: Unique identifier of the LLM provider + description: Handle (id) of the source version to copy from. schema: type: string - example: wso2-openai-provider - - name: apiKeyId - in: path + example: openai-v3-0 + - name: toTemplateId + in: query + required: false + description: >- + Expected handle of the new version. Must equal the handle derived + from the source family and toVersion; used to validate the target. + schema: + type: string + example: openai-v4-0 + - name: toVersion + in: query required: true - description: Name of the API key to delete + description: >- + New version identifier, e.g. v4.0. Must match v. with + a major of at least 1, and be unique within the family. schema: type: string - example: my-api-key - responses: - '204': - description: API key deleted successfully - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - '503': - $ref: '#/components/responses/GatewayConnectionUnavailable' - '500': - $ref: '#/components/responses/InternalServerError' - - /llm-proxies: - post: - summary: Create a new LLM proxy - description: | - Deploy a new LLM proxy configuration. - operationId: createLLMProxy - security: - - OAuth2Security: - - ap:llm_proxy:create - - ap:llm_proxy:manage - tags: - - LLM Proxies + example: v4.0 requestBody: - required: true + required: false + description: >- + Optional overrides applied on top of the copied config. Any field + present replaces the value cloned from the source version. content: application/json: schema: - $ref: '#/components/schemas/LLMProxy' + $ref: '#/components/schemas/CreateLLMProviderTemplateVersionRequest' responses: '201': - description: LLM proxy created successfully + description: New version created successfully headers: Location: $ref: '#/components/headers/Location' content: application/json: schema: - $ref: '#/components/schemas/LLMProxy' + $ref: '#/components/schemas/LLMProviderTemplate' + examples: + default: + $ref: '#/components/examples/CopiedTemplateVersion' '400': $ref: '#/components/responses/BadRequest' '401': @@ -3206,94 +2986,125 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /llm-provider-templates/{llmProviderTemplateId}: get: - summary: List all LLM proxies - description: Retrieve a list of all LLM proxies for a project. Requires the projectId query parameter. - operationId: listLLMProxies + summary: Get LLM provider template by id + description: | + Retrieve the complete configuration for a specific LLM provider template. + operationId: getLLMProviderTemplate security: - OAuth2Security: - - ap:llm_proxy:read - - ap:llm_proxy:manage + - ap:llm_template:read + - ap:llm_template:manage tags: - - LLM Proxies + - LLM Provider Templates parameters: - - $ref: '#/components/parameters/projectId-Q' - - $ref: '#/components/parameters/limit-Q' - - $ref: '#/components/parameters/offset-Q' + - name: llmProviderTemplateId + in: path + required: true + description: Unique handle of the template. + schema: + type: string + example: openai responses: '200': - description: List of LLM proxies + description: LLM provider template details content: application/json: schema: - $ref: '#/components/schemas/LLMProxyListResponse' + $ref: '#/components/schemas/LLMProviderTemplate' '401': $ref: '#/components/responses/Unauthorized' - '500': + '404': + $ref: '#/components/responses/NotFound' + '500': $ref: '#/components/responses/InternalServerError' - /llm-proxies/{llmProxyId}: - get: - summary: Get LLM proxy by unique identifier - description: Retrieve the complete configuration for a specific LLM proxy. - operationId: getLLMProxy + put: + summary: Update an existing LLM provider template + description: | + Update the configuration of an existing LLM provider template. The template + is identified by its unique handle (id). This operation updates the latest + version of the template. + operationId: updateLLMProviderTemplate security: - OAuth2Security: - - ap:llm_proxy:read - - ap:llm_proxy:manage + - ap:llm_template:update + - ap:llm_template:manage tags: - - LLM Proxies + - LLM Provider Templates parameters: - - name: llmProxyId + - name: llmProviderTemplateId in: path required: true - description: Unique identifier of the LLM proxy + description: Unique identifier of the template to update schema: type: string + example: openai + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LLMProviderTemplate' responses: '200': - description: LLM proxy details + description: LLM provider template updated successfully content: application/json: schema: - $ref: '#/components/schemas/LLMProxy' + $ref: '#/components/schemas/LLMProviderTemplate' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - put: - summary: Update an existing LLM proxy - description: Update the configuration of an existing LLM proxy. - operationId: updateLLMProxy + patch: + summary: Enable or disable a template version + description: | + Enable or disable the single template version identified by its handle + (id). Only built-in templates can be toggled; disabling a version in use + by a provider is rejected (409). + operationId: setLLMProviderTemplateVersionEnabled security: - OAuth2Security: - - ap:llm_proxy:update - - ap:llm_proxy:manage + - ap:llm_template:update + - ap:llm_template:manage tags: - - LLM Proxies + - LLM Provider Templates parameters: - - name: llmProxyId + - name: llmProviderTemplateId in: path required: true - description: Unique identifier of the LLM proxy + description: Unique handle of the template version. schema: type: string + example: openai requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/LLMProxy' + type: object + required: [enabled] + properties: + enabled: + type: boolean + description: Set to true to enable this template version, false to disable it. + example: true responses: '200': - description: LLM proxy updated successfully + description: Updated template version content: application/json: schema: - $ref: '#/components/schemas/LLMProxy' + $ref: '#/components/schemas/LLMProviderTemplate' '400': $ref: '#/components/responses/BadRequest' '401': @@ -3302,29 +3113,35 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' delete: - summary: Delete an LLM proxy - description: Remove an LLM proxy. - operationId: deleteLLMProxy + summary: Delete a template version + description: | + Delete the single template version identified by its handle (id). + Built-in versions are read-only (403); a version still in use by a + provider is blocked (409). + operationId: deleteLLMProviderTemplateVersion security: - OAuth2Security: - - ap:llm_proxy:delete - - ap:llm_proxy:manage + - ap:llm_template:delete + - ap:llm_template:manage tags: - - LLM Proxies + - LLM Provider Templates parameters: - - name: llmProxyId + - name: llmProviderTemplateId in: path required: true - description: Unique identifier of the LLM proxy + description: Unique handle of the template version. schema: type: string + example: deep-seek-v2-0 responses: '204': - description: LLM proxy deleted successfully + description: Version deleted successfully '400': $ref: '#/components/responses/BadRequest' '401': @@ -3333,149 +3150,190 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' - /llm-proxies/{llmProxyId}/builds: + /llm-providers: post: - summary: Prepare a build of a LLM proxy + summary: Create a new LLM provider description: | - Renders the LLM proxy's current definition into an immutable snapshot and stores it, - without deploying it anywhere. - - Preparing and deploying are separate steps so that what reaches a gateway is a - snapshot taken at a known moment: a deploy that names a build cannot silently - pick up edits made to the API since, and the same build can be deployed to any - number of gateways, and promoted onward, without being re-rendered. - - The artifact is stored at the platform's own data version; it is translated to - the target gateway's version when it is deployed. - - A LLM proxy keeps at most `deployments.max_builds_per_api` builds. Preparing another - first removes the oldest builds no current deployment is using; if every one is - in use, the request is refused with a `409` and a build has to be deleted to - make room. - - Access is validated against the organization in the JWT token. - operationId: CreateLLMProxyBuild + Deploy a new LLM provider configuration. + operationId: createLLMProvider security: - OAuth2Security: - - ap:llm_proxy:build:create - - ap:llm_proxy:build:manage - - ap:llm_proxy:manage + - ap:llm_provider:create + - ap:llm_provider:manage tags: - - LLM Proxy Deployments - - Deployments - parameters: - - name: llmProxyId - in: path - required: true - schema: - type: string - description: Identifier of the LLM proxy + - LLM Providers requestBody: - required: false + required: true content: application/json: schema: - $ref: '#/components/schemas/BuildRequest' + $ref: '#/components/schemas/LLMProvider' + example: + id: wso2-openai-provider + displayName: WSO2 OpenAI Provider + version: v1.0 + template: openai + upstream: + main: + url: https://api.openai.com + accessControl: + mode: deny_all + exceptions: + - path: /chat/completions + methods: [ OPTIONS, PATCH ] + description: Primary OpenAI provider + context: /openai + vhost: api.openai.com + openapi: |- + openapi: 3.0.3 + info: + title: Provider API + version: v1.0 + paths: {} + modelProviders: + - id: openai + name: OpenAI + models: + - id: gpt-4o-mini + name: GPT-4o mini + description: Cost-effective general model + rateLimiting: + providerLevel: + global: + request: + enabled: true + count: 1500 + reset: + duration: 2 + unit: week + token: + enabled: true + count: 1000000 + reset: + duration: 1 + unit: month + associatedGateways: + - id: prod-eu + configurations: + host: prod-eu.platform-gw.local + - id: prod-us + configurations: + host: prod-us.platform-gw.local responses: '201': - description: Build prepared successfully + description: LLM provider created successfully headers: Location: $ref: '#/components/headers/Location' content: application/json: schema: - $ref: '#/components/schemas/BuildResponse' + $ref: '#/components/schemas/LLMProvider' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' get: - summary: Get builds for a LLM proxy - description: | - Lists the LLM proxy's builds, newest first. The rendered artifact itself is not - included; a listing is for choosing which build to deploy. - Access is validated against the organization in the JWT token. - operationId: GetLLMProxyBuilds + summary: List all LLM providers + description: Retrieve a list of all LLM providers. + operationId: listLLMProviders security: - OAuth2Security: - - ap:llm_proxy:build:read - - ap:llm_proxy:build:manage - - ap:llm_proxy:manage + - ap:llm_provider:read + - ap:llm_provider:manage tags: - - LLM Proxy Deployments - - Deployments + - LLM Providers parameters: - - name: llmProxyId - in: path - required: true - schema: - type: string - description: Identifier of the LLM proxy - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': - description: Builds retrieved successfully + description: List of LLM providers content: application/json: schema: - $ref: '#/components/schemas/BuildListResponse' + $ref: '#/components/schemas/LLMProviderListResponse' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - /llm-proxies/{llmProxyId}/builds/{buildId}: + /llm-providers/{llmProviderId}: get: - summary: Get build by ID - description: | - Retrieves metadata for a single build. - Access is validated against the organization in the JWT token. - operationId: GetLLMProxyBuild + summary: Get LLM provider by identifier + description: Retrieve the complete configuration for a specific LLM provider. + operationId: getLLMProvider security: - OAuth2Security: - - ap:llm_proxy:build:read - - ap:llm_proxy:build:manage - - ap:llm_proxy:manage + - ap:llm_provider:read + - ap:llm_provider:manage tags: - - LLM Proxy Deployments - - Deployments + - LLM Providers parameters: - - name: llmProxyId + - name: llmProviderId in: path required: true + description: Unique identifier of the LLM provider schema: type: string - description: Identifier of the LLM proxy - - name: buildId - in: path + responses: + '200': + description: LLM provider details + content: + application/json: + schema: + $ref: '#/components/schemas/LLMProvider' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + put: + summary: Update an existing LLM provider + description: Update the configuration of an existing LLM provider. + operationId: updateLLMProvider + security: + - OAuth2Security: + - ap:llm_provider:update + - ap:llm_provider:manage + tags: + - LLM Providers + parameters: + - name: llmProviderId + in: path required: true + description: Unique identifier of the LLM provider schema: type: string - description: Identifier of the build + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LLMProvider' responses: '200': - description: Build metadata retrieved successfully + description: LLM provider updated successfully content: application/json: schema: - $ref: '#/components/schemas/BuildResponse' + $ref: '#/components/schemas/LLMProvider' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -3486,100 +3344,89 @@ paths: $ref: '#/components/responses/InternalServerError' delete: - summary: Delete a build - description: | - Deletes one of the LLM proxy's builds, freeing a slot when the API is at its build - limit. - - Refused with a conflict while a gateway is serving the build — that is, while - any `DEPLOYED`, `DEPLOYING` or `UNDEPLOYING` deployment runs it. Undeploy it - first. - - Undeployed, failed and superseded deployments release the build. They keep the - artifact they were created with, so they can still be redeployed, but they stop - reporting a `buildId` and can no longer be promoted to a later environment. - - Access is validated against the organization in the JWT token. - operationId: DeleteLLMProxyBuild + summary: Delete an LLM provider + description: Remove an LLM provider. + operationId: deleteLLMProvider security: - OAuth2Security: - - ap:llm_proxy:build:delete - - ap:llm_proxy:build:manage - - ap:llm_proxy:manage + - ap:llm_provider:delete + - ap:llm_provider:manage tags: - - LLM Proxy Deployments - - Deployments + - LLM Providers parameters: - - name: llmProxyId - in: path - required: true - schema: - type: string - description: Identifier of the LLM proxy - - name: buildId + - name: llmProviderId in: path required: true + description: Unique identifier of the LLM provider schema: type: string - description: Identifier of the build responses: '204': - description: Build deleted successfully + description: LLM provider deleted successfully + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' - /llm-proxies/{llmProxyId}/deployments: + /llm-providers/{llmProviderId}/builds: post: - summary: Create and deploy a new LLM proxy deployment + summary: Prepare a build of a LLM provider description: | - Creates an immutable deployment artifact for an LLM proxy and deploys it to a specified gateway. - Each deployment targets a single gateway. The proxyId parameter is the LLM proxy handle (identifier), - not the UUID. The operation returns a transitional DEPLOYING status. Final success or failure will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. + Renders the LLM provider's current definition into an immutable snapshot and stores it, + without deploying it anywhere. + + Preparing and deploying are separate steps so that what reaches a gateway is a + snapshot taken at a known moment: a deploy that names a build cannot silently + pick up edits made to the API since, and the same build can be deployed to any + number of gateways, and promoted onward, without being re-rendered. + + The artifact is stored at the platform's own data version; it is translated to + the target gateway's version when it is deployed. + + A LLM provider keeps at most `deployments.max_builds_per_api` builds. Preparing another + first removes the oldest builds no current deployment is using; if every one is + in use, the request is refused with a `409` and a build has to be deleted to + make room. + Access is validated against the organization in the JWT token. - operationId: deployLLMProxy + operationId: CreateLLMProviderBuild security: - OAuth2Security: - - ap:llm_proxy:deployment:create - - ap:llm_proxy:deployment:manage - - ap:llm_proxy:manage + - ap:llm_provider:build:create + - ap:llm_provider:build:manage + - ap:llm_provider:manage tags: - - LLM Proxy Deployments + - LLM Provider Deployments - Deployments parameters: - - name: llmProxyId + - name: llmProviderId in: path required: true - description: Unique identifier of the LLM proxy schema: type: string + description: Identifier of the LLM provider requestBody: - description: Deployment request with gateway ID, base reference, and metadata - required: true + required: false content: application/json: schema: - $ref: '#/components/schemas/DeployRequest' + $ref: '#/components/schemas/BuildRequest' responses: '201': - description: LLM proxy deployed successfully + description: Build prepared successfully headers: Location: $ref: '#/components/headers/Location' content: application/json: schema: - $ref: '#/components/schemas/DeploymentResponse' - examples: - default: - $ref: '#/components/examples/DeploymentDeploying' + $ref: '#/components/schemas/BuildResponse' '400': $ref: '#/components/responses/BadRequest' '401': @@ -3588,114 +3435,134 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' get: - summary: Get deployments for an LLM proxy + summary: Get builds for a LLM provider description: | - Retrieves all deployment artifacts for a specific LLM proxy. The proxyId parameter is the - LLM proxy handle (identifier), not the UUID. Supports filtering by gateway handle and deployment status. + Lists the LLM provider's builds, newest first. The rendered artifact itself is not + included; a listing is for choosing which build to deploy. Access is validated against the organization in the JWT token. - operationId: getLLMProxyDeployments + operationId: GetLLMProviderBuilds security: - OAuth2Security: - - ap:llm_proxy:deployment:read - - ap:llm_proxy:deployment:manage - - ap:llm_proxy:manage + - ap:llm_provider:build:read + - ap:llm_provider:build:manage + - ap:llm_provider:manage tags: - - LLM Proxy Deployments + - LLM Provider Deployments - Deployments parameters: - - name: llmProxyId + - name: llmProviderId in: path required: true - description: Unique identifier of the LLM proxy schema: type: string - - $ref: '#/components/parameters/gatewayId-Q' - - $ref: '#/components/parameters/deploymentStatus-Q' + description: Identifier of the LLM provider - $ref: '#/components/parameters/limit-Q' - - $ref: '#/components/parameters/offset-Q' responses: '200': - description: Deployments retrieved successfully + description: Builds retrieved successfully content: application/json: schema: - $ref: '#/components/schemas/DeploymentListResponse' - '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/schemas/BuildListResponse' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - /llm-proxies/{llmProxyId}/deployments/{deploymentId}: + /llm-providers/{llmProviderId}/builds/{buildId}: get: - summary: Get LLM proxy deployment by ID + summary: Get build by ID description: | - Retrieves metadata for a specific LLM proxy deployment artifact including status, gateway association, - and timestamps. Access is validated against the organization in the JWT token. - operationId: getLLMProxyDeployment + Retrieves metadata for a single build. + Access is validated against the organization in the JWT token. + operationId: GetLLMProviderBuild security: - OAuth2Security: - - ap:llm_proxy:deployment:read - - ap:llm_proxy:deployment:manage - - ap:llm_proxy:manage + - ap:llm_provider:build:read + - ap:llm_provider:build:manage + - ap:llm_provider:manage tags: - - LLM Proxy Deployments + - LLM Provider Deployments - Deployments parameters: - - name: llmProxyId + - name: llmProviderId in: path required: true - description: Unique identifier of the LLM proxy schema: type: string - - $ref: '#/components/parameters/deploymentId' + description: Identifier of the LLM provider + - name: buildId + in: path + required: true + schema: + type: string + description: Identifier of the build responses: '200': - description: Deployment metadata retrieved successfully + description: Build metadata retrieved successfully content: application/json: schema: - $ref: '#/components/schemas/DeploymentResponse' + $ref: '#/components/schemas/BuildResponse' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' + delete: - summary: Delete LLM proxy deployment + summary: Delete a build description: | - Deletes a deployment artifact. Deletion is only allowed when the deployment is in UNDEPLOYED status. + Deletes one of the LLM provider's builds, freeing a slot when the API is at its build + limit. + + Refused with a conflict while a gateway is serving the build — that is, while + any `DEPLOYED`, `DEPLOYING` or `UNDEPLOYING` deployment runs it. Undeploy it + first. + + Undeployed, failed and superseded deployments release the build. They keep the + artifact they were created with, so they can still be redeployed, but they stop + reporting a `buildId` and can no longer be promoted to a later environment. + Access is validated against the organization in the JWT token. - operationId: deleteLLMProxyDeployment + operationId: DeleteLLMProviderBuild security: - OAuth2Security: - - ap:llm_proxy:deployment:delete - - ap:llm_proxy:deployment:manage - - ap:llm_proxy:manage + - ap:llm_provider:build:delete + - ap:llm_provider:build:manage + - ap:llm_provider:manage tags: - - LLM Proxy Deployments + - LLM Provider Deployments - Deployments parameters: - - name: llmProxyId + - name: llmProviderId in: path required: true - description: Unique identifier of the LLM proxy schema: type: string - - $ref: '#/components/parameters/deploymentId' + description: Identifier of the LLM provider + - name: buildId + in: path + required: true + schema: + type: string + description: Identifier of the build responses: '204': - description: Deployment deleted successfully - '400': - $ref: '#/components/responses/BadRequest' + description: Build deleted successfully '401': $ref: '#/components/responses/Unauthorized' '403': @@ -3703,34 +3570,205 @@ paths: '404': $ref: '#/components/responses/NotFound' '409': - $ref: '#/components/responses/DeploymentActiveConflict' + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' - /llm-proxies/{llmProxyId}/deployments/{deploymentId}/undeploy: + /llm-providers/{llmProviderId}/deployments: post: - summary: Undeploy LLM proxy deployment from gateway + summary: Create and deploy a new LLM provider deployment description: | - Undeploys an active LLM proxy deployment, stopping it from being served on the specified gateway. - The deployment artifact remains in the system and can be restored later. - Returns the updated deployment object with initial status UNDEPLOYING. Final status (UNDEPLOYED or FAILED) will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. - - The gatewayId query parameter is validated against the deployment's bound gateway to prevent unintended operations. + Creates an immutable deployment artifact for an LLM provider and deploys it to a specified gateway. + Each deployment targets a single gateway. The providerId parameter is the LLM provider handle (identifier), + not the UUID. The operation returns a transitional DEPLOYING status. Final success or failure will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. Access is validated against the organization in the JWT token. - operationId: undeployLLMProxyDeployment + operationId: deployLLMProvider security: - OAuth2Security: - - ap:llm_proxy:deployment:undeploy - - ap:llm_proxy:deployment:manage - - ap:llm_proxy:manage + - ap:llm_provider:deployment:create + - ap:llm_provider:deployment:manage + - ap:llm_provider:manage tags: - - LLM Proxy Deployments + - LLM Provider Deployments - Deployments parameters: - - name: llmProxyId + - name: llmProviderId in: path required: true - description: Unique identifier of the LLM proxy + description: Unique identifier of the LLM provider + schema: + type: string + requestBody: + description: Deployment request with gateway ID, base reference, and metadata + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DeployRequest' + responses: + '201': + description: LLM provider deployed successfully + headers: + Location: + $ref: '#/components/headers/Location' + content: + application/json: + schema: + $ref: '#/components/schemas/DeploymentResponse' + examples: + default: + $ref: '#/components/examples/DeploymentDeploying' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + get: + summary: Get deployments for an LLM provider + description: | + Retrieves all deployment artifacts for a specific LLM provider. The providerId parameter is the + LLM provider handle (identifier), not the UUID. Supports filtering by gateway handle and deployment status. + Access is validated against the organization in the JWT token. + operationId: getLLMProviderDeployments + security: + - OAuth2Security: + - ap:llm_provider:deployment:read + - ap:llm_provider:deployment:manage + - ap:llm_provider:manage + tags: + - LLM Provider Deployments + - Deployments + parameters: + - name: llmProviderId + in: path + required: true + description: Unique identifier of the LLM provider + schema: + type: string + - $ref: '#/components/parameters/gatewayId-Q' + - $ref: '#/components/parameters/deploymentStatus-Q' + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' + responses: + '200': + description: Deployments retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DeploymentListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /llm-providers/{llmProviderId}/deployments/{deploymentId}: + get: + summary: Get LLM provider deployment by ID + description: | + Retrieves metadata for a specific LLM provider deployment artifact including status, gateway association, + and timestamps. Access is validated against the organization in the JWT token. + operationId: getLLMProviderDeployment + security: + - OAuth2Security: + - ap:llm_provider:deployment:read + - ap:llm_provider:deployment:manage + - ap:llm_provider:manage + tags: + - LLM Provider Deployments + - Deployments + parameters: + - name: llmProviderId + in: path + required: true + description: Unique identifier of the LLM provider + schema: + type: string + - $ref: '#/components/parameters/deploymentId' + responses: + '200': + description: Deployment metadata retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DeploymentResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + summary: Delete LLM provider deployment + description: | + Deletes a deployment artifact. Deletion is only allowed when the deployment is in UNDEPLOYED status. + Access is validated against the organization in the JWT token. + operationId: deleteLLMProviderDeployment + security: + - OAuth2Security: + - ap:llm_provider:deployment:delete + - ap:llm_provider:deployment:manage + - ap:llm_provider:manage + tags: + - LLM Provider Deployments + - Deployments + parameters: + - name: llmProviderId + in: path + required: true + description: Unique identifier of the LLM provider + schema: + type: string + - $ref: '#/components/parameters/deploymentId' + responses: + '204': + description: Deployment deleted successfully + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/DeploymentActiveConflict' + '500': + $ref: '#/components/responses/InternalServerError' + + /llm-providers/{llmProviderId}/deployments/{deploymentId}/undeploy: + post: + summary: Undeploy LLM provider deployment from gateway + description: | + Undeploys an active LLM provider deployment, stopping it from being served on the specified gateway. + The deployment artifact remains in the system and can be restored later. + Returns the updated deployment object with initial status UNDEPLOYING. Final status (UNDEPLOYED or FAILED) will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. + + The gatewayId query parameter is validated against the deployment's bound gateway to prevent unintended operations. + Access is validated against the organization in the JWT token. + operationId: undeployLLMProviderDeployment + security: + - OAuth2Security: + - ap:llm_provider:deployment:undeploy + - ap:llm_provider:deployment:manage + - ap:llm_provider:manage + tags: + - LLM Provider Deployments + - Deployments + parameters: + - name: llmProviderId + in: path + required: true + description: Unique identifier of the LLM provider schema: type: string - name: deploymentId @@ -3771,9 +3809,9 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /llm-proxies/{llmProxyId}/deployments/{deploymentId}/restore: + /llm-providers/{llmProviderId}/deployments/{deploymentId}/restore: post: - summary: Restore a previous LLM proxy deployment + summary: Restore a previous LLM provider deployment description: | Initiates restoring a previous deployment (ARCHIVED or UNDEPLOYED) on the specified gateway. Returns the deployment with initial status DEPLOYING. Final success or failure will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. @@ -3781,20 +3819,20 @@ paths: The gatewayId query parameter is validated against the deployment's bound gateway to prevent unintended operations. Access is validated against the organization in the JWT token. - operationId: restoreLLMProxyDeployment + operationId: restoreLLMProviderDeployment security: - OAuth2Security: - - ap:llm_proxy:deployment:restore - - ap:llm_proxy:deployment:manage - - ap:llm_proxy:manage + - ap:llm_provider:deployment:restore + - ap:llm_provider:deployment:manage + - ap:llm_provider:manage tags: - - LLM Proxy Deployments + - LLM Provider Deployments - Deployments parameters: - - name: llmProxyId + - name: llmProviderId in: path required: true - description: Unique identifier of the LLM proxy + description: Unique identifier of the LLM provider schema: type: string - name: deploymentId @@ -3835,78 +3873,116 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /llm-proxies/{llmProxyId}/api-keys: - post: - summary: Create a new API key for an LLM proxy - description: | - Generates a new API key for the specified LLM proxy. The generated key - is broadcasted to all gateways in the organization and can be used to - authenticate requests to the LLM proxy when API key validation is enabled. - operationId: createLLMProxyAPIKey + /llm-providers/{llmProviderId}/llm-proxies: + get: + summary: List LLM proxies by provider + description: Retrieve a list of LLM proxies that use the specified LLM provider. + operationId: listLLMProxiesByProvider security: - OAuth2Security: - - ap:llm_proxy:api_key:create - - ap:llm_proxy:api_key:manage + - ap:llm_proxy:deployment:read + - ap:llm_proxy:deployment:manage - ap:llm_proxy:manage - - ap:api_key:all:manage tags: - - LLM Proxies + - LLM Providers parameters: - - name: llmProxyId + - name: llmProviderId in: path required: true - description: Unique identifier of the LLM proxy + description: Unique identifier of the LLM provider schema: type: string - example: wso2-openai-proxy - requestBody: - description: API key creation details - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateLLMProxyAPIKeyRequest' + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: - '201': - description: API key created successfully - headers: - Location: - $ref: '#/components/headers/Location' + '200': + description: List of LLM proxies content: application/json: schema: - $ref: '#/components/schemas/CreateLLMProxyAPIKeyResponse' + $ref: '#/components/schemas/LLMProxyListResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' - '503': - $ref: '#/components/responses/GatewayConnectionUnavailable' '500': $ref: '#/components/responses/InternalServerError' - get: - summary: List API keys for an LLM proxy - description: Returns all API keys associated with the specified LLM proxy. The plain key value is never returned. - operationId: listLLMProxyAPIKeys - security: - - OAuth2Security: - - ap:llm_proxy:api_key:read - - ap:llm_proxy:api_key:manage - - ap:llm_proxy:manage + + /llm-providers/{llmProviderId}/api-keys: + post: + summary: Create a new API key for an LLM provider + description: | + Generates a new API key for the specified LLM provider. The generated key + is broadcasted to all gateways in the organization and can be used to + authenticate requests to the LLM provider when API key validation is enabled. + operationId: createLLMProviderAPIKey + security: + - OAuth2Security: + - ap:llm_provider:api_key:create + - ap:llm_provider:api_key:manage + - ap:llm_provider:manage - ap:api_key:all:manage tags: - - LLM Proxies + - LLM Providers - API Keys parameters: - - name: llmProxyId + - name: llmProviderId in: path required: true - description: Unique identifier of the LLM proxy + description: Unique identifier of the LLM provider schema: type: string - example: wso2-openai-proxy + example: wso2-openai-provider + requestBody: + description: API key creation details + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateLLMProviderAPIKeyRequest' + responses: + '201': + description: API key created successfully + headers: + Location: + $ref: '#/components/headers/Location' + content: + application/json: + schema: + $ref: '#/components/schemas/CreateLLMProviderAPIKeyResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/GatewayConnectionUnavailable' + '500': + $ref: '#/components/responses/InternalServerError' + get: + summary: List API keys for an LLM provider + description: Returns all API keys associated with the specified LLM provider. The plain key value is never returned. + operationId: listLLMProviderAPIKeys + security: + - OAuth2Security: + - ap:llm_provider:api_key:read + - ap:llm_provider:api_key:manage + - ap:llm_provider:manage + - ap:api_key:all:manage + tags: + - LLM Providers + - API Keys + parameters: + - name: llmProviderId + in: path + required: true + description: Unique identifier of the LLM provider + schema: + type: string + example: wso2-openai-provider - $ref: '#/components/parameters/limit-Q' - $ref: '#/components/parameters/offset-Q' responses: @@ -3915,7 +3991,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/LLMProxyAPIKeyListResponse' + $ref: '#/components/schemas/LLMProviderAPIKeyListResponse' '401': $ref: '#/components/responses/Unauthorized' '404': @@ -3923,29 +3999,29 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /llm-proxies/{llmProxyId}/api-keys/{apiKeyId}: + /llm-providers/{llmProviderId}/api-keys/{apiKeyId}: delete: - summary: Delete an API key for an LLM proxy + summary: Delete an API key for an LLM provider description: | Deletes the key from the database and broadcasts a revoke event to the allowed gateways. - operationId: deleteLLMProxyAPIKey + operationId: deleteLLMProviderAPIKey security: - OAuth2Security: - - ap:llm_proxy:api_key:delete - - ap:llm_proxy:api_key:manage - - ap:llm_proxy:manage + - ap:llm_provider:api_key:delete + - ap:llm_provider:api_key:manage + - ap:llm_provider:manage - ap:api_key:all:manage tags: - - LLM Proxies + - LLM Providers - API Keys parameters: - - name: llmProxyId + - name: llmProviderId in: path required: true - description: Unique identifier of the LLM proxy + description: Unique identifier of the LLM provider schema: type: string - example: wso2-openai-proxy + example: wso2-openai-provider - name: apiKeyId in: path required: true @@ -3965,96 +4041,98 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /mcp-proxies: + /llm-proxies: post: - summary: Create a new MCP proxy + summary: Create a new LLM proxy description: | - Deploy a new MCP proxy configuration. - operationId: createMCPProxy + Deploy a new LLM proxy configuration. + operationId: createLLMProxy security: - OAuth2Security: - - ap:mcp_proxy:create - - ap:mcp_proxy:manage + - ap:llm_proxy:create + - ap:llm_proxy:manage tags: - - MCP Proxies + - LLM Proxies requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/MCPProxy' + $ref: '#/components/schemas/LLMProxy' responses: '201': - description: MCP proxy created successfully + description: LLM proxy created successfully headers: Location: $ref: '#/components/headers/Location' content: application/json: schema: - $ref: '#/components/schemas/MCPProxy' + $ref: '#/components/schemas/LLMProxy' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' get: - summary: List all MCP proxies - description: Retrieve a list of all MCP proxies for a project. Requires the projectId query parameter. - operationId: listMCPProxies + summary: List all LLM proxies + description: Retrieve a list of all LLM proxies for a project. Requires the projectId query parameter. + operationId: listLLMProxies security: - OAuth2Security: - - ap:mcp_proxy:read - - ap:mcp_proxy:manage + - ap:llm_proxy:read + - ap:llm_proxy:manage tags: - - MCP Proxies + - LLM Proxies parameters: - $ref: '#/components/parameters/projectId-Q' - $ref: '#/components/parameters/limit-Q' - $ref: '#/components/parameters/offset-Q' responses: '200': - description: List of MCP proxies + description: List of LLM proxies content: application/json: schema: - $ref: '#/components/schemas/MCPProxyListResponse' + $ref: '#/components/schemas/LLMProxyListResponse' '401': $ref: '#/components/responses/Unauthorized' '500': $ref: '#/components/responses/InternalServerError' - /mcp-proxies/{mcpProxyId}: + /llm-proxies/{llmProxyId}: get: - summary: Get MCP proxy by unique identifier - description: Retrieve the complete configuration for a specific MCP proxy. - operationId: getMCPProxy + summary: Get LLM proxy by unique identifier + description: Retrieve the complete configuration for a specific LLM proxy. + operationId: getLLMProxy security: - OAuth2Security: - - ap:mcp_proxy:read - - ap:mcp_proxy:manage + - ap:llm_proxy:read + - ap:llm_proxy:manage tags: - - MCP Proxies + - LLM Proxies parameters: - - name: mcpProxyId + - name: llmProxyId in: path required: true - description: Unique identifier of the MCP proxy + description: Unique identifier of the LLM proxy schema: type: string responses: '200': - description: MCP proxy details + description: LLM proxy details content: application/json: schema: - $ref: '#/components/schemas/MCPProxy' + $ref: '#/components/schemas/LLMProxy' '401': $ref: '#/components/responses/Unauthorized' '404': @@ -4063,20 +4141,20 @@ paths: $ref: '#/components/responses/InternalServerError' put: - summary: Update an existing MCP proxy - description: Update the configuration of an existing MCP proxy. - operationId: updateMCPProxy + summary: Update an existing LLM proxy + description: Update the configuration of an existing LLM proxy. + operationId: updateLLMProxy security: - OAuth2Security: - - ap:mcp_proxy:update - - ap:mcp_proxy:manage + - ap:llm_proxy:update + - ap:llm_proxy:manage tags: - - MCP Proxies + - LLM Proxies parameters: - - name: mcpProxyId + - name: llmProxyId in: path required: true - description: Unique identifier of the MCP proxy + description: Unique identifier of the LLM proxy schema: type: string requestBody: @@ -4084,14 +4162,14 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/MCPProxy' + $ref: '#/components/schemas/LLMProxy' responses: '200': - description: MCP proxy updated successfully + description: LLM proxy updated successfully content: application/json: schema: - $ref: '#/components/schemas/MCPProxy' + $ref: '#/components/schemas/LLMProxy' '400': $ref: '#/components/responses/BadRequest' '401': @@ -4102,27 +4180,27 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - + delete: - summary: Delete an MCP proxy - description: Remove an MCP proxy. - operationId: deleteMCPProxy + summary: Delete an LLM proxy + description: Remove an LLM proxy. + operationId: deleteLLMProxy security: - OAuth2Security: - - ap:mcp_proxy:delete - - ap:mcp_proxy:manage + - ap:llm_proxy:delete + - ap:llm_proxy:manage tags: - - MCP Proxies + - LLM Proxies parameters: - - name: mcpProxyId + - name: llmProxyId in: path required: true - description: Unique identifier of the MCP proxy + description: Unique identifier of the LLM proxy schema: type: string responses: '204': - description: MCP proxy deleted successfully + description: LLM proxy deleted successfully '400': $ref: '#/components/responses/BadRequest' '401': @@ -4134,11 +4212,11 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /mcp-proxies/{mcpProxyId}/builds: + /llm-proxies/{llmProxyId}/builds: post: - summary: Prepare a build of a MCP proxy + summary: Prepare a build of a LLM proxy description: | - Renders the MCP proxy's current definition into an immutable snapshot and stores it, + Renders the LLM proxy's current definition into an immutable snapshot and stores it, without deploying it anywhere. Preparing and deploying are separate steps so that what reaches a gateway is a @@ -4149,28 +4227,28 @@ paths: The artifact is stored at the platform's own data version; it is translated to the target gateway's version when it is deployed. - A MCP proxy keeps at most `deployments.max_builds_per_api` builds. Preparing another + A LLM proxy keeps at most `deployments.max_builds_per_api` builds. Preparing another first removes the oldest builds no current deployment is using; if every one is in use, the request is refused with a `409` and a build has to be deleted to make room. Access is validated against the organization in the JWT token. - operationId: CreateMCPProxyBuild + operationId: CreateLLMProxyBuild security: - OAuth2Security: - - ap:mcp_proxy:build:create - - ap:mcp_proxy:build:manage - - ap:mcp_proxy:manage + - ap:llm_proxy:build:create + - ap:llm_proxy:build:manage + - ap:llm_proxy:manage tags: - - MCP Proxy Deployments + - LLM Proxy Deployments - Deployments parameters: - - name: mcpProxyId + - name: llmProxyId in: path required: true schema: type: string - description: Identifier of the MCP proxy + description: Identifier of the LLM proxy requestBody: required: false content: @@ -4201,27 +4279,27 @@ paths: $ref: '#/components/responses/InternalServerError' get: - summary: Get builds for a MCP proxy + summary: Get builds for a LLM proxy description: | - Lists the MCP proxy's builds, newest first. The rendered artifact itself is not + Lists the LLM proxy's builds, newest first. The rendered artifact itself is not included; a listing is for choosing which build to deploy. Access is validated against the organization in the JWT token. - operationId: GetMCPProxyBuilds + operationId: GetLLMProxyBuilds security: - OAuth2Security: - - ap:mcp_proxy:build:read - - ap:mcp_proxy:build:manage - - ap:mcp_proxy:manage + - ap:llm_proxy:build:read + - ap:llm_proxy:build:manage + - ap:llm_proxy:manage tags: - - MCP Proxy Deployments + - LLM Proxy Deployments - Deployments parameters: - - name: mcpProxyId + - name: llmProxyId in: path required: true schema: type: string - description: Identifier of the MCP proxy + description: Identifier of the LLM proxy - $ref: '#/components/parameters/limit-Q' responses: '200': @@ -4239,28 +4317,28 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /mcp-proxies/{mcpProxyId}/builds/{buildId}: + /llm-proxies/{llmProxyId}/builds/{buildId}: get: summary: Get build by ID description: | Retrieves metadata for a single build. Access is validated against the organization in the JWT token. - operationId: GetMCPProxyBuild + operationId: GetLLMProxyBuild security: - OAuth2Security: - - ap:mcp_proxy:build:read - - ap:mcp_proxy:build:manage - - ap:mcp_proxy:manage + - ap:llm_proxy:build:read + - ap:llm_proxy:build:manage + - ap:llm_proxy:manage tags: - - MCP Proxy Deployments + - LLM Proxy Deployments - Deployments parameters: - - name: mcpProxyId + - name: llmProxyId in: path required: true schema: type: string - description: Identifier of the MCP proxy + description: Identifier of the LLM proxy - name: buildId in: path required: true @@ -4286,7 +4364,7 @@ paths: delete: summary: Delete a build description: | - Deletes one of the MCP proxy's builds, freeing a slot when the API is at its build + Deletes one of the LLM proxy's builds, freeing a slot when the API is at its build limit. Refused with a conflict while a gateway is serving the build — that is, while @@ -4298,22 +4376,22 @@ paths: reporting a `buildId` and can no longer be promoted to a later environment. Access is validated against the organization in the JWT token. - operationId: DeleteMCPProxyBuild + operationId: DeleteLLMProxyBuild security: - OAuth2Security: - - ap:mcp_proxy:build:delete - - ap:mcp_proxy:build:manage - - ap:mcp_proxy:manage + - ap:llm_proxy:build:delete + - ap:llm_proxy:build:manage + - ap:llm_proxy:manage tags: - - MCP Proxy Deployments + - LLM Proxy Deployments - Deployments parameters: - - name: mcpProxyId + - name: llmProxyId in: path required: true schema: type: string - description: Identifier of the MCP proxy + description: Identifier of the LLM proxy - name: buildId in: path required: true @@ -4334,28 +4412,28 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /mcp-proxies/{mcpProxyId}/deployments: + /llm-proxies/{llmProxyId}/deployments: post: - summary: Create and deploy a new deployment for MCP proxy + summary: Create and deploy a new LLM proxy deployment description: | - Creates an immutable deployment artifact for an MCP proxy and deploys it to a specified gateway. - Each deployment targets a single gateway. The id parameter is the MCP proxy handle (identifier), - not the UUID. The operation returns a transitional DEPLOYING status. Final success or failure will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. + Creates an immutable deployment artifact for an LLM proxy and deploys it to a specified gateway. + Each deployment targets a single gateway. The proxyId parameter is the LLM proxy handle (identifier), + not the UUID. The operation returns a transitional DEPLOYING status. Final success or failure will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. Access is validated against the organization in the JWT token. - operationId: DeployMCPProxy + operationId: deployLLMProxy security: - OAuth2Security: - - ap:mcp_proxy:deployment:create - - ap:mcp_proxy:deployment:manage - - ap:mcp_proxy:manage + - ap:llm_proxy:deployment:create + - ap:llm_proxy:deployment:manage + - ap:llm_proxy:manage tags: - - MCP Proxy Deployments + - LLM Proxy Deployments - Deployments parameters: - - name: mcpProxyId + - name: llmProxyId in: path required: true - description: Unique identifier of the MCP proxy + description: Unique identifier of the LLM proxy schema: type: string requestBody: @@ -4367,7 +4445,7 @@ paths: $ref: '#/components/schemas/DeployRequest' responses: '201': - description: MCP proxy deployed successfully + description: LLM proxy deployed successfully headers: Location: $ref: '#/components/headers/Location' @@ -4382,31 +4460,33 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' get: - summary: Get deployments for an MCP proxy + summary: Get deployments for an LLM proxy description: | - Retrieves all deployment artifacts for a specific MCP proxy. The id parameter is the MCP proxy handle (identifier), - not the UUID. Supports filtering by gateway handle and deployment status. + Retrieves all deployment artifacts for a specific LLM proxy. The proxyId parameter is the + LLM proxy handle (identifier), not the UUID. Supports filtering by gateway handle and deployment status. Access is validated against the organization in the JWT token. - operationId: GetMCPProxyDeployments + operationId: getLLMProxyDeployments security: - OAuth2Security: - - ap:mcp_proxy:deployment:read - - ap:mcp_proxy:deployment:manage - - ap:mcp_proxy:manage + - ap:llm_proxy:deployment:read + - ap:llm_proxy:deployment:manage + - ap:llm_proxy:manage tags: - - MCP Proxy Deployments + - LLM Proxy Deployments - Deployments parameters: - - name: mcpProxyId + - name: llmProxyId in: path required: true - description: Unique identifier of the MCP proxy + description: Unique identifier of the LLM proxy schema: type: string - $ref: '#/components/parameters/gatewayId-Q' @@ -4429,26 +4509,26 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /mcp-proxies/{mcpProxyId}/deployments/{deploymentId}: + /llm-proxies/{llmProxyId}/deployments/{deploymentId}: get: - summary: Get deployment by ID + summary: Get LLM proxy deployment by ID description: | - Retrieves metadata for a specific deployment artifact including status, gateway association, + Retrieves metadata for a specific LLM proxy deployment artifact including status, gateway association, and timestamps. Access is validated against the organization in the JWT token. - operationId: GetMCPProxyDeployment + operationId: getLLMProxyDeployment security: - OAuth2Security: - - ap:mcp_proxy:deployment:read - - ap:mcp_proxy:deployment:manage - - ap:mcp_proxy:manage + - ap:llm_proxy:deployment:read + - ap:llm_proxy:deployment:manage + - ap:llm_proxy:manage tags: - - MCP Proxy Deployments + - LLM Proxy Deployments - Deployments parameters: - - name: mcpProxyId + - name: llmProxyId in: path required: true - description: Unique identifier of the MCP proxy + description: Unique identifier of the LLM proxy schema: type: string - $ref: '#/components/parameters/deploymentId' @@ -4466,24 +4546,24 @@ paths: '500': $ref: '#/components/responses/InternalServerError' delete: - summary: Delete deployment + summary: Delete LLM proxy deployment description: | Deletes a deployment artifact. Deletion is only allowed when the deployment is in UNDEPLOYED status. Access is validated against the organization in the JWT token. - operationId: DeleteMCPProxyDeployment + operationId: deleteLLMProxyDeployment security: - OAuth2Security: - - ap:mcp_proxy:deployment:delete - - ap:mcp_proxy:deployment:manage - - ap:mcp_proxy:manage + - ap:llm_proxy:deployment:delete + - ap:llm_proxy:deployment:manage + - ap:llm_proxy:manage tags: - - MCP Proxy Deployments + - LLM Proxy Deployments - Deployments parameters: - - name: mcpProxyId + - name: llmProxyId in: path required: true - description: Unique identifier of the MCP proxy + description: Unique identifier of the LLM proxy schema: type: string - $ref: '#/components/parameters/deploymentId' @@ -4494,6 +4574,8 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': @@ -4501,30 +4583,30 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /mcp-proxies/{mcpProxyId}/deployments/{deploymentId}/undeploy: + /llm-proxies/{llmProxyId}/deployments/{deploymentId}/undeploy: post: - summary: Undeploy deployment from gateway + summary: Undeploy LLM proxy deployment from gateway description: | - Undeploys an active deployment, stopping the MCP proxy from being served on the specified gateway. + Undeploys an active LLM proxy deployment, stopping it from being served on the specified gateway. The deployment artifact remains in the system and can be restored later. Returns the updated deployment object with initial status UNDEPLOYING. Final status (UNDEPLOYED or FAILED) will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. The gatewayId query parameter is validated against the deployment's bound gateway to prevent unintended operations. Access is validated against the organization in the JWT token. - operationId: UndeployMCPProxyDeployment + operationId: undeployLLMProxyDeployment security: - OAuth2Security: - - ap:mcp_proxy:deployment:undeploy - - ap:mcp_proxy:deployment:manage - - ap:mcp_proxy:manage + - ap:llm_proxy:deployment:undeploy + - ap:llm_proxy:deployment:manage + - ap:llm_proxy:manage tags: - - MCP Proxy Deployments + - LLM Proxy Deployments - Deployments parameters: - - name: mcpProxyId + - name: llmProxyId in: path required: true - description: Unique identifier of the MCP proxy + description: Unique identifier of the LLM proxy schema: type: string - name: deploymentId @@ -4556,6 +4638,8 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': @@ -4563,9 +4647,9 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /mcp-proxies/{mcpProxyId}/deployments/{deploymentId}/restore: + /llm-proxies/{llmProxyId}/deployments/{deploymentId}/restore: post: - summary: Restore a previous deployment + summary: Restore a previous LLM proxy deployment description: | Initiates restoring a previous deployment (ARCHIVED or UNDEPLOYED) on the specified gateway. Returns the deployment with initial status DEPLOYING. Final success or failure will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. @@ -4573,20 +4657,20 @@ paths: The gatewayId query parameter is validated against the deployment's bound gateway to prevent unintended operations. Access is validated against the organization in the JWT token. - operationId: RestoreMCPProxyDeployment + operationId: restoreLLMProxyDeployment security: - OAuth2Security: - - ap:mcp_proxy:deployment:restore - - ap:mcp_proxy:deployment:manage - - ap:mcp_proxy:manage + - ap:llm_proxy:deployment:restore + - ap:llm_proxy:deployment:manage + - ap:llm_proxy:manage tags: - - MCP Proxy Deployments + - LLM Proxy Deployments - Deployments parameters: - - name: mcpProxyId + - name: llmProxyId in: path required: true - description: Unique identifier of the MCP proxy + description: Unique identifier of the LLM proxy schema: type: string - name: deploymentId @@ -4618,6 +4702,8 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': @@ -4625,313 +4711,263 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /mcp-proxies/fetch-server-info: - post: - summary: Fetch server info from MCP proxy backend services - description: | - Fetches server information from the backend services of an MCP proxy. - This is used to validate connectivity and retrieve metadata about the backend services. - operationId: fetchMCPProxyServerInfo - security: - - OAuth2Security: - - ap:mcp_proxy:read - - ap:mcp_proxy:manage - tags: - - MCP Proxies - requestBody: - description: Target MCP server to introspect — either a direct `url` (with optional `auth`), or a `proxyId` to refetch using a stored proxy configuration. - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/MCPServerInfoFetchRequest' - responses: - '200': - description: Server info retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/MCPServerInfoFetchResponse' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalServerError' - - /gateways: + /llm-proxies/{llmProxyId}/api-keys: post: - summary: Register a new gateway + summary: Create a new API key for an LLM proxy description: | - Creates a new gateway within the organization specified in the JWT token. - Organization ID is automatically extracted from the token and does not need to be provided. - operationId: CreateGateway + Generates a new API key for the specified LLM proxy. The generated key + is broadcasted to all gateways in the organization and can be used to + authenticate requests to the LLM proxy when API key validation is enabled. + operationId: createLLMProxyAPIKey security: - OAuth2Security: - - ap:gateway:create - - ap:gateway:manage + - ap:llm_proxy:api_key:create + - ap:llm_proxy:api_key:manage + - ap:llm_proxy:manage + - ap:api_key:all:manage tags: - - Gateways + - LLM Proxies + parameters: + - name: llmProxyId + in: path + required: true + description: Unique identifier of the LLM proxy + schema: + type: string + example: wso2-openai-proxy requestBody: - description: Gateway registration details + description: API key creation details required: true content: application/json: schema: - $ref: '#/components/schemas/CreateGatewayRequest' + $ref: '#/components/schemas/CreateLLMProxyAPIKeyRequest' responses: '201': - description: Gateway registered successfully + description: API key created successfully headers: Location: $ref: '#/components/headers/Location' content: application/json: schema: - $ref: '#/components/schemas/GatewayResponse' + $ref: '#/components/schemas/CreateLLMProxyAPIKeyResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' + '503': + $ref: '#/components/responses/GatewayConnectionUnavailable' '500': $ref: '#/components/responses/InternalServerError' - get: - summary: List all gateways - description: | - Retrieves a list of all registered gateways for the organization specified in the JWT token. - Organization ID is automatically extracted from the token. - operationId: ListGateways + summary: List API keys for an LLM proxy + description: Returns all API keys associated with the specified LLM proxy. The plain key value is never returned. + operationId: listLLMProxyAPIKeys security: - OAuth2Security: - - ap:gateway:read - - ap:gateway:manage + - ap:llm_proxy:api_key:read + - ap:llm_proxy:api_key:manage + - ap:llm_proxy:manage + - ap:api_key:all:manage tags: - - Gateways + - LLM Proxies + - API Keys parameters: + - name: llmProxyId + in: path + required: true + description: Unique identifier of the LLM proxy + schema: + type: string + example: wso2-openai-proxy - $ref: '#/components/parameters/limit-Q' - $ref: '#/components/parameters/offset-Q' - - $ref: '#/components/parameters/sortBy-Q' - - $ref: '#/components/parameters/sortOrder-Q' - - $ref: '#/components/parameters/query-Q' responses: '200': - description: Gateways retrieved successfully + description: List of API keys retrieved successfully content: application/json: schema: - $ref: '#/components/schemas/GatewayListResponse' - '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/schemas/LLMProxyAPIKeyListResponse' '401': $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - /gateways/{gatewayId}: - get: - summary: Get gateway by ID - description: | - Retrieves a specific gateway by its ID (handle). Access is validated against the organization - in the JWT token. - operationId: GetGateway + /llm-proxies/{llmProxyId}/api-keys/{apiKeyId}: + delete: + summary: Delete an API key for an LLM proxy + description: | + Deletes the key from the database and broadcasts a revoke event to the allowed gateways. + operationId: deleteLLMProxyAPIKey security: - OAuth2Security: - - ap:gateway:read - - ap:gateway:manage + - ap:llm_proxy:api_key:delete + - ap:llm_proxy:api_key:manage + - ap:llm_proxy:manage + - ap:api_key:all:manage tags: - - Gateways + - LLM Proxies + - API Keys parameters: - - $ref: '#/components/parameters/gatewayId' + - name: llmProxyId + in: path + required: true + description: Unique identifier of the LLM proxy + schema: + type: string + example: wso2-openai-proxy + - name: apiKeyId + in: path + required: true + description: Name of the API key to delete + schema: + type: string + example: my-api-key responses: - '200': - description: Gateway retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/GatewayResponse' - '400': - $ref: '#/components/responses/BadRequest' + '204': + description: API key deleted successfully '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/GatewayConnectionUnavailable' '500': $ref: '#/components/responses/InternalServerError' - put: - summary: Update gateway + /mcp-proxies: + post: + summary: Create a new MCP proxy description: | - Updates an existing gateway's mutable fields (description). - Access is validated against the organization in the JWT token. - operationId: UpdateGateway + Deploy a new MCP proxy configuration. + operationId: createMCPProxy security: - OAuth2Security: - - ap:gateway:update - - ap:gateway:manage + - ap:mcp_proxy:create + - ap:mcp_proxy:manage tags: - - Gateways - parameters: - - $ref: '#/components/parameters/gatewayId' + - MCP Proxies requestBody: - description: Gateway object that needs to be updated required: true content: application/json: schema: - $ref: '#/components/schemas/GatewayResponse' + $ref: '#/components/schemas/MCPProxy' responses: - '200': - description: Gateway updated successfully + '201': + description: MCP proxy created successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: - $ref: '#/components/schemas/GatewayResponse' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '500': - $ref: '#/components/responses/InternalServerError' - - delete: - summary: Delete gateway - description: | - Permanently deletes a gateway and all associated tokens (CASCADE). - Deletion is blocked if the gateway has active API deployments or WebSocket connections. - Access is validated against the organization in the JWT token. - operationId: DeleteGateway - security: - - OAuth2Security: - - ap:gateway:delete - - ap:gateway:manage - tags: - - Gateways - parameters: - - $ref: '#/components/parameters/gatewayId' - responses: - '204': - description: Gateway deleted successfully + $ref: '#/components/schemas/MCPProxy' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' - /gateways/{gatewayId}/tokens: get: - summary: List active gateway tokens - description: | - Returns all active tokens for the specified gateway. Token hashes and salts are never exposed. - Access is validated against the organization in the JWT token. - operationId: listGatewayTokens + summary: List all MCP proxies + description: Retrieve a list of all MCP proxies for a project. Requires the projectId query parameter. + operationId: listMCPProxies security: - OAuth2Security: - - ap:gateway:token:read - - ap:gateway:token:manage - - ap:gateway:manage + - ap:mcp_proxy:read + - ap:mcp_proxy:manage tags: - - Gateway Tokens + - MCP Proxies parameters: - - $ref: '#/components/parameters/gatewayId' + - $ref: '#/components/parameters/projectId-Q' - $ref: '#/components/parameters/limit-Q' - $ref: '#/components/parameters/offset-Q' responses: '200': - description: List of active tokens + description: List of MCP proxies content: application/json: schema: - $ref: '#/components/schemas/GatewayTokenListResponse' + $ref: '#/components/schemas/MCPProxyListResponse' '401': $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - post: - summary: Rotate gateway token - description: | - Generates a new authentication token for the gateway. The existing token remains active - to enable zero-downtime rotation. Access is validated against the organization in the JWT token. - operationId: rotateGatewayToken + + /mcp-proxies/{mcpProxyId}: + get: + summary: Get MCP proxy by unique identifier + description: Retrieve the complete configuration for a specific MCP proxy. + operationId: getMCPProxy security: - OAuth2Security: - - ap:gateway:token:create - - ap:gateway:token:manage - - ap:gateway:manage + - ap:mcp_proxy:read + - ap:mcp_proxy:manage tags: - - Gateway Tokens + - MCP Proxies parameters: - - $ref: '#/components/parameters/gatewayId' + - name: mcpProxyId + in: path + required: true + description: Unique identifier of the MCP proxy + schema: + type: string responses: - '201': - description: New token generated successfully - headers: - Location: - $ref: '#/components/headers/Location' + '200': + description: MCP proxy details content: application/json: schema: - $ref: '#/components/schemas/TokenRotationResponse' - '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/schemas/MCPProxy' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - /gateways/{gatewayId}/tokens/{tokenId}: - delete: - summary: Revoke gateway token - description: | - Revokes a specific gateway token. Operation is idempotent - revoking an already-revoked - token succeeds. Access is validated against the organization in the JWT token. - operationId: revokeGatewayToken + put: + summary: Update an existing MCP proxy + description: Update the configuration of an existing MCP proxy. + operationId: updateMCPProxy security: - OAuth2Security: - - ap:gateway:token:delete - - ap:gateway:token:manage - - ap:gateway:manage + - ap:mcp_proxy:update + - ap:mcp_proxy:manage tags: - - Gateway Tokens + - MCP Proxies parameters: - - $ref: '#/components/parameters/gatewayId' - - $ref: '#/components/parameters/tokenId' + - name: mcpProxyId + in: path + required: true + description: Unique identifier of the MCP proxy + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MCPProxy' responses: '200': - description: Token revoked successfully + description: MCP proxy updated successfully content: application/json: schema: - type: object - properties: - message: - type: string - example: "Token revoked successfully" + $ref: '#/components/schemas/MCPProxy' '400': $ref: '#/components/responses/BadRequest' '401': @@ -4942,208 +4978,227 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - - /gateways/{gatewayId}/manifest: - get: - summary: Get gateway policy manifest - description: | - Returns the policy manifest for the specified gateway. The manifest is populated by the gateway - controller when it connects to the platform API, and contains all installed policies. Custom - policies additionally include their full policy definition schema. - operationId: GetGatewayManifest + + delete: + summary: Delete an MCP proxy + description: Remove an MCP proxy. + operationId: deleteMCPProxy security: - OAuth2Security: - - ap:gateway:manifest:read - - ap:gateway:manage + - ap:mcp_proxy:delete + - ap:mcp_proxy:manage tags: - - Gateways + - MCP Proxies parameters: - - $ref: '#/components/parameters/gatewayId' + - name: mcpProxyId + in: path + required: true + description: Unique identifier of the MCP proxy + schema: + type: string responses: - '200': - description: Gateway policy manifest - content: - application/json: - schema: - $ref: '#/components/schemas/ManifestSyncResponse' + '204': + description: MCP proxy deleted successfully + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - /gateway-custom-policies: - get: - summary: Get synced custom policies for the current organization + /mcp-proxies/{mcpProxyId}/builds: + post: + summary: Prepare a build of a MCP proxy description: | - Returns all custom policies synced to the current organization (from the JWT `organization` claim). - operationId: ListGatewayCustomPolicies + Renders the MCP proxy's current definition into an immutable snapshot and stores it, + without deploying it anywhere. + + Preparing and deploying are separate steps so that what reaches a gateway is a + snapshot taken at a known moment: a deploy that names a build cannot silently + pick up edits made to the API since, and the same build can be deployed to any + number of gateways, and promoted onward, without being re-rendered. + + The artifact is stored at the platform's own data version; it is translated to + the target gateway's version when it is deployed. + + A MCP proxy keeps at most `deployments.max_builds_per_api` builds. Preparing another + first removes the oldest builds no current deployment is using; if every one is + in use, the request is refused with a `409` and a build has to be deleted to + make room. + + Access is validated against the organization in the JWT token. + operationId: CreateMCPProxyBuild security: - OAuth2Security: - - ap:gateway_custom_policy:read - - ap:gateway_custom_policy:manage + - ap:mcp_proxy:build:create + - ap:mcp_proxy:build:manage + - ap:mcp_proxy:manage tags: - - Gateway Policies + - MCP Proxy Deployments + - Deployments parameters: - - $ref: '#/components/parameters/limit-Q' - - $ref: '#/components/parameters/offset-Q' + - name: mcpProxyId + in: path + required: true + schema: + type: string + description: Identifier of the MCP proxy + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BuildRequest' responses: - '200': - description: List of custom policies + '201': + description: Build prepared successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: - $ref: '#/components/schemas/CustomPolicyListResponse' + $ref: '#/components/schemas/BuildResponse' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' - /gateway-custom-policies/sync: - post: - summary: Sync a custom policy from the gateway manifest + get: + summary: Get builds for a MCP proxy description: | - Syncs a custom policy from the gateway manifest into the organization's custom policy registry. - Version-based rules apply: - - **New major version**: creates a new policy record (e.g. v1.x.x and v2.x.x coexist). - - **New minor version** (same major): updates the existing record (e.g. v1.1.0 → v1.2.0). - - **Patch version change** (same major.minor): not allowed. - - **Downgrade**: not allowed. - Policy names are case-insensitive. - After syncing, the policy can be applied to APIs in the organization. - operationId: SyncCustomPolicy + Lists the MCP proxy's builds, newest first. The rendered artifact itself is not + included; a listing is for choosing which build to deploy. + Access is validated against the organization in the JWT token. + operationId: GetMCPProxyBuilds security: - OAuth2Security: - - ap:gateway_custom_policy:create - - ap:gateway_custom_policy:manage + - ap:mcp_proxy:build:read + - ap:mcp_proxy:build:manage + - ap:mcp_proxy:manage tags: - - Gateway Policies + - MCP Proxy Deployments + - Deployments parameters: - - name: gatewayId - in: query - required: true - schema: - type: string - minLength: 3 - maxLength: 40 - readOnly: true - description: Handle (URL-friendly slug) of the gateway whose manifest contains the policy - example: "prod-gateway-01" - - name: policyName - in: query - required: true - schema: - type: string - description: Name of the custom policy (case-insensitive) - example: "set-wso2-headers" - - name: policyVersion - in: query + - name: mcpProxyId + in: path required: true schema: type: string - description: Version of the custom policy in MAJOR.MINOR.PATCH format - example: "1.0.0" + description: Identifier of the MCP proxy + - $ref: '#/components/parameters/limit-Q' responses: '200': - description: Custom policy synced successfully + description: Builds retrieved successfully content: application/json: schema: - $ref: '#/components/schemas/CustomPolicyResponse' - '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/schemas/BuildListResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' - '422': - description: Policy is not a custom policy or manifest is unavailable - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - example: - status: error - code: POLICY_INVALID_STATE - message: "The policy is not a custom policy, or its manifest is unavailable." '500': $ref: '#/components/responses/InternalServerError' - /gateway-custom-policies/{gatewayCustomPolicyId}/versions/{version}: + /mcp-proxies/{mcpProxyId}/builds/{buildId}: get: - summary: Get a specific custom policy version - description: Returns a custom policy by its UUID and version for the current organization. - operationId: GetGatewayCustomPolicy + summary: Get build by ID + description: | + Retrieves metadata for a single build. + Access is validated against the organization in the JWT token. + operationId: GetMCPProxyBuild security: - OAuth2Security: - - ap:gateway_custom_policy:read - - ap:gateway_custom_policy:manage + - ap:mcp_proxy:build:read + - ap:mcp_proxy:build:manage + - ap:mcp_proxy:manage tags: - - Gateway Policies + - MCP Proxy Deployments + - Deployments parameters: - - name: gatewayCustomPolicyId + - name: mcpProxyId in: path required: true schema: type: string - format: uuid - description: UUID of the custom policy record - example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" - - name: version + description: Identifier of the MCP proxy + - name: buildId in: path required: true schema: type: string - description: Version of the custom policy (e.g. "1.0.0") - example: "1.0.0" + description: Identifier of the build responses: '200': - description: Custom policy retrieved successfully + description: Build metadata retrieved successfully content: application/json: schema: - $ref: '#/components/schemas/CustomPolicyResponse' + $ref: '#/components/schemas/BuildResponse' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' + delete: - summary: Delete a specific custom policy version + summary: Delete a build description: | - Deletes a custom policy by its UUID and version. The policy must not be in use by any APIs. - operationId: DeleteGatewayCustomPolicy + Deletes one of the MCP proxy's builds, freeing a slot when the API is at its build + limit. + + Refused with a conflict while a gateway is serving the build — that is, while + any `DEPLOYED`, `DEPLOYING` or `UNDEPLOYING` deployment runs it. Undeploy it + first. + + Undeployed, failed and superseded deployments release the build. They keep the + artifact they were created with, so they can still be redeployed, but they stop + reporting a `buildId` and can no longer be promoted to a later environment. + + Access is validated against the organization in the JWT token. + operationId: DeleteMCPProxyBuild security: - OAuth2Security: - - ap:gateway_custom_policy:delete - - ap:gateway_custom_policy:manage + - ap:mcp_proxy:build:delete + - ap:mcp_proxy:build:manage + - ap:mcp_proxy:manage tags: - - Gateway Policies + - MCP Proxy Deployments + - Deployments parameters: - - name: gatewayCustomPolicyId + - name: mcpProxyId in: path required: true schema: type: string - format: uuid - description: UUID of the custom policy record - example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" - - name: version + description: Identifier of the MCP proxy + - name: buildId in: path required: true schema: type: string - description: Version of the custom policy (e.g. "1.0.0") - example: "1.0.0" + description: Identifier of the build responses: '204': - description: Custom policy deleted successfully + description: Build deleted successfully '401': $ref: '#/components/responses/Unauthorized' '403': @@ -5155,73 +5210,94 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /applications: + /mcp-proxies/{mcpProxyId}/deployments: post: - summary: Create a new application + summary: Create and deploy a new deployment for MCP proxy description: | - Creates a new application within the organization specified in the JWT token. - operationId: CreateApplication + Creates an immutable deployment artifact for an MCP proxy and deploys it to a specified gateway. + Each deployment targets a single gateway. The id parameter is the MCP proxy handle (identifier), + not the UUID. The operation returns a transitional DEPLOYING status. Final success or failure will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. + Access is validated against the organization in the JWT token. + operationId: DeployMCPProxy security: - OAuth2Security: - - ap:application:create - - ap:application:manage + - ap:mcp_proxy:deployment:create + - ap:mcp_proxy:deployment:manage + - ap:mcp_proxy:manage tags: - - Applications + - MCP Proxy Deployments + - Deployments + parameters: + - name: mcpProxyId + in: path + required: true + description: Unique identifier of the MCP proxy + schema: + type: string requestBody: + description: Deployment request with gateway ID, base reference, and metadata required: true content: application/json: schema: - $ref: '#/components/schemas/CreateApplicationRequest' + $ref: '#/components/schemas/DeployRequest' responses: '201': - description: Application created successfully + description: MCP proxy deployed successfully headers: Location: $ref: '#/components/headers/Location' content: application/json: schema: - $ref: '#/components/schemas/Application' + $ref: '#/components/schemas/DeploymentResponse' + examples: + default: + $ref: '#/components/examples/DeploymentDeploying' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' get: - summary: Get applications for current user's organization + summary: Get deployments for an MCP proxy description: | - Retrieves applications belonging to the organization specified in the JWT token. - Filters by project using the required `projectId` query parameter. - operationId: ListApplications + Retrieves all deployment artifacts for a specific MCP proxy. The id parameter is the MCP proxy handle (identifier), + not the UUID. Supports filtering by gateway handle and deployment status. + Access is validated against the organization in the JWT token. + operationId: GetMCPProxyDeployments security: - OAuth2Security: - - ap:application:read - - ap:application:manage + - ap:mcp_proxy:deployment:read + - ap:mcp_proxy:deployment:manage + - ap:mcp_proxy:manage tags: - - Applications - parameters: - - $ref: '#/components/parameters/projectId-Q' + - MCP Proxy Deployments + - Deployments + parameters: + - name: mcpProxyId + in: path + required: true + description: Unique identifier of the MCP proxy + schema: + type: string + - $ref: '#/components/parameters/gatewayId-Q' + - $ref: '#/components/parameters/deploymentStatus-Q' - $ref: '#/components/parameters/limit-Q' - $ref: '#/components/parameters/offset-Q' - - $ref: '#/components/parameters/sortBy-Q' - - $ref: '#/components/parameters/sortOrder-Q' - - $ref: '#/components/parameters/query-Q' responses: '200': - description: Applications retrieved successfully + description: Deployments retrieved successfully content: application/json: schema: - $ref: '#/components/schemas/ApplicationListResponse' + $ref: '#/components/schemas/DeploymentListResponse' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '404': @@ -5229,202 +5305,268 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /applications/{applicationId}: + /mcp-proxies/{mcpProxyId}/deployments/{deploymentId}: get: - summary: Get application by handle + summary: Get deployment by ID description: | - Retrieves a specific application by handle. Access is validated against - the organization in the JWT token. - operationId: GetApplication + Retrieves metadata for a specific deployment artifact including status, gateway association, + and timestamps. Access is validated against the organization in the JWT token. + operationId: GetMCPProxyDeployment security: - OAuth2Security: - - ap:application:read - - ap:application:manage + - ap:mcp_proxy:deployment:read + - ap:mcp_proxy:deployment:manage + - ap:mcp_proxy:manage tags: - - Applications + - MCP Proxy Deployments + - Deployments parameters: - - $ref: '#/components/parameters/appId' + - name: mcpProxyId + in: path + required: true + description: Unique identifier of the MCP proxy + schema: + type: string + - $ref: '#/components/parameters/deploymentId' responses: '200': - description: Application retrieved successfully + description: Deployment metadata retrieved successfully content: application/json: schema: - $ref: '#/components/schemas/Application' - '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/schemas/DeploymentResponse' '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - - put: - summary: Update application + delete: + summary: Delete deployment description: | - Updates an existing application by handle. - operationId: UpdateApplication + Deletes a deployment artifact. Deletion is only allowed when the deployment is in UNDEPLOYED status. + Access is validated against the organization in the JWT token. + operationId: DeleteMCPProxyDeployment security: - OAuth2Security: - - ap:application:update - - ap:application:manage + - ap:mcp_proxy:deployment:delete + - ap:mcp_proxy:deployment:manage + - ap:mcp_proxy:manage tags: - - Applications + - MCP Proxy Deployments + - Deployments parameters: - - $ref: '#/components/parameters/appId' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Application' + - name: mcpProxyId + in: path + required: true + description: Unique identifier of the MCP proxy + schema: + type: string + - $ref: '#/components/parameters/deploymentId' responses: - '200': - description: Application updated successfully - content: - application/json: - schema: - $ref: '#/components/schemas/Application' + '204': + description: Deployment deleted successfully '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': - $ref: '#/components/responses/Conflict' + $ref: '#/components/responses/DeploymentActiveConflict' '500': $ref: '#/components/responses/InternalServerError' - delete: - summary: Delete application + /mcp-proxies/{mcpProxyId}/deployments/{deploymentId}/undeploy: + post: + summary: Undeploy deployment from gateway description: | - Deletes an existing application by handle. - operationId: DeleteApplication + Undeploys an active deployment, stopping the MCP proxy from being served on the specified gateway. + The deployment artifact remains in the system and can be restored later. + Returns the updated deployment object with initial status UNDEPLOYING. Final status (UNDEPLOYED or FAILED) will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. + + The gatewayId query parameter is validated against the deployment's bound gateway to prevent unintended operations. + Access is validated against the organization in the JWT token. + operationId: UndeployMCPProxyDeployment security: - OAuth2Security: - - ap:application:delete - - ap:application:manage + - ap:mcp_proxy:deployment:undeploy + - ap:mcp_proxy:deployment:manage + - ap:mcp_proxy:manage tags: - - Applications + - MCP Proxy Deployments + - Deployments parameters: - - $ref: '#/components/parameters/appId' + - name: mcpProxyId + in: path + required: true + description: Unique identifier of the MCP proxy + schema: + type: string + - name: deploymentId + in: path + required: true + schema: + type: string + description: UUID of the deployment to undeploy + - name: gatewayId + in: query + required: true + schema: + type: string + pattern: '^[a-z0-9-]+$' + minLength: 3 + maxLength: 63 + description: Handle (URL-friendly slug) of the gateway (validated against deployment's bound gateway) responses: - '204': - description: Application deleted successfully + '200': + description: Undeploy initiated successfully. Returns the deployment with initial status UNDEPLOYING. Poll status for final result. + content: + application/json: + schema: + $ref: '#/components/schemas/DeploymentResponse' + examples: + default: + $ref: '#/components/examples/DeploymentUndeploying' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' - /applications/{applicationId}/api-keys: - get: - summary: List application API key mappings + /mcp-proxies/{mcpProxyId}/deployments/{deploymentId}/restore: + post: + summary: Restore a previous deployment description: | - Lists all API keys mapped to the specified application. - operationId: ListApplicationAPIKeys + Initiates restoring a previous deployment (ARCHIVED or UNDEPLOYED) on the specified gateway. + Returns the deployment with initial status DEPLOYING. Final success or failure will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. + The target deployment must not already be in DEPLOYED status. + + The gatewayId query parameter is validated against the deployment's bound gateway to prevent unintended operations. + Access is validated against the organization in the JWT token. + operationId: RestoreMCPProxyDeployment security: - OAuth2Security: - - ap:application:api_key:read - - ap:application:api_key:manage - - ap:application:manage - - ap:api_key:all:manage + - ap:mcp_proxy:deployment:restore + - ap:mcp_proxy:deployment:manage + - ap:mcp_proxy:manage tags: - - Applications - - API Keys + - MCP Proxy Deployments + - Deployments parameters: - - $ref: '#/components/parameters/appId' - - $ref: '#/components/parameters/limit-Q' - - $ref: '#/components/parameters/offset-Q' + - name: mcpProxyId + in: path + required: true + description: Unique identifier of the MCP proxy + schema: + type: string + - name: deploymentId + in: path + required: true + schema: + type: string + description: UUID of the deployment to restore (must be ARCHIVED or UNDEPLOYED) + - name: gatewayId + in: query + required: true + schema: + type: string + pattern: '^[a-z0-9-]+$' + minLength: 3 + maxLength: 63 + description: Handle (URL-friendly slug) of the gateway (validated against deployment's bound gateway) responses: '200': - description: Mapped API keys retrieved successfully + description: Restore initiated successfully. Returns the deployment with initial status DEPLOYING. Poll status for final result. content: application/json: schema: - $ref: '#/components/schemas/MappedAPIKeyListResponse' + $ref: '#/components/schemas/DeploymentResponse' + examples: + default: + $ref: '#/components/examples/DeploymentDeploying' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' + /mcp-proxies/fetch-server-info: post: - summary: Add application API key mappings + summary: Fetch server info from MCP proxy backend services description: | - Adds API key mappings to the specified application. - operationId: AddApplicationAPIKeys + Fetches server information from the backend services of an MCP proxy. + This is used to validate connectivity and retrieve metadata about the backend services. + operationId: fetchMCPProxyServerInfo security: - OAuth2Security: - - ap:application:api_key:create - - ap:application:api_key:manage - - ap:application:manage - - ap:api_key:all:manage + - ap:mcp_proxy:read + - ap:mcp_proxy:manage tags: - - Applications - - API Keys - parameters: - - $ref: '#/components/parameters/appId' + - MCP Proxies requestBody: + description: Target MCP server to introspect — either a direct `url` (with optional `auth`), or a `proxyId` to refetch using a stored proxy configuration. required: true content: application/json: schema: - $ref: '#/components/schemas/AddApplicationAPIKeysRequest' + $ref: '#/components/schemas/MCPServerInfoFetchRequest' responses: '200': - description: API key mappings added successfully + description: Server info retrieved successfully content: application/json: schema: - $ref: '#/components/schemas/MappedAPIKeyListResponse' + $ref: '#/components/schemas/MCPServerInfoFetchResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - /applications/{applicationId}/api-keys/{apiKeyId}: - delete: - summary: Remove application API key mapping + /gateways: + post: + summary: Register a new gateway description: | - Removes a mapped API key from the specified application. - operationId: RemoveApplicationAPIKey + Creates a new gateway within the organization specified in the JWT token. + Organization ID is automatically extracted from the token and does not need to be provided. + operationId: CreateGateway security: - OAuth2Security: - - ap:application:api_key:delete - - ap:application:api_key:manage - - ap:application:manage - - ap:api_key:all:manage + - ap:gateway:create + - ap:gateway:manage tags: - - Applications - - API Keys - parameters: - - $ref: '#/components/parameters/appId' - - $ref: '#/components/parameters/mappedKeyId' - - $ref: '#/components/parameters/entityID-Q' + - Gateways + requestBody: + description: Gateway registration details + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateGatewayRequest' responses: - '204': - description: API key mapping removed successfully + '201': + description: Gateway registered successfully + headers: + Location: + $ref: '#/components/headers/Location' + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayResponse' '400': $ref: '#/components/responses/BadRequest' '401': @@ -5433,33 +5575,65 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' - /applications/{applicationId}/associations: get: - summary: List application associations + summary: List all gateways description: | - Lists association targets mapped to the specified application. - operationId: ListApplicationAssociations + Retrieves a list of all registered gateways for the organization specified in the JWT token. + Organization ID is automatically extracted from the token. + operationId: ListGateways security: - OAuth2Security: - - ap:application:association:read - - ap:application:association:manage - - ap:application:manage + - ap:gateway:read + - ap:gateway:manage tags: - - Applications + - Gateways parameters: - - $ref: '#/components/parameters/appId' - $ref: '#/components/parameters/limit-Q' - $ref: '#/components/parameters/offset-Q' + - $ref: '#/components/parameters/sortBy-Q' + - $ref: '#/components/parameters/sortOrder-Q' + - $ref: '#/components/parameters/query-Q' responses: '200': - description: Application associations retrieved successfully + description: Gateways retrieved successfully content: application/json: schema: - $ref: '#/components/schemas/ApplicationAssociationListResponse' + $ref: '#/components/schemas/GatewayListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + + /gateways/{gatewayId}: + get: + summary: Get gateway by ID + description: | + Retrieves a specific gateway by its ID (handle). Access is validated against the organization + in the JWT token. + operationId: GetGateway + security: + - OAuth2Security: + - ap:gateway:read + - ap:gateway:manage + tags: + - Gateways + parameters: + - $ref: '#/components/parameters/gatewayId' + responses: + '200': + description: Gateway retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayResponse' '400': $ref: '#/components/responses/BadRequest' '401': @@ -5469,33 +5643,34 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - post: - summary: Add application associations + put: + summary: Update gateway description: | - Adds association targets to the specified application. - operationId: AddApplicationAssociations + Updates an existing gateway's mutable fields (description). + Access is validated against the organization in the JWT token. + operationId: UpdateGateway security: - OAuth2Security: - - ap:application:association:create - - ap:application:association:manage - - ap:application:manage + - ap:gateway:update + - ap:gateway:manage tags: - - Applications + - Gateways parameters: - - $ref: '#/components/parameters/appId' + - $ref: '#/components/parameters/gatewayId' requestBody: + description: Gateway object that needs to be updated required: true content: application/json: schema: - $ref: '#/components/schemas/AddApplicationAssociationsRequest' + $ref: '#/components/schemas/GatewayResponse' responses: '200': - description: Application associations added successfully + description: Gateway updated successfully content: application/json: schema: - $ref: '#/components/schemas/ApplicationAssociationListResponse' + $ref: '#/components/schemas/GatewayResponse' '400': $ref: '#/components/responses/BadRequest' '401': @@ -5507,25 +5682,24 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /applications/{applicationId}/associations/{associationId}: delete: - summary: Remove application association + summary: Delete gateway description: | - Removes an association target from the specified application. - operationId: RemoveApplicationAssociation + Permanently deletes a gateway and all associated tokens (CASCADE). + Deletion is blocked if the gateway has active API deployments or WebSocket connections. + Access is validated against the organization in the JWT token. + operationId: DeleteGateway security: - OAuth2Security: - - ap:application:association:delete - - ap:application:association:manage - - ap:application:manage + - ap:gateway:delete + - ap:gateway:manage tags: - - Applications + - Gateways parameters: - - $ref: '#/components/parameters/appId' - - $ref: '#/components/parameters/associationId' + - $ref: '#/components/parameters/gatewayId' responses: '204': - description: Application association removed successfully + description: Gateway deleted successfully '400': $ref: '#/components/responses/BadRequest' '401': @@ -5534,166 +5708,225 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' - /applications/{applicationId}/associations/{associationId}/api-keys: + /gateways/{gatewayId}/tokens: get: - summary: List application API key mappings for an association + summary: List active gateway tokens description: | - Lists API keys mapped to the specified application for the given associated target. - operationId: ListApplicationAssociationAPIKeys + Returns all active tokens for the specified gateway. Token hashes and salts are never exposed. + Access is validated against the organization in the JWT token. + operationId: listGatewayTokens security: - OAuth2Security: - - ap:application:association:api_key:read - - ap:application:association:manage - - ap:application:manage - - ap:api_key:all:manage + - ap:gateway:token:read + - ap:gateway:token:manage + - ap:gateway:manage tags: - - Applications - - API Keys + - Gateway Tokens parameters: - - $ref: '#/components/parameters/appId' - - $ref: '#/components/parameters/associationId' + - $ref: '#/components/parameters/gatewayId' - $ref: '#/components/parameters/limit-Q' - $ref: '#/components/parameters/offset-Q' responses: '200': - description: Mapped API keys retrieved successfully + description: List of active tokens content: application/json: schema: - $ref: '#/components/schemas/MappedAPIKeyListResponse' - '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/schemas/GatewayTokenListResponse' '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - - /subscription-plans: post: - summary: Create subscription plan - description: Creates an organization-scoped subscription plan. - operationId: CreateSubscriptionPlan + summary: Rotate gateway token + description: | + Generates a new authentication token for the gateway. The existing token remains active + to enable zero-downtime rotation. Access is validated against the organization in the JWT token. + operationId: rotateGatewayToken security: - OAuth2Security: - - ap:subscription_plan:create - - ap:subscription_plan:manage + - ap:gateway:token:create + - ap:gateway:token:manage + - ap:gateway:manage tags: - - SubscriptionPlans - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateSubscriptionPlanRequest' + - Gateway Tokens + parameters: + - $ref: '#/components/parameters/gatewayId' responses: '201': - description: Subscription plan created successfully + description: New token generated successfully headers: Location: $ref: '#/components/headers/Location' content: application/json: schema: - $ref: '#/components/schemas/SubscriptionPlan' + $ref: '#/components/schemas/TokenRotationResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '409': - $ref: '#/components/responses/Conflict' + '404': + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - get: - summary: List subscription plans - description: Returns subscription plans for the organization. - operationId: ListSubscriptionPlans + + /gateways/{gatewayId}/tokens/{tokenId}: + delete: + summary: Revoke gateway token + description: | + Revokes a specific gateway token. Operation is idempotent - revoking an already-revoked + token succeeds. Access is validated against the organization in the JWT token. + operationId: revokeGatewayToken security: - OAuth2Security: - - ap:subscription_plan:read - - ap:subscription_plan:manage + - ap:gateway:token:delete + - ap:gateway:token:manage + - ap:gateway:manage tags: - - SubscriptionPlans + - Gateway Tokens parameters: - - $ref: '#/components/parameters/limit-Q' - - $ref: '#/components/parameters/offset-Q' + - $ref: '#/components/parameters/gatewayId' + - $ref: '#/components/parameters/tokenId' responses: '200': - description: List of subscription plans + description: Token revoked successfully content: application/json: schema: - $ref: '#/components/schemas/SubscriptionPlanListResponse' + type: object + properties: + message: + type: string + example: "Token revoked successfully" + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - /subscription-plans/{subscriptionPlanId}: + /gateways/{gatewayId}/manifest: get: - summary: Get subscription plan by ID - operationId: GetSubscriptionPlan + summary: Get gateway policy manifest + description: | + Returns the policy manifest for the specified gateway. The manifest is populated by the gateway + controller when it connects to the platform API, and contains all installed policies. Custom + policies additionally include their full policy definition schema. + operationId: GetGatewayManifest security: - OAuth2Security: - - ap:subscription_plan:read - - ap:subscription_plan:manage + - ap:gateway:manifest:read + - ap:gateway:manage tags: - - SubscriptionPlans + - Gateways parameters: - - name: subscriptionPlanId - in: path - required: true - schema: - type: string - maxLength: 50 + - $ref: '#/components/parameters/gatewayId' responses: '200': - description: Subscription plan details + description: Gateway policy manifest content: application/json: schema: - $ref: '#/components/schemas/SubscriptionPlan' + $ref: '#/components/schemas/ManifestSyncResponse' '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - put: - summary: Update subscription plan - operationId: UpdateSubscriptionPlan + + /gateway-custom-policies: + get: + summary: Get synced custom policies for the current organization + description: | + Returns all custom policies synced to the current organization (from the JWT `organization` claim). + operationId: ListGatewayCustomPolicies security: - OAuth2Security: - - ap:subscription_plan:update - - ap:subscription_plan:manage + - ap:gateway_custom_policy:read + - ap:gateway_custom_policy:manage tags: - - SubscriptionPlans + - Gateway Policies parameters: - - name: subscriptionPlanId - in: path + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' + responses: + '200': + description: List of custom policies + content: + application/json: + schema: + $ref: '#/components/schemas/CustomPolicyListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + + /gateway-custom-policies/sync: + post: + summary: Sync a custom policy from the gateway manifest + description: | + Syncs a custom policy from the gateway manifest into the organization's custom policy registry. + Version-based rules apply: + - **New major version**: creates a new policy record (e.g. v1.x.x and v2.x.x coexist). + - **New minor version** (same major): updates the existing record (e.g. v1.1.0 → v1.2.0). + - **Patch version change** (same major.minor): not allowed. + - **Downgrade**: not allowed. + Policy names are case-insensitive. + After syncing, the policy can be applied to APIs in the organization. + operationId: SyncCustomPolicy + security: + - OAuth2Security: + - ap:gateway_custom_policy:create + - ap:gateway_custom_policy:manage + tags: + - Gateway Policies + parameters: + - name: gatewayId + in: query required: true schema: type: string - maxLength: 50 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SubscriptionPlan' + minLength: 3 + maxLength: 40 + readOnly: true + description: Handle (URL-friendly slug) of the gateway whose manifest contains the policy + example: "prod-gateway-01" + - name: policyName + in: query + required: true + schema: + type: string + description: Name of the custom policy (case-insensitive) + example: "set-wso2-headers" + - name: policyVersion + in: query + required: true + schema: + type: string + description: Version of the custom policy in MAJOR.MINOR.PATCH format + example: "1.0.0" responses: '200': - description: Updated subscription plan + description: Custom policy synced successfully content: application/json: schema: - $ref: '#/components/schemas/SubscriptionPlan' + $ref: '#/components/schemas/CustomPolicyResponse' '400': $ref: '#/components/responses/BadRequest' '401': @@ -5704,65 +5937,128 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '422': + description: Policy is not a custom policy or manifest is unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + status: error + code: POLICY_INVALID_STATE + message: "The policy is not a custom policy, or its manifest is unavailable." + '500': + $ref: '#/components/responses/InternalServerError' + + /gateway-custom-policies/{gatewayCustomPolicyId}/versions/{version}: + get: + summary: Get a specific custom policy version + description: Returns a custom policy by its UUID and version for the current organization. + operationId: GetGatewayCustomPolicy + security: + - OAuth2Security: + - ap:gateway_custom_policy:read + - ap:gateway_custom_policy:manage + tags: + - Gateway Policies + parameters: + - name: gatewayCustomPolicyId + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the custom policy record + example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + - name: version + in: path + required: true + schema: + type: string + description: Version of the custom policy (e.g. "1.0.0") + example: "1.0.0" + responses: + '200': + description: Custom policy retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CustomPolicyResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' delete: - summary: Delete subscription plan - operationId: DeleteSubscriptionPlan + summary: Delete a specific custom policy version + description: | + Deletes a custom policy by its UUID and version. The policy must not be in use by any APIs. + operationId: DeleteGatewayCustomPolicy security: - OAuth2Security: - - ap:subscription_plan:delete - - ap:subscription_plan:manage + - ap:gateway_custom_policy:delete + - ap:gateway_custom_policy:manage tags: - - SubscriptionPlans + - Gateway Policies parameters: - - name: subscriptionPlanId + - name: gatewayCustomPolicyId in: path required: true schema: type: string - maxLength: 50 + format: uuid + description: UUID of the custom policy record + example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + - name: version + in: path + required: true + schema: + type: string + description: Version of the custom policy (e.g. "1.0.0") + example: "1.0.0" responses: '204': - description: Subscription plan deleted (no content) + description: Custom policy deleted successfully '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' - /subscriptions: + /applications: post: - summary: Create subscription + summary: Create a new application description: | - Creates a subscription for the specified artifact. - `subscriberId` identifies the unique subscriber for this artifact, allowing multiple subscribers to create subscriptions for the same artifact. - operationId: CreateSubscription + Creates a new application within the organization specified in the JWT token. + operationId: CreateApplication security: - OAuth2Security: - - ap:subscription:create - - ap:subscription:manage + - ap:application:create + - ap:application:manage tags: - - Subscriptions + - Applications requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/CreateSubscriptionRequest' + $ref: '#/components/schemas/CreateApplicationRequest' responses: '201': - description: Subscription created successfully + description: Application created successfully headers: Location: $ref: '#/components/headers/Location' content: application/json: schema: - $ref: '#/components/schemas/Subscription' + $ref: '#/components/schemas/Application' '400': $ref: '#/components/responses/BadRequest' '401': @@ -5775,57 +6071,33 @@ paths: $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' + get: - summary: List subscriptions + summary: Get applications for current user's organization description: | - Returns subscriptions filtered by artifact and/or application. - Optional query parameters artifactId, subscriberId, applicationId and status filter the list. - Supports pagination via limit and offset. - operationId: ListSubscriptions + Retrieves applications belonging to the organization specified in the JWT token. + Filters by project using the required `projectId` query parameter. + operationId: ListApplications security: - OAuth2Security: - - ap:subscription:read - - ap:subscription:manage + - ap:application:read + - ap:application:manage tags: - - Subscriptions + - Applications parameters: - - name: artifactId - in: query - required: false - description: Filter by artifact ID (UUID or handle) - schema: - type: string - - name: subscriberId - in: query - required: false - description: Filter by subscriber ID - schema: - type: string - minLength: 1 - - name: applicationId - in: query - required: false - description: Filter by application ID - schema: - type: string - - name: status - in: query - required: false - description: Filter by status (ACTIVE, INACTIVE, REVOKED) - schema: - type: string - enum: [ACTIVE, INACTIVE, REVOKED] + - $ref: '#/components/parameters/projectId-Q' - $ref: '#/components/parameters/limit-Q' - $ref: '#/components/parameters/offset-Q' + - $ref: '#/components/parameters/sortBy-Q' + - $ref: '#/components/parameters/sortOrder-Q' + - $ref: '#/components/parameters/query-Q' responses: '200': - description: List of subscriptions + description: Applications retrieved successfully content: application/json: schema: - $ref: '#/components/schemas/SubscriptionListResponse' - '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/schemas/ApplicationListResponse' '401': $ref: '#/components/responses/Unauthorized' '404': @@ -5833,83 +6105,63 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /subscriptions/{subscriptionId}: + /applications/{applicationId}: get: - summary: Get subscription by ID + summary: Get application by handle description: | - Returns a single subscription by ID, scoped to the organization in the access token. - operationId: GetSubscription + Retrieves a specific application by handle. Access is validated against + the organization in the JWT token. + operationId: GetApplication security: - OAuth2Security: - - ap:subscription:read - - ap:subscription:manage + - ap:application:read + - ap:application:manage tags: - - Subscriptions + - Applications parameters: - - name: subscriptionId - in: path - required: true - description: Subscription UUID - schema: - type: string - format: uuid + - $ref: '#/components/parameters/appId' responses: '200': - description: Subscription details + description: Application retrieved successfully content: application/json: schema: - $ref: '#/components/schemas/Subscription' + $ref: '#/components/schemas/Application' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' + put: - summary: Update subscription + summary: Update application description: | - Updates a subscription (e.g. status). - Query parameter `subscriberId` is required and must match the subscription's subscriber for access control. - operationId: UpdateSubscription + Updates an existing application by handle. + operationId: UpdateApplication security: - OAuth2Security: - - ap:subscription:update - - ap:subscription:manage + - ap:application:update + - ap:application:manage tags: - - Subscriptions + - Applications parameters: - - name: subscriptionId - in: path - required: true - description: Subscription UUID - schema: - type: string - format: uuid - - name: subscriberId - in: query - required: true - description: Subscriber ID; must match the subscription's subscriberId. - schema: - type: string - minLength: 1 + - $ref: '#/components/parameters/appId' requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/Subscription' + $ref: '#/components/schemas/Application' responses: '200': - description: Updated subscription + description: Application updated successfully content: application/json: schema: - $ref: '#/components/schemas/Subscription' + $ref: '#/components/schemas/Application' '400': $ref: '#/components/responses/BadRequest' '401': @@ -5918,38 +6170,64 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' + delete: - summary: Delete subscription + summary: Delete application description: | - Removes the subscription for the API. - Query parameter `subscriberId` is required and must match the subscription's subscriber for access control. - operationId: DeleteSubscription + Deletes an existing application by handle. + operationId: DeleteApplication security: - OAuth2Security: - - ap:subscription:delete - - ap:subscription:manage + - ap:application:delete + - ap:application:manage tags: - - Subscriptions + - Applications parameters: - - name: subscriptionId - in: path - required: true - description: Subscription UUID - schema: - type: string - format: uuid - - name: subscriberId - in: query - required: true - description: Subscriber ID; must match the subscription's subscriberId. - schema: - type: string - minLength: 1 + - $ref: '#/components/parameters/appId' responses: '204': - description: Subscription deleted (no content) + description: Application deleted successfully + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /applications/{applicationId}/api-keys: + get: + summary: List application API key mappings + description: | + Lists all API keys mapped to the specified application. + operationId: ListApplicationAPIKeys + security: + - OAuth2Security: + - ap:application:api_key:read + - ap:application:api_key:manage + - ap:application:manage + - ap:api_key:all:manage + tags: + - Applications + - API Keys + parameters: + - $ref: '#/components/parameters/appId' + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' + responses: + '200': + description: Mapped API keys retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/MappedAPIKeyListResponse' '400': $ref: '#/components/responses/BadRequest' '401': @@ -5961,97 +6239,103 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /api-portals: post: - summary: Create an API Portal + summary: Add application API key mappings description: | - Registers a new API Portal in the caller's organization against an - existing portal URL. The URL is required. Organization ID is extracted - from the JWT token. - operationId: CreateApiPortal + Adds API key mappings to the specified application. + operationId: AddApplicationAPIKeys security: - OAuth2Security: - - ap:api_portal:create - - ap:api_portal:manage + - ap:application:api_key:create + - ap:application:api_key:manage + - ap:application:manage + - ap:api_key:all:manage tags: - - API Portals + - Applications + - API Keys + parameters: + - $ref: '#/components/parameters/appId' requestBody: - description: API Portal registration details required: true content: application/json: schema: - $ref: '#/components/schemas/CreateApiPortalRequest' + $ref: '#/components/schemas/AddApplicationAPIKeysRequest' responses: - '201': - description: API Portal created successfully - headers: - Location: - $ref: '#/components/headers/Location' + '200': + description: API key mappings added successfully content: application/json: schema: - $ref: '#/components/schemas/ApiPortalResponse' + $ref: '#/components/schemas/MappedAPIKeyListResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '409': - $ref: '#/components/responses/Conflict' + '404': + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - get: - summary: List API Portals - description: Lists API Portals in the org resolved from the JWT token. - operationId: ListApiPortals + + /applications/{applicationId}/api-keys/{apiKeyId}: + delete: + summary: Remove application API key mapping + description: | + Removes a mapped API key from the specified application. + operationId: RemoveApplicationAPIKey security: - OAuth2Security: - - ap:api_portal:read - - ap:api_portal:manage + - ap:application:api_key:delete + - ap:application:api_key:manage + - ap:application:manage + - ap:api_key:all:manage tags: - - API Portals + - Applications + - API Keys parameters: - - $ref: '#/components/parameters/limit-Q' - - $ref: '#/components/parameters/offset-Q' - - $ref: '#/components/parameters/sortBy-Q' - - $ref: '#/components/parameters/sortOrder-Q' - - $ref: '#/components/parameters/query-Q' + - $ref: '#/components/parameters/appId' + - $ref: '#/components/parameters/mappedKeyId' + - $ref: '#/components/parameters/entityID-Q' responses: - '200': - description: API Portals retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/ApiPortalListResponse' + '204': + description: API key mapping removed successfully '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - /api-portals/{apiPortalId}: + /applications/{applicationId}/associations: get: - summary: Get API Portal by ID - description: Reads a single API Portal by its handle. Access is validated against the org in the JWT token. - operationId: GetApiPortal + summary: List application associations + description: | + Lists association targets mapped to the specified application. + operationId: ListApplicationAssociations security: - OAuth2Security: - - ap:api_portal:read - - ap:api_portal:manage + - ap:application:association:read + - ap:application:association:manage + - ap:application:manage tags: - - API Portals + - Applications parameters: - - $ref: '#/components/parameters/apiPortalId' + - $ref: '#/components/parameters/appId' + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': - description: API Portal retrieved successfully + description: Application associations retrieved successfully content: application/json: schema: - $ref: '#/components/schemas/ApiPortalResponse' + $ref: '#/components/schemas/ApplicationAssociationListResponse' '400': $ref: '#/components/responses/BadRequest' '401': @@ -6060,34 +6344,34 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - put: - summary: Update API Portal + + post: + summary: Add application associations description: | - Updates mutable fields on an API Portal. The server ignores any immutable - field appearing in the body. Access is validated against the org in the JWT token. - operationId: UpdateApiPortal + Adds association targets to the specified application. + operationId: AddApplicationAssociations security: - OAuth2Security: - - ap:api_portal:update - - ap:api_portal:manage + - ap:application:association:create + - ap:application:association:manage + - ap:application:manage tags: - - API Portals + - Applications parameters: - - $ref: '#/components/parameters/apiPortalId' + - $ref: '#/components/parameters/appId' requestBody: - description: API Portal fields to update required: true content: application/json: schema: - $ref: '#/components/schemas/UpdateApiPortalRequest' + $ref: '#/components/schemas/AddApplicationAssociationsRequest' responses: '200': - description: API Portal updated successfully + description: Application associations added successfully content: application/json: schema: - $ref: '#/components/schemas/ApiPortalResponse' + $ref: '#/components/schemas/ApplicationAssociationListResponse' '400': $ref: '#/components/responses/BadRequest' '401': @@ -6098,24 +6382,26 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' + + /applications/{applicationId}/associations/{associationId}: delete: - summary: Delete API Portal + summary: Remove application association description: | - Deletes the API Portal registration and purges the encrypted shared key. The remote - portal instance is not touched; operators are responsible for its lifecycle. Access - is validated against the org in the JWT token. - operationId: DeleteApiPortal + Removes an association target from the specified application. + operationId: RemoveApplicationAssociation security: - OAuth2Security: - - ap:api_portal:delete - - ap:api_portal:manage - tags: - - API Portals + - ap:application:association:delete + - ap:application:association:manage + - ap:application:manage + tags: + - Applications parameters: - - $ref: '#/components/parameters/apiPortalId' + - $ref: '#/components/parameters/appId' + - $ref: '#/components/parameters/associationId' responses: '204': - description: API Portal deleted successfully + description: Application association removed successfully '400': $ref: '#/components/responses/BadRequest' '401': @@ -6127,85 +6413,69 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - /me/api-keys: + /applications/{applicationId}/associations/{associationId}/api-keys: get: - summary: List API keys for the current user, or for all users with `ap:api_key:all:manage` + summary: List application API key mappings for an association description: | - Returns API keys created by the caller within the organization. - Callers holding the `ap:api_key:all:manage` scope instead receive every user's API keys - in the organization; the `createdBy` field identifies each key's creator. - Optionally filter by one or more artifact types using a comma-separated `type` query parameter. - The plain key value is never returned. - operationId: listUserAPIKeys + Lists API keys mapped to the specified application for the given associated target. + operationId: ListApplicationAssociationAPIKeys security: - OAuth2Security: - - ap:api_key:read + - ap:application:association:api_key:read + - ap:application:association:manage + - ap:application:manage - ap:api_key:all:manage tags: + - Applications - API Keys parameters: - - name: type - in: query - description: | - Comma-separated list of artifact types to filter by. - If omitted, all types are returned. - required: false - schema: - type: array - items: - type: string - enum: [RestApi, LlmProvider, LlmProxy] - style: form - explode: false - example: LlmProxy,LlmProvider + - $ref: '#/components/parameters/appId' + - $ref: '#/components/parameters/associationId' - $ref: '#/components/parameters/limit-Q' - $ref: '#/components/parameters/offset-Q' responses: '200': - description: List of API keys retrieved successfully + description: Mapped API keys retrieved successfully content: application/json: schema: - $ref: '#/components/schemas/UserAPIKeyListResponse' + $ref: '#/components/schemas/MappedAPIKeyListResponse' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' - /secrets: + /subscription-plans: post: - summary: Create a secret - description: | - Create a new encrypted secret scoped to the organization. The plaintext value is never returned. - operationId: createSecret + summary: Create subscription plan + description: Creates an organization-scoped subscription plan. + operationId: CreateSubscriptionPlan security: - OAuth2Security: - - ap:secret:create - - ap:secret:manage + - ap:subscription_plan:create + - ap:subscription_plan:manage tags: - - Secrets + - SubscriptionPlans requestBody: required: true content: - multipart/form-data: + application/json: schema: - $ref: '#/components/schemas/SecretCreateRequest' - example: - id: wso2-openai-key - displayName: WSO2 OpenAI API Key - description: API key for WSO2 OpenAI integration - value: sk-xxx - type: GENERIC + $ref: '#/components/schemas/CreateSubscriptionPlanRequest' responses: '201': - description: Secret created successfully. + description: Subscription plan created successfully headers: Location: $ref: '#/components/headers/Location' content: application/json: schema: - $ref: '#/components/schemas/SecretResponse' + $ref: '#/components/schemas/SubscriptionPlan' '400': $ref: '#/components/responses/BadRequest' '401': @@ -6214,119 +6484,92 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' - '503': - $ref: '#/components/responses/ServiceUnavailable' '500': $ref: '#/components/responses/InternalServerError' - get: - summary: List secrets - description: | - Returns metadata for all secrets in the organization. The plaintext value is - never included in list or get responses. - operationId: listSecrets + summary: List subscription plans + description: Returns subscription plans for the organization. + operationId: ListSubscriptionPlans security: - OAuth2Security: - - ap:secret:read - - ap:secret:manage + - ap:subscription_plan:read + - ap:subscription_plan:manage tags: - - Secrets + - SubscriptionPlans parameters: - $ref: '#/components/parameters/limit-Q' - $ref: '#/components/parameters/offset-Q' - - name: updatedAfter - in: query - description: RFC3339 timestamp — return only secrets updated after this time. Used by GW controller for incremental polling. - schema: - type: string - format: date-time - example: "2026-01-01T00:00:00Z" responses: '200': - description: List of secret metadata + description: List of subscription plans content: application/json: schema: - $ref: '#/components/schemas/SecretListResponse' - '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/schemas/SubscriptionPlanListResponse' '401': $ref: '#/components/responses/Unauthorized' - '503': - $ref: '#/components/responses/ServiceUnavailable' '500': $ref: '#/components/responses/InternalServerError' - /secrets/{secretId}: + /subscription-plans/{subscriptionPlanId}: get: - summary: Get a secret by handle - description: Returns metadata for a single secret. The plaintext value is never returned. - operationId: getSecret + summary: Get subscription plan by ID + operationId: GetSubscriptionPlan security: - OAuth2Security: - - ap:secret:read - - ap:secret:manage + - ap:subscription_plan:read + - ap:subscription_plan:manage tags: - - Secrets + - SubscriptionPlans parameters: - - name: secretId + - name: subscriptionPlanId in: path required: true - description: The secret handle schema: type: string + maxLength: 50 responses: '200': - description: Secret metadata + description: Subscription plan details content: application/json: schema: - $ref: '#/components/schemas/SecretSummary' + $ref: '#/components/schemas/SubscriptionPlan' '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' - '503': - $ref: '#/components/responses/ServiceUnavailable' '500': $ref: '#/components/responses/InternalServerError' - put: - summary: Rotate a secret value - description: | - Re-encrypts and stores a new value for an existing secret. The handle is immutable - so all `{{ secret "handle" }}` placeholder references across resources remain valid - without modification. - operationId: rotateSecret + summary: Update subscription plan + operationId: UpdateSubscriptionPlan security: - OAuth2Security: - - ap:secret:update - - ap:secret:manage + - ap:subscription_plan:update + - ap:subscription_plan:manage tags: - - Secrets + - SubscriptionPlans parameters: - - name: secretId + - name: subscriptionPlanId in: path required: true - description: The secret handle schema: type: string + maxLength: 50 requestBody: required: true content: - multipart/form-data: + application/json: schema: - $ref: '#/components/schemas/SecretUpdateRequest' - example: - value: sk-new-rotated-value - displayName: WSO2 OpenAI API Key (rotated) + $ref: '#/components/schemas/SubscriptionPlan' responses: '200': - description: Secret rotated successfully. + description: Updated subscription plan content: application/json: schema: - $ref: '#/components/schemas/SecretResponse' + $ref: '#/components/schemas/SubscriptionPlan' '400': $ref: '#/components/responses/BadRequest' '401': @@ -6335,72 +6578,705 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - '503': - $ref: '#/components/responses/ServiceUnavailable' + '409': + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' - delete: - summary: Delete a secret - description: | - Soft-deletes a secret by marking it as DEPRECATED. Returns 409 if the secret is - still referenced by any LLM provider or API configuration. - operationId: deleteSecret + summary: Delete subscription plan + operationId: DeleteSubscriptionPlan security: - OAuth2Security: - - ap:secret:delete - - ap:secret:manage + - ap:subscription_plan:delete + - ap:subscription_plan:manage tags: - - Secrets + - SubscriptionPlans parameters: - - name: secretId + - name: subscriptionPlanId in: path required: true - description: The secret handle schema: type: string + maxLength: 50 responses: '204': - description: Secret deleted successfully + description: Subscription plan deleted (no content) '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - '409': - description: Conflict. The secret is referenced by one or more active resources. + '500': + $ref: '#/components/responses/InternalServerError' + + /subscriptions: + post: + summary: Create subscription + description: | + Creates a subscription for the specified artifact. + `subscriberId` identifies the unique subscriber for this artifact, allowing multiple subscribers to create subscriptions for the same artifact. + operationId: CreateSubscription + security: + - OAuth2Security: + - ap:subscription:create + - ap:subscription:manage + tags: + - Subscriptions + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSubscriptionRequest' + responses: + '201': + description: Subscription created successfully + headers: + Location: + $ref: '#/components/headers/Location' content: application/json: schema: - $ref: '#/components/schemas/Error' - example: - status: error - code: SECRET_IN_USE - message: The secret is referenced by one or more active resources. - details: - references: - - type: llm_provider - handle: wso2-openai-provider - name: WSO2 OpenAI Provider - '503': - $ref: '#/components/responses/ServiceUnavailable' + $ref: '#/components/schemas/Subscription' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' - -components: - headers: - Location: - description: URL of the newly created resource. - schema: - type: string - format: uri - securitySchemes: - OAuth2Security: - type: oauth2 - flows: - clientCredentials: - tokenUrl: https://localhost:9243/oauth2/token + get: + summary: List subscriptions + description: | + Returns subscriptions filtered by artifact and/or application. + Optional query parameters artifactId, subscriberId, applicationId and status filter the list. + Supports pagination via limit and offset. + operationId: ListSubscriptions + security: + - OAuth2Security: + - ap:subscription:read + - ap:subscription:manage + tags: + - Subscriptions + parameters: + - name: artifactId + in: query + required: false + description: Filter by artifact ID (UUID or handle) + schema: + type: string + - name: subscriberId + in: query + required: false + description: Filter by subscriber ID + schema: + type: string + minLength: 1 + - name: applicationId + in: query + required: false + description: Filter by application ID + schema: + type: string + - name: status + in: query + required: false + description: Filter by status (ACTIVE, INACTIVE, REVOKED) + schema: + type: string + enum: [ACTIVE, INACTIVE, REVOKED] + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' + responses: + '200': + description: List of subscriptions + content: + application/json: + schema: + $ref: '#/components/schemas/SubscriptionListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /subscriptions/{subscriptionId}: + get: + summary: Get subscription by ID + description: | + Returns a single subscription by ID, scoped to the organization in the access token. + operationId: GetSubscription + security: + - OAuth2Security: + - ap:subscription:read + - ap:subscription:manage + tags: + - Subscriptions + parameters: + - name: subscriptionId + in: path + required: true + description: Subscription UUID + schema: + type: string + format: uuid + responses: + '200': + description: Subscription details + content: + application/json: + schema: + $ref: '#/components/schemas/Subscription' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + put: + summary: Update subscription + description: | + Updates a subscription (e.g. status). + Query parameter `subscriberId` is required and must match the subscription's subscriber for access control. + operationId: UpdateSubscription + security: + - OAuth2Security: + - ap:subscription:update + - ap:subscription:manage + tags: + - Subscriptions + parameters: + - name: subscriptionId + in: path + required: true + description: Subscription UUID + schema: + type: string + format: uuid + - name: subscriberId + in: query + required: true + description: Subscriber ID; must match the subscription's subscriberId. + schema: + type: string + minLength: 1 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Subscription' + responses: + '200': + description: Updated subscription + content: + application/json: + schema: + $ref: '#/components/schemas/Subscription' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + summary: Delete subscription + description: | + Removes the subscription for the API. + Query parameter `subscriberId` is required and must match the subscription's subscriber for access control. + operationId: DeleteSubscription + security: + - OAuth2Security: + - ap:subscription:delete + - ap:subscription:manage + tags: + - Subscriptions + parameters: + - name: subscriptionId + in: path + required: true + description: Subscription UUID + schema: + type: string + format: uuid + - name: subscriberId + in: query + required: true + description: Subscriber ID; must match the subscription's subscriberId. + schema: + type: string + minLength: 1 + responses: + '204': + description: Subscription deleted (no content) + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /api-portals: + post: + summary: Create an API Portal + description: | + Registers a new API Portal in the caller's organization against an + existing portal URL. The URL is required. Organization ID is extracted + from the JWT token. + operationId: CreateApiPortal + security: + - OAuth2Security: + - ap:api_portal:create + - ap:api_portal:manage + tags: + - API Portals + requestBody: + description: API Portal registration details + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateApiPortalRequest' + responses: + '201': + description: API Portal created successfully + headers: + Location: + $ref: '#/components/headers/Location' + content: + application/json: + schema: + $ref: '#/components/schemas/ApiPortalResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + get: + summary: List API Portals + description: Lists API Portals in the org resolved from the JWT token. + operationId: ListApiPortals + security: + - OAuth2Security: + - ap:api_portal:read + - ap:api_portal:manage + tags: + - API Portals + parameters: + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' + - $ref: '#/components/parameters/sortBy-Q' + - $ref: '#/components/parameters/sortOrder-Q' + - $ref: '#/components/parameters/query-Q' + responses: + '200': + description: API Portals retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ApiPortalListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + + /api-portals/{apiPortalId}: + get: + summary: Get API Portal by ID + description: Reads a single API Portal by its handle. Access is validated against the org in the JWT token. + operationId: GetApiPortal + security: + - OAuth2Security: + - ap:api_portal:read + - ap:api_portal:manage + tags: + - API Portals + parameters: + - $ref: '#/components/parameters/apiPortalId' + responses: + '200': + description: API Portal retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ApiPortalResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + put: + summary: Update API Portal + description: | + Updates mutable fields on an API Portal. The server ignores any immutable + field appearing in the body. Access is validated against the org in the JWT token. + operationId: UpdateApiPortal + security: + - OAuth2Security: + - ap:api_portal:update + - ap:api_portal:manage + tags: + - API Portals + parameters: + - $ref: '#/components/parameters/apiPortalId' + requestBody: + description: API Portal fields to update + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateApiPortalRequest' + responses: + '200': + description: API Portal updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ApiPortalResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + summary: Delete API Portal + description: | + Deletes the API Portal registration and purges the encrypted shared key. The remote + portal instance is not touched; operators are responsible for its lifecycle. Access + is validated against the org in the JWT token. + operationId: DeleteApiPortal + security: + - OAuth2Security: + - ap:api_portal:delete + - ap:api_portal:manage + tags: + - API Portals + parameters: + - $ref: '#/components/parameters/apiPortalId' + responses: + '204': + description: API Portal deleted successfully + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /me/api-keys: + get: + summary: List API keys for the current user, or for all users with `ap:api_key:all:manage` + description: | + Returns API keys created by the caller within the organization. + Callers holding the `ap:api_key:all:manage` scope instead receive every user's API keys + in the organization; the `createdBy` field identifies each key's creator. + Optionally filter by one or more artifact types using a comma-separated `type` query parameter. + The plain key value is never returned. + operationId: listUserAPIKeys + security: + - OAuth2Security: + - ap:api_key:read + - ap:api_key:all:manage + tags: + - API Keys + parameters: + - name: type + in: query + description: | + Comma-separated list of artifact types to filter by. + If omitted, all types are returned. + required: false + schema: + type: array + items: + type: string + enum: [RestApi, LlmProvider, LlmProxy, GraphQLApi] + style: form + explode: false + example: LlmProxy,LlmProvider + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' + responses: + '200': + description: List of API keys retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/UserAPIKeyListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + + /secrets: + post: + summary: Create a secret + description: | + Create a new encrypted secret scoped to the organization. The plaintext value is never returned. + operationId: createSecret + security: + - OAuth2Security: + - ap:secret:create + - ap:secret:manage + tags: + - Secrets + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/SecretCreateRequest' + example: + id: wso2-openai-key + displayName: WSO2 OpenAI API Key + description: API key for WSO2 OpenAI integration + value: sk-xxx + type: GENERIC + responses: + '201': + description: Secret created successfully. + headers: + Location: + $ref: '#/components/headers/Location' + content: + application/json: + schema: + $ref: '#/components/schemas/SecretResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '503': + $ref: '#/components/responses/ServiceUnavailable' + '500': + $ref: '#/components/responses/InternalServerError' + + get: + summary: List secrets + description: | + Returns metadata for all secrets in the organization. The plaintext value is + never included in list or get responses. + operationId: listSecrets + security: + - OAuth2Security: + - ap:secret:read + - ap:secret:manage + tags: + - Secrets + parameters: + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' + - name: updatedAfter + in: query + description: RFC3339 timestamp — return only secrets updated after this time. Used by GW controller for incremental polling. + schema: + type: string + format: date-time + example: "2026-01-01T00:00:00Z" + responses: + '200': + description: List of secret metadata + content: + application/json: + schema: + $ref: '#/components/schemas/SecretListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '503': + $ref: '#/components/responses/ServiceUnavailable' + '500': + $ref: '#/components/responses/InternalServerError' + + /secrets/{secretId}: + get: + summary: Get a secret by handle + description: Returns metadata for a single secret. The plaintext value is never returned. + operationId: getSecret + security: + - OAuth2Security: + - ap:secret:read + - ap:secret:manage + tags: + - Secrets + parameters: + - name: secretId + in: path + required: true + description: The secret handle + schema: + type: string + responses: + '200': + description: Secret metadata + content: + application/json: + schema: + $ref: '#/components/schemas/SecretSummary' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/ServiceUnavailable' + '500': + $ref: '#/components/responses/InternalServerError' + + put: + summary: Rotate a secret value + description: | + Re-encrypts and stores a new value for an existing secret. The handle is immutable + so all `{{ secret "handle" }}` placeholder references across resources remain valid + without modification. + operationId: rotateSecret + security: + - OAuth2Security: + - ap:secret:update + - ap:secret:manage + tags: + - Secrets + parameters: + - name: secretId + in: path + required: true + description: The secret handle + schema: + type: string + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/SecretUpdateRequest' + example: + value: sk-new-rotated-value + displayName: WSO2 OpenAI API Key (rotated) + responses: + '200': + description: Secret rotated successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/SecretResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/ServiceUnavailable' + '500': + $ref: '#/components/responses/InternalServerError' + + delete: + summary: Delete a secret + description: | + Soft-deletes a secret by marking it as DEPRECATED. Returns 409 if the secret is + still referenced by any LLM provider or API configuration. + operationId: deleteSecret + security: + - OAuth2Security: + - ap:secret:delete + - ap:secret:manage + tags: + - Secrets + parameters: + - name: secretId + in: path + required: true + description: The secret handle + schema: + type: string + responses: + '204': + description: Secret deleted successfully + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + description: Conflict. The secret is referenced by one or more active resources. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + status: error + code: SECRET_IN_USE + message: The secret is referenced by one or more active resources. + details: + references: + - type: llm_provider + handle: wso2-openai-provider + name: WSO2 OpenAI Provider + '503': + $ref: '#/components/responses/ServiceUnavailable' + '500': + $ref: '#/components/responses/InternalServerError' + +components: + headers: + Location: + description: URL of the newly created resource. + schema: + type: string + format: uri + securitySchemes: + OAuth2Security: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://localhost:9243/oauth2/token scopes: ap:api_key:all:manage: Manage API keys created by any user in the organization ap:api_key:read: Read API keys owned by the current user @@ -6446,6 +7322,24 @@ components: ap:gateway_custom_policy:delete: Delete a gateway custom policy ap:gateway_custom_policy:manage: Full access to gateway custom policies ap:gateway_custom_policy:read: Read gateway custom policies + ap:graphql_api:api_key:create: Create an API key for a GraphQL API + ap:graphql_api:api_key:delete: Delete an API key of a GraphQL API + ap:graphql_api:api_key:manage: Full access to a GraphQL API's API keys + ap:graphql_api:api_key:update: Update an API key of a GraphQL API + ap:graphql_api:create: Create a GraphQL API + ap:graphql_api:delete: Delete a GraphQL API + ap:graphql_api:deployment:create: Deploy a GraphQL API + ap:graphql_api:deployment:delete: Delete a GraphQL API deployment + ap:graphql_api:deployment:manage: Full access to GraphQL API deployments + ap:graphql_api:deployment:read: Read GraphQL API deployments + ap:graphql_api:deployment:restore: Restore a GraphQL API deployment + ap:graphql_api:deployment:undeploy: Undeploy a GraphQL API deployment + ap:graphql_api:gateway:create: Add gateways to a GraphQL API + ap:graphql_api:gateway:manage: Full access to a GraphQL API's gateways + ap:graphql_api:gateway:read: Read a GraphQL API's gateways + ap:graphql_api:manage: Full access to GraphQL APIs + ap:graphql_api:read: Read GraphQL APIs + ap:graphql_api:update: Update a GraphQL API ap:llm_provider:api_key:create: Create an LLM provider API key ap:llm_provider:api_key:delete: Delete an LLM provider API key ap:llm_provider:api_key:manage: Full access to LLM provider API keys @@ -6617,7 +7511,7 @@ components: example: wso2-openai-provider artifactType: type: string - enum: [RestApi, LlmProvider, LlmProxy] + enum: [RestApi, LlmProvider, LlmProxy, GraphQLApi] description: Type of the artifact this key belongs to UserAPIKeyListResponse: @@ -7833,165 +8727,666 @@ components: properties: id: type: string - format: uuid - description: ID of the newly generated token - example: "def45678-g901-23hi-j456-789012klmnop" - token: + format: uuid + description: ID of the newly generated token + example: "def45678-g901-23hi-j456-789012klmnop" + token: + type: string + description: Plain-text new authentication token (only exposed once during rotation). The example value is a non-functional placeholder. + example: "REDACTED_TOKEN" + createdAt: + type: string + format: date-time + description: Timestamp when new token was created + example: "2025-10-15T14:20:00Z" + message: + type: string + description: Informational message about token rotation + example: "New token generated successfully. Old token remains active until revoked." + + TokenInfoResponse: + type: object + properties: + id: + type: string + format: uuid + description: Token UUID + example: "abc12345-f678-90de-f123-456789abcdef" + status: + type: string + enum: [active, revoked] + description: Current token status + example: "active" + createdAt: + type: string + format: date-time + description: Timestamp when token was created + example: "2025-10-14T10:30:00Z" + revokedAt: + type: string + format: date-time + nullable: true + description: Timestamp when token was revoked (null if active) + example: null + + CreateRESTAPIRequest: + allOf: + - $ref: '#/components/schemas/RESTAPI' + - type: object + required: + - displayName + - context + - version + - projectId + + ImportOpenAPIRequest: + type: object + required: + - file + - displayName + - version + - context + - projectId + - upstream + properties: + file: + type: string + format: binary + description: OpenAPI 3.x or Swagger 2.x spec file (.json, .yaml, .yml) + id: + type: string + description: Unique handle/identifier for the API. Can be provided during creation or auto-generated. On update (PUT), if provided must match the path parameter — returns 400 if they differ. + minLength: 3 + maxLength: 40 + example: my-rest-api-handle + displayName: + description: Human-readable name for the API + pattern: '(^[^~!@#;:%^*()+={}|\\<>"'',&$\[\]\/]*$)' + type: string + minLength: 1 + maxLength: 128 + example: PizzaShackAPI + description: + maxLength: 32766 + type: string + example: This is a simple API for Pizza Shack online pizza delivery store + context: + maxLength: 232 + minLength: 1 + type: string + example: /pizza + version: + maxLength: 30 + minLength: 1 + type: string + pattern: '^[^~!@#;:%^*()+={}|\\<>"'',&/$\[\]\s+\/]+$' + example: 1.0.0 + projectId: + type: string + pattern: '^[a-z0-9-]+$' + minLength: 3 + maxLength: 63 + description: Handle (URL-friendly slug) of the project this API belongs to + example: default-project + upstream: + $ref: "#/components/schemas/Upstream" + + OpenAPISpecFileRequest: + type: object + required: + - file + properties: + file: + type: string + format: binary + description: OpenAPI 3.x or Swagger 2.x spec file (.json, .yaml, .yml) + + ValidateOpenAPIResponse: + type: object + required: + - isValid + - errors + properties: + isValid: + type: boolean + description: Whether the spec passed validation + errors: + type: array + items: + $ref: '#/components/schemas/OpenAPIValidationError' + description: Validation errors; empty when isValid is true + info: + $ref: '#/components/schemas/OpenAPISpecInfo' + + OpenAPIValidationError: + type: object + required: + - message + properties: + message: + type: string + description: Human-readable description of the validation error + path: + type: string + description: JSON Pointer path within the spec where the error was found + + OpenAPISpecInfo: + type: object + properties: + title: + type: string + description: Value of info.title from the spec + version: + type: string + description: Value of info.version from the spec + + OpenAPIContent: + type: object + properties: + content: + type: string + description: Raw spec content + + GraphQLIntrospectionMode: + type: string + enum: [SDL, ENDPOINT] + example: ENDPOINT + + GraphQLAPI: + title: GraphQL API object + required: + - displayName + - context + - version + - projectId + - upstream + type: object + properties: + id: + type: string + description: Unique handle/identifier for the API. Can be provided during creation or auto-generated. On update (PUT), if provided must match the path parameter — returns 400 if they differ. + minLength: 3 + maxLength: 40 + example: countries-graphql-api + displayName: + description: Human-readable name for the API + pattern: '(^[^~!@#;:%^*()+={}|\\<>"'',&$\[\]\/]*$)' + type: string + minLength: 1 + maxLength: 128 + example: Countries GraphQL API + description: + maxLength: 32766 + type: string + example: Public GraphQL API for querying country/region reference data + context: + maxLength: 232 + minLength: 1 + type: string + description: | + Base path for the single GraphQL endpoint. Suggested (not enforced) + convention: end the path with `/graphql`, matching how most standalone + GraphQL servers name their single endpoint — this is not validated. + example: /countries/graphql + version: + maxLength: 30 + minLength: 1 + type: string + pattern: '^[^~!@#;:%^*()+={}|\\<>"'',&/$\[\]\s+\/]+$' + example: v1.0 + createdBy: + maxLength: 200 + type: string + readOnly: true + example: "john.doe" + updatedBy: + maxLength: 200 + type: string + readOnly: true + description: Only present in the detail response (GET /graphql-apis/{graphqlApiId}), omitted from list responses. + example: "john.doe" + projectId: type: string - description: Plain-text new authentication token (only exposed once during rotation). The example value is a non-functional placeholder. - example: "REDACTED_TOKEN" + pattern: '^[a-z0-9-]+$' + minLength: 3 + maxLength: 63 + example: default-project createdAt: type: string format: date-time - description: Timestamp when new token was created - example: "2025-10-15T14:20:00Z" - message: + readOnly: true + example: "2026-08-11T10:00:00Z" + updatedAt: type: string - description: Informational message about token rotation - example: "New token generated successfully. Old token remains active until revoked." - - TokenInfoResponse: - type: object - properties: - id: + format: date-time + readOnly: true + example: "2026-08-11T10:00:00Z" + readOnly: + type: boolean + readOnly: true + description: True if the artifact originated from a data-plane gateway (origin gateway_api) and is read-only in the control plane. + example: false + upstream: + $ref: "#/components/schemas/Upstream" + description: | + Reused unmodified from REST APIs. A GraphQL API has exactly one logical + endpoint (no per-operation paths), so `upstream.main.url` is the single + GraphQL endpoint — either the backend to proxy to (SDL-supplied case) or + the endpoint introspected at creation time (see `sdl`/`introspectionMode` below). + kind: type: string - format: uuid - description: Token UUID - example: "abc12345-f678-90de-f123-456789abcdef" - status: + description: Kind of the API based on its communication protocol or architectural style + example: GraphQLApi + default: GraphQLApi + schemaSource: type: string - enum: [active, revoked] - description: Current token status - example: "active" - createdAt: + enum: [inline, url, file, introspection] + default: introspection + description: | + Declares how the schema is being supplied, so the server validates + against stated intent instead of guessing it from which fields happen + to be populated. `inline` requires `sdl`; `url` requires `sdlUrl`; + `file` requires the `sdlFile` multipart part (see + GraphQLAPIMultipartRequest); `introspection` (the default) requires a + literal `upstream.main.url` and derives the schema by querying it. + Only the field matching the declared source may be present — a + mismatch (wrong field populated, nothing populated, more than one + populated) is a `400` (`VALIDATION_FAILED`), not a silent + fall-through to a different resolution path. Schema *resolution* is + separate and best-effort: a failure to actually resolve (bad SDL, + unreachable URL, introspection failing) never fails the request — + see `sdl` below. + example: introspection + sdl: type: string - format: date-time - description: Timestamp when token was created - example: "2025-10-14T10:30:00Z" - revokedAt: + description: | + The GraphQL schema in SDL form — resolved per `schemaSource`, from a + directly-supplied document (`inline`/`file`), fetched from `sdlUrl` + (`url`), or derived from `upstream.main.url` (`introspection`). Always + the *resolved* schema, never a document-supplied schema-location + reference. Optional in practice: if resolution fails, the API is still + created/updated and this is left empty (create) or unchanged from its + previous value (update) rather than the request failing — see + `schemaSource`. + example: | + type Query { + countries: [Country] + country(code: ID!): Country + } + type Country { + code: String + name: String + capital: String + } + sdlUrl: type: string - format: date-time - nullable: true - description: Timestamp when token was revoked (null if active) - example: null - - CreateRESTAPIRequest: - allOf: - - $ref: '#/components/schemas/RESTAPI' - - type: object - required: - - displayName - - context - - version - - projectId + format: uri + writeOnly: true + description: | + A URL to a raw SDL document to fetch and use as `sdl` when + `schemaSource` is `url` — the write-side counterpart to how an OpenAPI + document can be supplied by reference for other artifact kinds (see + LlmProviderTemplate's `metadata.openapiSpecUrl`). Distinct from + `upstream.main.url`: this is a plain HTTP(S) GET of a static schema + file, not a live introspection query against a GraphQL server, and is + fetched through the same shared SSRF-guarded HTTP client every other + operator/tenant-supplied fetch in this API uses, under the operator- + configured policy (default `netguard.PermitPrivateBlockMetadata()`): the + host is resolved and every candidate IP — including each redirect hop — + is checked at dial time, refusing link-local/metadata/unspecified/ + multicast addresses while private and in-cluster addresses (a Kubernetes + ClusterIP, a service-DNS name, localhost) remain reachable. Never stored + or echoed back; only the fetched `sdl` text is persisted and returned. + example: https://raw.githubusercontent.com/example/countries-api/main/schema.graphql + introspectionMode: + allOf: + - $ref: '#/components/schemas/GraphQLIntrospectionMode' + readOnly: true + description: | + How `sdl` was obtained. SDL = supplied directly in the create/update + request. ENDPOINT = derived by introspecting `upstream.main.url` at + creation time. Informational only — storage and downstream behavior are + identical either way. + example: ENDPOINT + policies: + type: array + description: | + List of policies to be applied on the API. Reused unmodified from + REST APIs. A `cors` policy applies only to the API's single `POST` + route — a GraphQL API has no per-operation list to add an + `OPTIONS` entry to, so a browser preflight request is not routed + at all and a `cors` policy will not run for it; cross-origin + browser clients that trigger a preflight are not currently + supported. + items: + $ref: '#/components/schemas/Policy' + subscriptionPlans: + type: array + description: List of subscription plan names enabled for this API. + items: + type: string + example: [Gold, Silver] - ImportOpenAPIRequest: - type: object + # GraphQLAPI minus sdl/sdlUrl — the shape returned by GET + # /graphql-apis/{graphqlApiId}. Duplicated rather than composed via allOf + # (OpenAPI has no "subtract a property" mechanism) so GraphQLAPI itself stays + # unchanged for Create/Update, which still echo the resolved sdl back. + GraphQLAPIDetail: + title: GraphQL API detail (without sdl) required: - - file - displayName - - version - context + - version - projectId - upstream + type: object properties: - file: - type: string - format: binary - description: OpenAPI 3.x or Swagger 2.x spec file (.json, .yaml, .yml) id: type: string - description: Unique handle/identifier for the API. Can be provided during creation or auto-generated. On update (PUT), if provided must match the path parameter — returns 400 if they differ. + description: Unique handle/identifier for the API. minLength: 3 maxLength: 40 - example: my-rest-api-handle + example: countries-graphql-api displayName: description: Human-readable name for the API pattern: '(^[^~!@#;:%^*()+={}|\\<>"'',&$\[\]\/]*$)' type: string minLength: 1 maxLength: 128 - example: PizzaShackAPI + example: Countries GraphQL API description: maxLength: 32766 type: string - example: This is a simple API for Pizza Shack online pizza delivery store + example: Public GraphQL API for querying country/region reference data context: maxLength: 232 minLength: 1 type: string - example: /pizza + description: | + Base path for the single GraphQL endpoint. Suggested (not enforced) + convention: end the path with `/graphql`, matching how most standalone + GraphQL servers name their single endpoint — this is not validated. + example: /countries/graphql version: maxLength: 30 minLength: 1 type: string pattern: '^[^~!@#;:%^*()+={}|\\<>"'',&/$\[\]\s+\/]+$' - example: 1.0.0 + example: v1.0 + createdBy: + maxLength: 200 + type: string + readOnly: true + example: "john.doe" + updatedBy: + maxLength: 200 + type: string + readOnly: true + example: "john.doe" projectId: type: string pattern: '^[a-z0-9-]+$' minLength: 3 maxLength: 63 - description: Handle (URL-friendly slug) of the project this API belongs to example: default-project + createdAt: + type: string + format: date-time + readOnly: true + example: "2026-08-11T10:00:00Z" + updatedAt: + type: string + format: date-time + readOnly: true + example: "2026-08-11T10:00:00Z" + readOnly: + type: boolean + readOnly: true + description: True if the artifact originated from a data-plane gateway (origin gateway_api) and is read-only in the control plane. + example: false upstream: $ref: "#/components/schemas/Upstream" + description: | + Reused unmodified from REST APIs. A GraphQL API has exactly one logical + endpoint (no per-operation paths), so `upstream.main.url` is the single + GraphQL endpoint — either the backend to proxy to (SDL-supplied case) or + the endpoint introspected at creation time (see `introspectionMode` below). + kind: + type: string + description: Kind of the API based on its communication protocol or architectural style + example: GraphQLApi + default: GraphQLApi + introspectionMode: + allOf: + - $ref: '#/components/schemas/GraphQLIntrospectionMode' + readOnly: true + description: | + How the schema was obtained. SDL = supplied directly in the create/update + request. ENDPOINT = derived by introspecting `upstream.main.url` at + creation time. Informational only — storage and downstream behavior are + identical either way. + example: ENDPOINT + policies: + type: array + description: | + List of policies to be applied on the API. Reused unmodified from + REST APIs. A `cors` policy applies only to the API's single `POST` + route — a GraphQL API has no per-operation list to add an + `OPTIONS` entry to, so a browser preflight request is not routed + at all and a `cors` policy will not run for it; cross-origin + browser clients that trigger a preflight are not currently + supported. + items: + $ref: '#/components/schemas/Policy' + subscriptionPlans: + type: array + description: List of subscription plan names enabled for this API. + items: + type: string + example: [Gold, Silver] - OpenAPISpecFileRequest: + GraphQLAPISDLResponse: + title: GraphQL API SDL type: object required: - - file + - sdl properties: - file: + sdl: + type: string + description: | + The GraphQL schema in SDL form, resolved at create/update time (either + supplied directly or derived via upstream introspection) — see + `GET /graphql-apis/{graphqlApiId}` for the rest of the API's metadata. + example: | + type Query { + countries: [Country] + country(code: ID!): Country + } + type Country { + code: String + name: String + capital: String + } + + CreateGraphQLAPIRequest: + allOf: + - $ref: '#/components/schemas/GraphQLAPI' + - type: object + required: + - displayName + - context + - version + - projectId + - upstream + + GraphQLAPIMultipartRequest: + title: GraphQL API object with SDL file upload + type: object + required: + - metadata + properties: + metadata: + type: string + description: | + JSON-encoded request body — CreateGraphQLAPIRequest fields for create, + GraphQLAPI fields for update, including `schemaSource`. When + `schemaSource` is `file`, the `sdlFile` part below is required and any + `sdl`/`sdlUrl` in this metadata is a structural-validation error, not a + silent override — every schema-source variant is expressed + consistently through the `schemaSource` field rather than by which + part happens to be present. + example: | + {"displayName":"Countries GraphQL API","context":"/countries","version":"v1.0","projectId":"default-project","schemaSource":"introspection","upstream":{"main":{"url":"https://countries.trevorblades.com/graphql"}}} + sdlFile: type: string format: binary - description: OpenAPI 3.x or Swagger 2.x spec file (.json, .yaml, .yml) + description: | + The GraphQL SDL document as a file upload (e.g. schema.graphql). + Required when `schemaSource` is `file`; must be omitted otherwise. - ValidateOpenAPIResponse: + GraphQLAPIListItem: + title: GraphQL API list item type: object required: - - isValid - - errors + - displayName + - context + - version + - projectId properties: - isValid: + id: + type: string + minLength: 3 + maxLength: 40 + example: countries-graphql-api + displayName: + type: string + minLength: 1 + maxLength: 128 + example: Countries GraphQL API + description: + maxLength: 32766 + type: string + context: + type: string + example: /countries/graphql + version: + type: string + example: v1.0 + projectId: + type: string + example: default-project + upstream: + $ref: "#/components/schemas/Upstream" + introspectionMode: + $ref: '#/components/schemas/GraphQLIntrospectionMode' + kind: + type: string + example: GraphQLApi + default: GraphQLApi + readOnly: type: boolean - description: Whether the spec passed validation - errors: + example: false + createdBy: + type: string + readOnly: true + example: "john.doe" + createdAt: + type: string + format: date-time + readOnly: true + updatedAt: + type: string + format: date-time + readOnly: true + GraphQLAPIListResponse: + type: object + required: + - count + - list + - pagination + properties: + count: + type: integer + example: 1 + list: type: array items: - $ref: '#/components/schemas/OpenAPIValidationError' - description: Validation errors; empty when isValid is true - info: - $ref: '#/components/schemas/OpenAPISpecInfo' + $ref: '#/components/schemas/GraphQLAPIListItem' + pagination: + $ref: '#/components/schemas/Pagination' - OpenAPIValidationError: + ValidateGraphQLSchemaRequest: + title: GraphQL schema validation request type: object - required: - - message properties: - message: + schemaSource: type: string - description: Human-readable description of the validation error - path: + enum: [inline, url, file, introspection] + default: introspection + description: | + Same semantics as `GraphQLAPI.schemaSource` — declares which of + `sdl`/`sdlUrl`/the `sdlFile` multipart part/`upstream.main.url` + supplies the schema to resolve. + example: introspection + sdl: type: string - description: JSON Pointer path within the spec where the error was found + description: The GraphQL schema in SDL form, when `schemaSource` is `inline` (or the uploaded file's content, when `file`). + sdlUrl: + type: string + format: uri + writeOnly: true + description: A URL to fetch the SDL from, when `schemaSource` is `url`. + upstream: + $ref: '#/components/schemas/Upstream' + description: | + Only relevant when `schemaSource` is `introspection` (explicit or + inferred) — unlike `GraphQLAPI.upstream`, this is not required, + since a validation request for `inline`/`url`/`file` has no use + for it. - OpenAPISpecInfo: + ValidateGraphQLSchemaMultipartRequest: + title: GraphQL schema validation request with SDL file upload type: object + required: + - metadata properties: - title: + metadata: type: string - description: Value of info.title from the spec - version: + description: JSON-encoded ValidateGraphQLSchemaRequest. + example: | + {"schemaSource":"introspection","upstream":{"main":{"url":"https://countries.trevorblades.com/graphql"}}} + sdlFile: type: string - description: Value of info.version from the spec + format: binary + description: | + The GraphQL SDL document as a file upload. Required when + `schemaSource` is `file`; must be omitted otherwise. - OpenAPIContent: + ValidateGraphQLSchemaResponse: + title: GraphQL schema validation result type: object + required: + - resolved + - sdl properties: - content: + resolved: + type: boolean + description: Whether the declared schemaSource actually resolved to a usable schema. + example: true + sdl: type: string - description: Raw spec content + description: The resolved SDL text when `resolved` is `true`; empty otherwise. + introspectionMode: + $ref: '#/components/schemas/GraphQLIntrospectionMode' + description: Only set when `resolved` is `true`. + message: + type: string + description: | + A generic explanation, set only when `resolved` is `false`. Never + the specific parser/fetch/introspection failure reason — reuses + the same sterile message `GraphQLAPISchemaResolveFailed` uses + elsewhere (`error-handling.md`). + example: The provided endpoint could not be used to derive a GraphQL schema, or the supplied SDL could not be parsed. TimeUnit: type: string @@ -11497,6 +12892,14 @@ tags: description: Publishing, unpublishing and deprecating an API on an API Portal, and the per-portal draft and live listing that feed those actions - name: API Portals description: API Portal registration and management + - name: GraphQL APIs + description: GraphQL API management operations + - name: GraphQL API Deployments + description: GraphQL API deployment artifact management and lifecycle operations + - name: API Portal + description: API portal publishing and unpublishing operations + - name: DevPortals + description: DevPortal management operations - name: Gateways description: Gateway registration and management operations - name: Gateway Tokens @@ -11514,7 +12917,7 @@ tags: - name: LLM Proxy Deployments description: LLM proxy deployment operations - name: API Keys - description: API key management operations for REST APIs and LLM Providers + description: API key management operations for REST APIs, LLM Providers, and GraphQL APIs - name: MCP Proxies description: MCP proxy management operations - name: MCP Proxy Deployments diff --git a/platform-api/resources/role-to-scope-mapping.yaml b/platform-api/resources/role-to-scope-mapping.yaml index d8616d8977..a0b76425b0 100644 --- a/platform-api/resources/role-to-scope-mapping.yaml +++ b/platform-api/resources/role-to-scope-mapping.yaml @@ -86,6 +86,7 @@ roles: - ap:api_publication:read # - ap:websub_api:manage # event-gateway build only # - ap:webbroker_api:manage # event-gateway build only + - ap:graphql_api:manage # API Portal & MCP Hub - dp:organization:manage - dp:organization_content:manage @@ -141,6 +142,8 @@ roles: # - ap:websub_api:deployment:read # event-gateway build only # - ap:webbroker_api:read # event-gateway build only # - ap:webbroker_api:deployment:read # event-gateway build only + - ap:graphql_api:read + - ap:graphql_api:deployment:manage # API Portal & MCP Hub - dp:key_manager:manage - dp:key_manager:read @@ -191,6 +194,7 @@ roles: - ap:api_publication:read # - ap:websub_api:manage # event-gateway build only # - ap:webbroker_api:manage # event-gateway build only + - ap:graphql_api:manage # API Portal & MCP Hub - dp:api:manage - dp:api_content:manage @@ -222,6 +226,7 @@ roles: - ap:mcp_proxy:read - ap:llm_proxy:read - ap:llm_provider:read + - ap:graphql_api:read - ap:api_key:read # API Portal & MCP Hub - dp:application:manage @@ -277,6 +282,8 @@ roles: # - ap:websub_api:deployment:read # event-gateway build only # - ap:webbroker_api:read # event-gateway build only # - ap:webbroker_api:deployment:read # event-gateway build only + - ap:graphql_api:read + - ap:graphql_api:deployment:read # API Portal & MCP Hub - dp:organization:read - dp:organization_content:read diff --git a/portals/ai-workspace/bff/internal/config/config.go b/portals/ai-workspace/bff/internal/config/config.go index f6b306c54c..2b9e748a4f 100644 --- a/portals/ai-workspace/bff/internal/config/config.go +++ b/portals/ai-workspace/bff/internal/config/config.go @@ -289,6 +289,10 @@ const defaultOIDCScopes = "openid profile email offline_access" + " ap:llm_proxy:deployment:read ap:llm_proxy:deployment:create ap:llm_proxy:deployment:delete ap:llm_proxy:deployment:manage ap:llm_proxy:deployment:undeploy ap:llm_proxy:deployment:restore" + " ap:mcp_proxy:read ap:mcp_proxy:create ap:mcp_proxy:update ap:mcp_proxy:delete ap:mcp_proxy:manage" + " ap:mcp_proxy:deployment:read ap:mcp_proxy:deployment:create ap:mcp_proxy:deployment:delete ap:mcp_proxy:deployment:manage ap:mcp_proxy:deployment:undeploy ap:mcp_proxy:deployment:restore" + + " ap:graphql_api:read ap:graphql_api:create ap:graphql_api:update ap:graphql_api:delete ap:graphql_api:manage" + + " ap:graphql_api:gateway:read ap:graphql_api:gateway:create ap:graphql_api:gateway:manage" + + " ap:graphql_api:api_key:create ap:graphql_api:api_key:update ap:graphql_api:api_key:delete ap:graphql_api:api_key:manage" + + " ap:graphql_api:deployment:read ap:graphql_api:deployment:create ap:graphql_api:deployment:delete ap:graphql_api:deployment:manage ap:graphql_api:deployment:undeploy ap:graphql_api:deployment:restore" + " ap:api_portal:read ap:api_portal:create ap:api_portal:update ap:api_portal:delete ap:api_portal:manage" + " ap:api_portal:draft:read ap:api_portal:draft:update ap:api_portal:draft:manage" + " ap:api_portal:publication:read" + diff --git a/portals/api-control-plane/src/api/generated/platform.d.ts b/portals/api-control-plane/src/api/generated/platform.d.ts index e367a1e68e..171f84285a 100644 --- a/portals/api-control-plane/src/api/generated/platform.d.ts +++ b/portals/api-control-plane/src/api/generated/platform.d.ts @@ -845,6 +845,318 @@ export interface paths { patch?: never; trace?: never; }; + "/graphql-apis": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get all GraphQL APIs for an organization + * @description Retrieves all GraphQL APIs belonging to an organization. Requires the + * projectId query parameter to filter APIs by project. Access is validated + * against the organization in the JWT token. + */ + get: operations["ListGraphQLAPIs"]; + put?: never; + /** + * Create a new GraphQL API + * @description Creates a new GraphQL API in the platform. `schemaSource` declares how the + * schema is supplied: `inline` (the `sdl` field), `url` (fetched from + * `sdlUrl`), `file` (the `sdlFile` multipart part), or `introspection` (the + * default — `upstream.main.url` must expose standard GraphQL introspection). + * Only the field matching the declared source may be present — a request + * that supplies a field not matching the declared `schemaSource` (or more + * than one schema field at once), omits the field/part its declared source + * requires, or declares `introspection` against an `upstream.main.ref` + * instead of a literal `url`, is a request-shape problem and is rejected + * with `400` (`VALIDATION_FAILED`) describing exactly what's inconsistent. + * Once the request shape itself is valid, schema resolution is best-effort: + * if the declared source can't actually be resolved (unreachable URL, + * invalid SDL, introspection failing/disabled), the API is still created + * with an empty schema rather than failing — fetch it later via + * `GET /graphql-apis/{graphqlApiId}/sdl` once it can be resolved. The API is + * associated with a project, which must belong to the organization + * specified in the JWT token. + */ + post: operations["CreateGraphQLAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/{graphqlApiId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get GraphQL API by ID + * @description Retrieves the GraphQL API's metadata and configuration. The `sdl` field + * is deliberately omitted from this response — it can be large, and most + * callers only need the metadata — fetch it separately via + * `GET /graphql-apis/{graphqlApiId}/sdl`. + */ + get: operations["GetGraphQLAPI"]; + /** + * Update GraphQL API + * @description Updates an existing GraphQL API's details. `schemaSource` behaves as on + * create (see `POST /graphql-apis`), including the same `400` + * (`VALIDATION_FAILED`) response for a request shape that's inconsistent + * with the declared `schemaSource` — re-supply `sdl`/`sdlUrl`/`sdlFile`, or + * leave it as `introspection` to re-query `upstream.main.url` and pick up a + * changed backend schema. If resolution fails (the source can't actually be + * resolved right now), the previously-stored schema is left unchanged rather + * than being cleared. + */ + put: operations["UpdateGraphQLAPI"]; + post?: never; + /** Delete GraphQL API */ + delete: operations["DeleteGraphQLAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/{graphqlApiId}/sdl": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the SDL for a GraphQL API + * @description Retrieves the GraphQL API's resolved schema in SDL form — the same text + * `GET /graphql-apis/{graphqlApiId}` would have returned in its `sdl` field + * before that field was split out into this dedicated endpoint (large, and + * rarely needed alongside the rest of the metadata). + */ + get: operations["GetGraphQLAPISDL"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/validate-schema": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Dry-run GraphQL schema resolution + * @description Attempts to resolve a schema exactly as `POST`/`PUT /graphql-apis` + * would — the same `schemaSource`-driven structural validation, the + * same best-effort resolution (§5.2) — without persisting anything. A + * request-shape mismatch (`schemaSource` inconsistent with the fields + * supplied) is a `400` (`VALIDATION_FAILED`), same as create/update. An + * actual resolution failure (bad SDL, an unreachable `sdlUrl`, a failed + * introspection query) is **not** an error here either — the response + * reports `resolved: false` so the caller can decide what to do, rather + * than having to create a real API just to find out. + */ + post: operations["ValidateGraphQLSchema"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/{graphqlApiId}/gateways": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get gateways for GraphQL API + * @description Retrieves all gateways associated with the specified API, including deployment details. + * Returns gateway information along with association timestamps and deployment status. + * Access is validated against the organization in the JWT token. + */ + get: operations["GetGraphQLAPIGateways"]; + put?: never; + /** + * Add gateways for GraphQL API + * @description Associates gateways to the specified API. If gateways are already associated, + * updates the association timestamp. Returns all gateways associated with the API + * including deployment details. Access is validated against the organization + * in the JWT token. + */ + post: operations["AddGatewaysToGraphQLAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/{graphqlApiId}/api-keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create API key + * @description Creates a new API key for the specified GraphQL API. The API key will be hashed before + * storage and broadcasted to all gateways where the API is deployed. This endpoint + * allows external platforms to inject API keys to hybrid gateways. + */ + post: operations["CreateGraphQLAPIKey"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/{graphqlApiId}/api-keys/{apiKeyId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update API key + * @description Updates an existing API key for the specified GraphQL API. The new API key value will + * be hashed before storage and broadcasted to all gateways where the API is deployed. + * This endpoint allows external platforms to rotate API keys on hybrid gateways. + */ + put: operations["UpdateGraphQLAPIKey"]; + post?: never; + /** + * Revoke API key + * @description Revokes an API key for the specified GraphQL API. The revocation will be broadcasted + * to all gateways where the API is deployed. This endpoint allows external platforms + * to revoke API keys on hybrid gateways. + */ + delete: operations["RevokeGraphQLAPIKey"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/{graphqlApiId}/deployments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get deployments for a GraphQL API + * @description Retrieves all deployment artifacts for a specific API. The graphqlApiId parameter is the API handle (identifier), + * not the UUID. Supports filtering by gateway handle and deployment status. + * Access is validated against the organization in the JWT token. + */ + get: operations["GetGraphQLAPIDeployments"]; + put?: never; + /** + * Create and deploy a new deployment + * @description Creates an immutable deployment artifact for a GraphQL API and deploys it to a specified gateway. + * Each deployment targets a single gateway. The graphqlApiId parameter is the API handle (identifier), + * not the UUID. The operation returns a transitional DEPLOYING status. Final success or failure will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. + * Access is validated against the organization in the JWT token. + */ + post: operations["DeployGraphQLAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/{graphqlApiId}/deployments/{deploymentId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get deployment by ID + * @description Retrieves metadata for a specific deployment artifact including status, gateway association, + * and timestamps. Access is validated against the organization in the JWT token. + */ + get: operations["GetGraphQLAPIDeployment"]; + put?: never; + post?: never; + /** + * Delete deployment + * @description Deletes a deployment artifact. Deletion is only allowed when the deployment is in UNDEPLOYED status. + * Access is validated against the organization in the JWT token. + */ + delete: operations["DeleteGraphQLAPIDeployment"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/{graphqlApiId}/deployments/{deploymentId}/undeploy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Undeploy deployment from gateway + * @description Undeploys an active deployment, stopping the API from being served on the specified gateway. + * The deployment artifact remains in the system and can be restored later. + * Returns the updated deployment object with initial status UNDEPLOYING. Final status (UNDEPLOYED or FAILED) will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. + * + * The gatewayId query parameter is validated against deployment's bound gateway to prevent unintended operations. + * Access is validated against the organization in the JWT token. + */ + post: operations["UndeployGraphQLAPIDeployment"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/{graphqlApiId}/deployments/{deploymentId}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Restore a previous deployment + * @description Initiates restoring a previous deployment (ARCHIVED or UNDEPLOYED) on the specified gateway. + * Returns the deployment with initial status DEPLOYING. Final success or failure will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. + * The target deployment must not already be in DEPLOYED status. + * + * The gatewayId query parameter is validated against the deployment's bound gateway to prevent unintended operations. + * Access is validated against the organization in the JWT token. + */ + post: operations["RestoreGraphQLAPIDeployment"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/llm-provider-templates": { parameters: { query?: never; @@ -2443,7 +2755,7 @@ export interface components { * @description Type of the artifact this key belongs to * @enum {string} */ - artifactType: "RestApi" | "LlmProvider" | "LlmProxy"; + artifactType: "RestApi" | "LlmProvider" | "LlmProxy" | "GraphQLApi"; }; UserAPIKeyListResponse: { /** @description List of API keys */ @@ -3478,45 +3790,413 @@ export interface components { content?: string; }; /** - * @description Time unit for API key expiration duration - * @example days + * @example ENDPOINT * @enum {string} */ - TimeUnit: "seconds" | "minutes" | "hours" | "days" | "weeks" | "months"; - ExpirationDuration: { - /** - * @description Duration value (must be positive) - * @example 30 - */ - duration: number; - unit: components["schemas"]["TimeUnit"]; - }; - CreateAPIKeyRequest: { + GraphQLIntrospectionMode: "SDL" | "ENDPOINT"; + /** GraphQL API object */ + GraphQLAPI: { /** - * @description Unique identifier for this API key within the API (optional; if omitted, - * generated from displayName) - * @example production-key-01 + * @description Unique handle/identifier for the API. Can be provided during creation or auto-generated. On update (PUT), if provided must match the path parameter — returns 400 if they differ. + * @example countries-graphql-api */ id?: string; /** - * @description Human-readable name for the API key - * @example Production API Key + * @description Human-readable name for the API + * @example Countries GraphQL API */ displayName: string; + /** @example Public GraphQL API for querying country/region reference data */ + description?: string; /** - * @description Optional. A pre-minted plain text API key to inject (used by external platforms pushing a key to hybrid gateways). Omit it to have the server generate one, the generated value is returned once in the response and is never retrievable afterwards. - * @example sk_example_1234567890abcdef + * @description Base path for the single GraphQL endpoint. Suggested (not enforced) + * convention: end the path with `/graphql`, matching how most standalone + * GraphQL servers name their single endpoint — this is not validated. + * @example /countries/graphql */ - apiKey?: string; + context: string; + /** @example v1.0 */ + version: string; + /** @example john.doe */ + readonly createdBy?: string; /** - * @description Optional reference ID for tracing purposes (from external platforms) - * @example ext-ref-12345 + * @description Only present in the detail response (GET /graphql-apis/{graphqlApiId}), omitted from list responses. + * @example john.doe */ - externalRefId?: string | null; + readonly updatedBy?: string; + /** @example default-project */ + projectId: string; /** * Format: date-time - * @description Optional expiration time in ISO 8601 format - * @example 2026-12-31T23:59:59Z + * @example 2026-08-11T10:00:00Z + */ + readonly createdAt?: string; + /** + * Format: date-time + * @example 2026-08-11T10:00:00Z + */ + readonly updatedAt?: string; + /** + * @description True if the artifact originated from a data-plane gateway (origin gateway_api) and is read-only in the control plane. + * @example false + */ + readonly readOnly?: boolean; + /** + * @description Reused unmodified from REST APIs. A GraphQL API has exactly one logical + * endpoint (no per-operation paths), so `upstream.main.url` is the single + * GraphQL endpoint — either the backend to proxy to (SDL-supplied case) or + * the endpoint introspected at creation time (see `sdl`/`introspectionMode` below). + */ + upstream: components["schemas"]["Upstream"]; + /** + * @description Kind of the API based on its communication protocol or architectural style + * @default GraphQLApi + * @example GraphQLApi + */ + kind: string; + /** + * @description Declares how the schema is being supplied, so the server validates + * against stated intent instead of guessing it from which fields happen + * to be populated. `inline` requires `sdl`; `url` requires `sdlUrl`; + * `file` requires the `sdlFile` multipart part (see + * GraphQLAPIMultipartRequest); `introspection` (the default) requires a + * literal `upstream.main.url` and derives the schema by querying it. + * Only the field matching the declared source may be present — a + * mismatch (wrong field populated, nothing populated, more than one + * populated) is a `400` (`VALIDATION_FAILED`), not a silent + * fall-through to a different resolution path. Schema *resolution* is + * separate and best-effort: a failure to actually resolve (bad SDL, + * unreachable URL, introspection failing) never fails the request — + * see `sdl` below. + * @default introspection + * @example introspection + * @enum {string} + */ + schemaSource: "inline" | "url" | "file" | "introspection"; + /** + * @description The GraphQL schema in SDL form — resolved per `schemaSource`, from a + * directly-supplied document (`inline`/`file`), fetched from `sdlUrl` + * (`url`), or derived from `upstream.main.url` (`introspection`). Always + * the *resolved* schema, never a document-supplied schema-location + * reference. Optional in practice: if resolution fails, the API is still + * created/updated and this is left empty (create) or unchanged from its + * previous value (update) rather than the request failing — see + * `schemaSource`. + * @example type Query { + * countries: [Country] + * country(code: ID!): Country + * } + * type Country { + * code: String + * name: String + * capital: String + * } + */ + sdl?: string; + /** + * Format: uri + * @description A URL to a raw SDL document to fetch and use as `sdl` when + * `schemaSource` is `url` — the write-side counterpart to how an OpenAPI + * document can be supplied by reference for other artifact kinds (see + * LlmProviderTemplate's `metadata.openapiSpecUrl`). Distinct from + * `upstream.main.url`: this is a plain HTTP(S) GET of a static schema + * file, not a live introspection query against a GraphQL server, and is + * fetched through the same shared SSRF-guarded HTTP client every other + * operator/tenant-supplied fetch in this API uses, under the operator- + * configured policy (default `netguard.PermitPrivateBlockMetadata()`): the + * host is resolved and every candidate IP — including each redirect hop — + * is checked at dial time, refusing link-local/metadata/unspecified/ + * multicast addresses while private and in-cluster addresses (a Kubernetes + * ClusterIP, a service-DNS name, localhost) remain reachable. Never stored + * or echoed back; only the fetched `sdl` text is persisted and returned. + * @example https://raw.githubusercontent.com/example/countries-api/main/schema.graphql + */ + sdlUrl?: string; + /** + * @description How `sdl` was obtained. SDL = supplied directly in the create/update + * request. ENDPOINT = derived by introspecting `upstream.main.url` at + * creation time. Informational only — storage and downstream behavior are + * identical either way. + * @example ENDPOINT + */ + readonly introspectionMode?: components["schemas"]["GraphQLIntrospectionMode"]; + /** + * @description List of policies to be applied on the API. Reused unmodified from + * REST APIs. A `cors` policy applies only to the API's single `POST` + * route — a GraphQL API has no per-operation list to add an + * `OPTIONS` entry to, so a browser preflight request is not routed + * at all and a `cors` policy will not run for it; cross-origin + * browser clients that trigger a preflight are not currently + * supported. + */ + policies?: components["schemas"]["Policy"][]; + /** + * @description List of subscription plan names enabled for this API. + * @example [ + * "Gold", + * "Silver" + * ] + */ + subscriptionPlans?: string[]; + }; + /** GraphQL API detail (without sdl) */ + GraphQLAPIDetail: { + /** + * @description Unique handle/identifier for the API. + * @example countries-graphql-api + */ + id?: string; + /** + * @description Human-readable name for the API + * @example Countries GraphQL API + */ + displayName: string; + /** @example Public GraphQL API for querying country/region reference data */ + description?: string; + /** + * @description Base path for the single GraphQL endpoint. Suggested (not enforced) + * convention: end the path with `/graphql`, matching how most standalone + * GraphQL servers name their single endpoint — this is not validated. + * @example /countries/graphql + */ + context: string; + /** @example v1.0 */ + version: string; + /** @example john.doe */ + readonly createdBy?: string; + /** @example john.doe */ + readonly updatedBy?: string; + /** @example default-project */ + projectId: string; + /** + * Format: date-time + * @example 2026-08-11T10:00:00Z + */ + readonly createdAt?: string; + /** + * Format: date-time + * @example 2026-08-11T10:00:00Z + */ + readonly updatedAt?: string; + /** + * @description True if the artifact originated from a data-plane gateway (origin gateway_api) and is read-only in the control plane. + * @example false + */ + readonly readOnly?: boolean; + /** + * @description Reused unmodified from REST APIs. A GraphQL API has exactly one logical + * endpoint (no per-operation paths), so `upstream.main.url` is the single + * GraphQL endpoint — either the backend to proxy to (SDL-supplied case) or + * the endpoint introspected at creation time (see `introspectionMode` below). + */ + upstream: components["schemas"]["Upstream"]; + /** + * @description Kind of the API based on its communication protocol or architectural style + * @default GraphQLApi + * @example GraphQLApi + */ + kind: string; + /** + * @description How the schema was obtained. SDL = supplied directly in the create/update + * request. ENDPOINT = derived by introspecting `upstream.main.url` at + * creation time. Informational only — storage and downstream behavior are + * identical either way. + * @example ENDPOINT + */ + readonly introspectionMode?: components["schemas"]["GraphQLIntrospectionMode"]; + /** + * @description List of policies to be applied on the API. Reused unmodified from + * REST APIs. A `cors` policy applies only to the API's single `POST` + * route — a GraphQL API has no per-operation list to add an + * `OPTIONS` entry to, so a browser preflight request is not routed + * at all and a `cors` policy will not run for it; cross-origin + * browser clients that trigger a preflight are not currently + * supported. + */ + policies?: components["schemas"]["Policy"][]; + /** + * @description List of subscription plan names enabled for this API. + * @example [ + * "Gold", + * "Silver" + * ] + */ + subscriptionPlans?: string[]; + }; + /** GraphQL API SDL */ + GraphQLAPISDLResponse: { + /** + * @description The GraphQL schema in SDL form, resolved at create/update time (either + * supplied directly or derived via upstream introspection) — see + * `GET /graphql-apis/{graphqlApiId}` for the rest of the API's metadata. + * @example type Query { + * countries: [Country] + * country(code: ID!): Country + * } + * type Country { + * code: String + * name: String + * capital: String + * } + */ + sdl: string; + }; + CreateGraphQLAPIRequest: components["schemas"]["GraphQLAPI"] & Record; + /** GraphQL API object with SDL file upload */ + GraphQLAPIMultipartRequest: { + /** + * @description JSON-encoded request body — CreateGraphQLAPIRequest fields for create, + * GraphQLAPI fields for update, including `schemaSource`. When + * `schemaSource` is `file`, the `sdlFile` part below is required and any + * `sdl`/`sdlUrl` in this metadata is a structural-validation error, not a + * silent override — every schema-source variant is expressed + * consistently through the `schemaSource` field rather than by which + * part happens to be present. + * @example {"displayName":"Countries GraphQL API","context":"/countries","version":"v1.0","projectId":"default-project","schemaSource":"introspection","upstream":{"main":{"url":"https://countries.trevorblades.com/graphql"}}} + */ + metadata: string; + /** + * Format: binary + * @description The GraphQL SDL document as a file upload (e.g. schema.graphql). + * Required when `schemaSource` is `file`; must be omitted otherwise. + */ + sdlFile?: string; + }; + /** GraphQL API list item */ + GraphQLAPIListItem: { + /** @example countries-graphql-api */ + id?: string; + /** @example Countries GraphQL API */ + displayName: string; + description?: string; + /** @example /countries/graphql */ + context: string; + /** @example v1.0 */ + version: string; + /** @example default-project */ + projectId: string; + upstream?: components["schemas"]["Upstream"]; + introspectionMode?: components["schemas"]["GraphQLIntrospectionMode"]; + /** + * @default GraphQLApi + * @example GraphQLApi + */ + kind: string; + /** @example false */ + readOnly?: boolean; + /** @example john.doe */ + readonly createdBy?: string; + /** Format: date-time */ + readonly createdAt?: string; + /** Format: date-time */ + readonly updatedAt?: string; + }; + GraphQLAPIListResponse: { + /** @example 1 */ + count: number; + list: components["schemas"]["GraphQLAPIListItem"][]; + pagination: components["schemas"]["Pagination"]; + }; + /** GraphQL schema validation request */ + ValidateGraphQLSchemaRequest: { + /** + * @description Same semantics as `GraphQLAPI.schemaSource` — declares which of + * `sdl`/`sdlUrl`/the `sdlFile` multipart part/`upstream.main.url` + * supplies the schema to resolve. + * @default introspection + * @example introspection + * @enum {string} + */ + schemaSource: "inline" | "url" | "file" | "introspection"; + /** @description The GraphQL schema in SDL form, when `schemaSource` is `inline` (or the uploaded file's content, when `file`). */ + sdl?: string; + /** + * Format: uri + * @description A URL to fetch the SDL from, when `schemaSource` is `url`. + */ + sdlUrl?: string; + /** + * @description Only relevant when `schemaSource` is `introspection` (explicit or + * inferred) — unlike `GraphQLAPI.upstream`, this is not required, + * since a validation request for `inline`/`url`/`file` has no use + * for it. + */ + upstream?: components["schemas"]["Upstream"]; + }; + /** GraphQL schema validation request with SDL file upload */ + ValidateGraphQLSchemaMultipartRequest: { + /** + * @description JSON-encoded ValidateGraphQLSchemaRequest. + * @example {"schemaSource":"introspection","upstream":{"main":{"url":"https://countries.trevorblades.com/graphql"}}} + */ + metadata: string; + /** + * Format: binary + * @description The GraphQL SDL document as a file upload. Required when + * `schemaSource` is `file`; must be omitted otherwise. + */ + sdlFile?: string; + }; + /** GraphQL schema validation result */ + ValidateGraphQLSchemaResponse: { + /** + * @description Whether the declared schemaSource actually resolved to a usable schema. + * @example true + */ + resolved: boolean; + /** @description The resolved SDL text when `resolved` is `true`; empty otherwise. */ + sdl: string; + /** @description Only set when `resolved` is `true`. */ + introspectionMode?: components["schemas"]["GraphQLIntrospectionMode"]; + /** + * @description A generic explanation, set only when `resolved` is `false`. Never + * the specific parser/fetch/introspection failure reason — reuses + * the same sterile message `GraphQLAPISchemaResolveFailed` uses + * elsewhere (`error-handling.md`). + * @example The provided endpoint could not be used to derive a GraphQL schema, or the supplied SDL could not be parsed. + */ + message?: string; + }; + /** + * @description Time unit for API key expiration duration + * @example days + * @enum {string} + */ + TimeUnit: "seconds" | "minutes" | "hours" | "days" | "weeks" | "months"; + ExpirationDuration: { + /** + * @description Duration value (must be positive) + * @example 30 + */ + duration: number; + unit: components["schemas"]["TimeUnit"]; + }; + CreateAPIKeyRequest: { + /** + * @description Unique identifier for this API key within the API (optional; if omitted, + * generated from displayName) + * @example production-key-01 + */ + id?: string; + /** + * @description Human-readable name for the API key + * @example Production API Key + */ + displayName: string; + /** + * @description Optional. A pre-minted plain text API key to inject (used by external platforms pushing a key to hybrid gateways). Omit it to have the server generate one, the generated value is returned once in the response and is never retrievable afterwards. + * @example sk_example_1234567890abcdef + */ + apiKey?: string; + /** + * @description Optional reference ID for tracing purposes (from external platforms) + * @example ext-ref-12345 + */ + externalRefId?: string | null; + /** + * Format: date-time + * @description Optional expiration time in ISO 8601 format + * @example 2026-12-31T23:59:59Z */ expiresAt?: string | null; /** @description Optional expiration duration */ @@ -3876,7 +4556,11 @@ export interface components { * @example prod-gateway-01 */ gatewayId: string; - /** @description Optional metadata for the deployment. Supported keys include `endpointUrl`, `vhostMain`, and `vhostSandbox`. */ + /** + * @description Optional metadata for the deployment. Supported keys are `endpointUrl`, `vhostMain` and `vhostSandbox` for REST APIs. An LLM provider deployment takes `endpointUrl` too, which replaces the backend it routes to, and `upstreamAuthValue` — the credential that deployment authenticates to the provider's upstream with, so one provider can run on several gateways against different accounts with the same vendor. It must be given as a `{{ secret "handle" }}` reference naming a secret of this organization, never the credential itself. Like the provider's own `auth.value` it is write-only: it is never returned by any read of a deployment, so replacing it means giving a new one rather than editing what came back. Omitting it leaves the provider's own credential in place. + * + * `upstreamAuthHeader` names the header that credential is sent in. It is read only alongside `upstreamAuthValue`, and only where the upstream authenticates with an api-key — basic and bearer send `Authorization` by definition. + */ metadata?: { [key: string]: unknown; }; @@ -3970,15 +4654,14 @@ export interface components { */ baseDeploymentId?: string | null; /** - * @description Build this deployment runs, such as `2026-01-31-2`. Every REST API deployment - * has one: `base: build` runs the build it names, and `base: current` stores what - * it renders as a build and runs that. + * @description Build this deployment runs, such as `2026-01-31-2`. REST API, LLM provider, + * LLM proxy and MCP proxy deployments all have one: `base: build` runs the build + * it names, and `base: current` stores what it renders as a build and runs that. * - * Null for artifact kinds that have no builds — MCP proxy, LLM and event API - * deployments — including one promoted from another deployment, which reuses that - * deployment's rendered artifact. Also null once the build it ran has been pruned. - * Null means only that no build can be named; the deployment keeps its own - * rendered artifact either way. + * Null for artifact kinds that have no builds, and for a deployment promoted from + * another, which reuses that deployment's rendered artifact. Also null once the + * build it ran has been pruned. Null means only that no build can be named; the + * deployment keeps its own rendered artifact either way. * @example 2026-01-31-2 */ buildId?: string | null; @@ -5855,7 +6538,7 @@ export interface components { "application/json": components["schemas"]["Error"]; }; }; - /** @description Conflict. code identifies which: PUBLICATION_STATE_CONFLICT when the action is not valid for the publication's current status (unpublish needs a published or deprecated listing, deprecate a published one), or PUBLICATION_PORTAL_CONFLICT when the API Portal refused the change — another API already holds this handle or display name and version, or the listing still has subscriptions or active API keys and so cannot be removed. A portal conflict does not clear on retry: the operator renames, removes the consumers, or deprecates instead. A state conflict clears once the publication is in a status that allows the action. No local state was changed. */ + /** @description Conflict. code identifies which: PUBLICATION_STATE_CONFLICT when the action is not valid for the publication's current status (unpublish needs a published or deprecated listing, deprecate a published one), PUBLICATION_DRAFT_CHANGED when the draft was saved while a publish of it was in flight (the API Portal may already hold the earlier copy while the local listing is unchanged; review the draft and publish again to bring them in line), or PUBLICATION_PORTAL_CONFLICT when the API Portal refused the change — another API already holds this handle or display name and version, or the listing still has subscriptions or active API keys and so cannot be removed. A portal conflict does not clear on retry: the operator renames, removes the consumers, or deprecates instead. A state conflict clears once the publication is in a status that allows the action. No local state was changed by any of these; only a draft-changed conflict can leave the API Portal ahead of it until the next publish. */ PublicationConflict: { headers: { [name: string]: unknown; @@ -7493,6 +8176,595 @@ export interface operations { 503: components["responses"]["PortalUnavailable"]; }; }; + ListGraphQLAPIs: { + parameters: { + query: { + /** @description **Project ID** consisting of the **handle** (unique slug identifier) of the Project whose resources should be returned. */ + projectId: components["parameters"]["projectId-Q"]; + /** @description Maximum number of items to return per page. */ + limit?: components["parameters"]["limit-Q"]; + /** @description Zero-based index of the first item to return. */ + offset?: components["parameters"]["offset-Q"]; + /** @description Field to sort the collection by. An unrecognized value falls back to the default sort (createdAt). */ + sortBy?: components["parameters"]["sortBy-Q"]; + /** @description Sort direction applied to `sortBy`. */ + sortOrder?: components["parameters"]["sortOrder-Q"]; + /** @description Case-insensitive substring filter matched against the resource display name and id (handle). */ + query?: components["parameters"]["query-Q"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description GraphQL APIs retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GraphQLAPIListResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + CreateGraphQLAPI: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * @description GraphQL API object that needs to be added, as `multipart/form-data` — see + * GraphQLAPIMultipartRequest. This is the only accepted content type, even + * when `schemaSource` is `inline`, `url`, or `introspection` and no file is + * being uploaded, so that every schema-source variant is expressed the + * same way. + */ + requestBody: { + content: { + "multipart/form-data": components["schemas"]["GraphQLAPIMultipartRequest"]; + }; + }; + responses: { + /** @description GraphQL API created successfully */ + 201: { + headers: { + Location: components["headers"]["Location"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GraphQLAPI"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + GetGraphQLAPI: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description GraphQL API retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GraphQLAPIDetail"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + UpdateGraphQLAPI: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + }; + cookie?: never; + }; + /** + * @description As `multipart/form-data` only — see GraphQLAPIMultipartRequest and the + * note on `POST /graphql-apis`. + */ + requestBody: { + content: { + "multipart/form-data": components["schemas"]["GraphQLAPIMultipartRequest"]; + }; + }; + responses: { + /** @description GraphQL API updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GraphQLAPI"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + DeleteGraphQLAPI: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description GraphQL API deleted successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + GetGraphQLAPISDL: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description SDL retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GraphQLAPISDLResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + ValidateGraphQLSchema: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * @description As `multipart/form-data` only, following the same convention as + * `POST /graphql-apis` — see `GraphQLAPIMultipartRequest`. + */ + requestBody: { + content: { + "multipart/form-data": components["schemas"]["ValidateGraphQLSchemaMultipartRequest"]; + }; + }; + responses: { + /** @description Schema resolution attempted — see `resolved` for the outcome. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ValidateGraphQLSchemaResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + GetGraphQLAPIGateways: { + parameters: { + query?: { + /** @description Maximum number of items to return per page. */ + limit?: components["parameters"]["limit-Q"]; + /** @description Zero-based index of the first item to return. */ + offset?: components["parameters"]["offset-Q"]; + }; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of gateways associated with the API, including deployment details */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RESTAPIGatewayListResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + AddGatewaysToGraphQLAPI: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + }; + cookie?: never; + }; + /** @description List of gateways to associate with the API */ + requestBody?: { + content: { + "application/json": components["schemas"]["AddGatewayToRESTAPIRequest"][]; + }; + }; + responses: { + /** @description List of all gateways associated with the API, including deployment details */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RESTAPIGatewayListResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + CreateGraphQLAPIKey: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + }; + cookie?: never; + }; + /** @description API key creation request */ + requestBody: { + content: { + "application/json": components["schemas"]["CreateAPIKeyRequest"]; + }; + }; + responses: { + /** @description API key created successfully */ + 201: { + headers: { + Location: components["headers"]["Location"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateAPIKeyResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + 503: components["responses"]["GatewayConnectionUnavailable"]; + }; + }; + UpdateGraphQLAPIKey: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + /** + * @description The unique name/identifier of the API key + * @example my-api-key + */ + apiKeyId: string; + }; + cookie?: never; + }; + /** @description API key update request */ + requestBody: { + content: { + "application/json": components["schemas"]["UpdateAPIKeyRequest"]; + }; + }; + responses: { + /** @description API key updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UpdateAPIKeyResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + 503: components["responses"]["GatewayConnectionUnavailable"]; + }; + }; + RevokeGraphQLAPIKey: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + /** + * @description The unique name/identifier of the API key to revoke + * @example my-api-key + */ + apiKeyId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description API key revoked successfully (no content) */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + 503: components["responses"]["GatewayConnectionUnavailable"]; + }; + }; + GetGraphQLAPIDeployments: { + parameters: { + query?: { + /** @description **Gateway ID** consisting of the **handle** (unique slug identifier) of the Gateway to filter status by. */ + gatewayId?: components["parameters"]["gatewayId-Q"]; + /** @description Filter deployments by status (DEPLOYED, UNDEPLOYED, DEPLOYING, UNDEPLOYING, FAILED, or ARCHIVED) */ + status?: components["parameters"]["deploymentStatus-Q"]; + /** @description Maximum number of items to return per page. */ + limit?: components["parameters"]["limit-Q"]; + /** @description Zero-based index of the first item to return. */ + offset?: components["parameters"]["offset-Q"]; + }; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deployments retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeploymentListResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + DeployGraphQLAPI: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + }; + cookie?: never; + }; + /** @description Deployment request with gateway ID, base reference, and metadata */ + requestBody: { + content: { + "application/json": components["schemas"]["DeployRequest"]; + }; + }; + responses: { + /** @description GraphQL API deployed successfully */ + 201: { + headers: { + Location: components["headers"]["Location"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeploymentResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + GetGraphQLAPIDeployment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + /** @description The UUID of the deployment */ + deploymentId: components["parameters"]["deploymentId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deployment metadata retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeploymentResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + DeleteGraphQLAPIDeployment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + /** @description The UUID of the deployment */ + deploymentId: components["parameters"]["deploymentId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deployment deleted successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["DeploymentActiveConflict"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + UndeployGraphQLAPIDeployment: { + parameters: { + query: { + /** @description Handle (URL-friendly slug) of the gateway (validated against deployment's bound gateway) */ + gatewayId: string; + }; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + /** @description UUID of the deployment to undeploy */ + deploymentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Undeploy initiated successfully. Returns the deployment with initial status UNDEPLOYING. Poll status for final result. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeploymentResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + RestoreGraphQLAPIDeployment: { + parameters: { + query: { + /** @description Handle (URL-friendly slug) of the gateway (validated against deployment's bound gateway) */ + gatewayId: string; + }; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + /** @description UUID of the deployment to restore (must be ARCHIVED or UNDEPLOYED) */ + deploymentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Restore initiated successfully. Returns the deployment with initial status DEPLOYING. Poll status for final result. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeploymentResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 500: components["responses"]["InternalServerError"]; + }; + }; listLLMProviderTemplates: { parameters: { query?: { @@ -10719,7 +11991,7 @@ export interface operations { * If omitted, all types are returned. * @example LlmProxy,LlmProvider */ - type?: ("RestApi" | "LlmProvider" | "LlmProxy")[]; + type?: ("RestApi" | "LlmProvider" | "LlmProxy" | "GraphQLApi")[]; /** @description Maximum number of items to return per page. */ limit?: components["parameters"]["limit-Q"]; /** @description Zero-based index of the first item to return. */ diff --git a/tests/framework/core/cleanup/cleanup.go b/tests/framework/core/cleanup/cleanup.go index 8055bbca30..66da830133 100644 --- a/tests/framework/core/cleanup/cleanup.go +++ b/tests/framework/core/cleanup/cleanup.go @@ -60,6 +60,7 @@ var ( // gateway-controller's own Mcp resource (the "/mcp-proxies" collection), distinct from // KindMCPServer below (platform-api's separate MCP server registration). KindMCPProxy = Kind{Name: "mcp-proxy", Order: 52} + KindGraphQLAPI = Kind{Name: "graphql-api", Order: 53} KindMCPServer = Kind{Name: "mcp-server", Order: 55} KindPolicy = Kind{Name: "policy", Order: 60} KindSharedScope = Kind{Name: "shared-scope", Order: 70} diff --git a/tests/framework/suites/it/features/graphql_api_keys.feature b/tests/framework/suites/it/features/graphql_api_keys.feature new file mode 100644 index 0000000000..a4355a8c3a --- /dev/null +++ b/tests/framework/suites/it/features/graphql_api_keys.feature @@ -0,0 +1,299 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# -------------------------------------------------------------------- + +# Mirrors api_keys.feature (RestApi) scenario-for-scenario against the +# /graphql-apis/{id}/api-keys endpoints. The API key CRUD logic itself is +# shared, kind-agnostic service code (utils.APIKeyService), so this exists +# primarily to guard the gateway-controller wiring specific to the GraphQL +# path: the OpenAPI spec paths, the ServerInterface methods, and the +# relativeRoles auth-route map entries in cmd/controller/main.go. +@graphql-api-keys +Feature: GraphQL API key management + As an API administrator + I want to manage API keys for GraphQL APIs + So that I can control access through API key authentication + + Background: + Given the gateway services are running + And I authenticate using basic auth as "admin" + + Scenario: Complete API key lifecycle - generate, list, regenerate, and revoke + Given I generate a unique value from "test-key" and store it as "apiKeyName" + Given I generate a unique value from "graphql-apikey-lifecycle-api" and store it as "graphqlApiKeyName1_1" + Given I generate a unique API context from "/graphql-apikey-lifecycle" and store it as "graphqlApiKeyContext1_1" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlApiKeyName1_1} | + | spec.displayName | GraphQL-APIKey-Lifecycle-API | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlApiKeyContext1_1} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName1_1}/api-keys" with body: + """ + { + "name": "${CTX:apiKeyName}" + } + """ + Then the response status should be 201 + And the response should be valid JSON + And the JSON response field "status" should be "success" + And the JSON response should have field "apiKey" + And the JSON response should have field "apiKey.name" + And the JSON response should have field "apiKey.apiKey" + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName1_1}/api-keys" + Then the response status should be 200 + And the response should be valid JSON + And the JSON response field "status" should be "success" + And the response body should contain "${CTX:apiKeyName}" + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName1_1}/api-keys/${CTX:apiKeyName}/regenerate" with body: + """ + {} + """ + Then the response status should be 200 + And the response should be valid JSON + And the JSON response field "status" should be "success" + And the JSON response should have field "apiKey.apiKey" + When I send a "DELETE" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName1_1}/api-keys/${CTX:apiKeyName}" + Then the response status should be 200 + And the response should be valid JSON + And the JSON response field "status" should be "success" + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName1_1}/api-keys" + Then the response status should be 200 + And the response should be valid JSON + And the response body should not contain "${CTX:apiKeyName}" + When I delete the GraphQL API "${CTX:graphqlApiKeyName1_1}" + Then the response should be successful + + Scenario: Generate multiple API keys for same GraphQL API + Given I generate a unique value from "key-one" and store it as "firstKeyName" + And I generate a unique value from "key-two" and store it as "secondKeyName" + Given I generate a unique value from "graphql-multi-key-api" and store it as "graphqlApiKeyName2_1" + Given I generate a unique API context from "/graphql-multi-key" and store it as "graphqlApiKeyContext2_1" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlApiKeyName2_1} | + | spec.displayName | GraphQL-Multi-Key-API | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlApiKeyContext2_1} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName2_1}/api-keys" with body: + """ + { + "name": "${CTX:firstKeyName}" + } + """ + Then the response status should be 201 + And the response should be valid JSON + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName2_1}/api-keys" with body: + """ + { + "name": "${CTX:secondKeyName}" + } + """ + Then the response status should be 201 + And the response should be valid JSON + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName2_1}/api-keys" + Then the response status should be 200 + And the response should be valid JSON + And the response body should contain "${CTX:firstKeyName}" + And the response body should contain "${CTX:secondKeyName}" + When I delete the GraphQL API "${CTX:graphqlApiKeyName2_1}" + Then the response should be successful + + Scenario: List API keys for GraphQL API with no keys returns empty list + Given I generate a unique value from "graphql-no-keys-api" and store it as "graphqlApiKeyName3_1" + Given I generate a unique API context from "/graphql-no-keys" and store it as "graphqlApiKeyContext3_1" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlApiKeyName3_1} | + | spec.displayName | GraphQL-No-Keys-API | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlApiKeyContext3_1} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName3_1}/api-keys" + Then the response status should be 200 + And the response should be valid JSON + And the JSON response field "status" should be "success" + When I delete the GraphQL API "${CTX:graphqlApiKeyName3_1}" + Then the response should be successful + + Scenario: Generate API key for non-existent GraphQL API returns 404 + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/non-existent-api-id/api-keys" with body: + """ + { + "name": "test-key" + } + """ + Then the response status should be 404 + And the response should be valid JSON + And the JSON response field "status" should be "error" + + Scenario: Generate API key without name auto-generates name + Given I generate a unique value from "graphql-key-validation-api" and store it as "graphqlApiKeyName5_1" + Given I generate a unique API context from "/graphql-key-validation" and store it as "graphqlApiKeyContext5_1" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlApiKeyName5_1} | + | spec.displayName | GraphQL-Key-Validation-API | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlApiKeyContext5_1} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName5_1}/api-keys" with body: + """ + {} + """ + Then the response status should be 201 + And the response should be valid JSON + And the JSON response field "status" should be "success" + And the JSON response should have field "apiKey" + When I delete the GraphQL API "${CTX:graphqlApiKeyName5_1}" + Then the response should be successful + + Scenario: List API keys for non-existent GraphQL API returns 404 + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/non-existent-api-id/api-keys" + Then the response status should be 404 + And the response should be valid JSON + And the JSON response field "status" should be "error" + + Scenario: List API keys with invalid GraphQL API ID format returns 404 + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/invalid@api!id/api-keys" + Then the response status should be 404 + And the response should be valid JSON + + Scenario: Revoke API key with invalid formats returns 404 + When I send a "DELETE" request to the "gateway-controller" service at "/graphql-apis/invalid@api/api-keys/invalid@key" + Then the response status should be 404 + And the response should be valid JSON + + Scenario: Revoke non-existent API key returns success (idempotent) + Given I generate a unique value from "graphql-revoke-error-api" and store it as "graphqlApiKeyName9_1" + Given I generate a unique API context from "/graphql-revoke-error" and store it as "graphqlApiKeyContext9_1" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlApiKeyName9_1} | + | spec.displayName | GraphQL-Revoke-Error-API | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlApiKeyContext9_1} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + When I send a "DELETE" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName9_1}/api-keys/non-existent-key" + Then the response status should be 200 + And the response should be valid JSON + When I delete the GraphQL API "${CTX:graphqlApiKeyName9_1}" + Then the response should be successful + + Scenario: Regenerate API key for non-existent GraphQL API returns 404 + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/non-existent-api-id/api-keys/test-key/regenerate" with body: + """ + {} + """ + Then the response status should be 404 + And the response should be valid JSON + And the JSON response field "status" should be "error" + + Scenario: Regenerate non-existent API key returns 404 + Given I generate a unique value from "graphql-test-regenerate-api" and store it as "graphqlApiKeyName11_1" + Given I generate a unique API context from "/graphql-test-regen" and store it as "graphqlApiKeyContext11_1" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlApiKeyName11_1} | + | spec.displayName | GraphQL-Test-Regenerate-Api | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlApiKeyContext11_1} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName11_1}/api-keys/non-existent-key/regenerate" with body: + """ + {} + """ + Then the response status should be 404 + When I delete the GraphQL API "${CTX:graphqlApiKeyName11_1}" + Then the response should be successful + + Scenario: Regenerate API key with invalid ID formats returns 404 + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/invalid@api/api-keys/invalid@key/regenerate" with body: + """ + {} + """ + Then the response status should be 404 + And the response should be valid JSON + + Scenario: Generate API key with invalid JSON body returns error + Given I generate a unique value from "graphql-invalid-json-key-api" and store it as "graphqlApiKeyName13_1" + Given I generate a unique API context from "/graphql-invalid-json-key" and store it as "graphqlApiKeyContext13_1" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlApiKeyName13_1} | + | spec.displayName | GraphQL-Invalid-JSON-Key-API | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlApiKeyContext13_1} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName13_1}/api-keys" with body: + """ + { this is not valid json + """ + Then the response should be a client error + And the response should be valid JSON + When I delete the GraphQL API "${CTX:graphqlApiKeyName13_1}" + Then the response should be successful + + Scenario: API key with special characters in name + Given I generate a unique value from "graphql-special-char-key-api" and store it as "graphqlApiKeyName14_1" + Given I generate a unique API context from "/graphql-special-char-key" and store it as "graphqlApiKeyContext14_1" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlApiKeyName14_1} | + | spec.displayName | GraphQL-Special-Char-Key-API | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlApiKeyContext14_1} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName14_1}/api-keys" with body: + """ + { + "name": "my-api-key_v1" + } + """ + Then the response status should be 201 + And the response should be valid JSON + And the JSON response field "status" should be "success" + When I delete the GraphQL API "${CTX:graphqlApiKeyName14_1}" + Then the response should be successful + + Scenario: List API keys with pagination parameters + Given I generate a unique value from "graphql-paginated-keys-api" and store it as "graphqlApiKeyName15_1" + Given I generate a unique API context from "/graphql-paginated-keys" and store it as "graphqlApiKeyContext15_1" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlApiKeyName15_1} | + | spec.displayName | GraphQL-Paginated-Keys-API | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlApiKeyContext15_1} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName15_1}/api-keys?limit=10&offset=0" + Then the response status should be 200 + And the response should be valid JSON + And the JSON response field "status" should be "success" + When I delete the GraphQL API "${CTX:graphqlApiKeyName15_1}" + Then the response should be successful diff --git a/tests/framework/suites/it/features/graphql_deploy.feature b/tests/framework/suites/it/features/graphql_deploy.feature new file mode 100644 index 0000000000..5a6570dd88 --- /dev/null +++ b/tests/framework/suites/it/features/graphql_deploy.feature @@ -0,0 +1,649 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# -------------------------------------------------------------------- + +@graphql-deploy +Feature: GraphQL API CRUD and connectivity + As a gateway operator + I want to deploy a GraphQL API configuration against the gateway-controller + So that I can verify routing, policy enforcement, and CRUD behavior + + Background: + Given the gateway services are running + And I authenticate using basic auth as "admin" + + Scenario: Deploy a GraphQL API and invoke it successfully + Given I generate a unique resource name from "graphql-e2e" and store it as "graphqlName" + And I generate a unique value from "graphql-e2e" and store it as "graphqlDisplayName" + And I generate a unique API version from "graphql-e2e" and store it as "graphqlVersion" + And I generate a unique API context from "/graphql-e2e" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | ${CTX:graphqlVersion} | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + And the response should be valid JSON + And the JSON response field "status.state" should be "deployed" + + And I send a "POST" request to "${CTX:graphqlContext}" until status 200 with body: + """ + {"query":"{ countries { code name } }"} + """ + + When I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ countries { code name } }"} + """ + Then the response should be successful + And the response should be valid JSON + And the response body should contain "{ countries { code name } }" + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "status" should be "success" + + Scenario: Update a deployed GraphQL API's upstream, and verify the change takes effect + Given I generate a unique resource name from "graphql-update" and store it as "graphqlName" + And I generate a unique value from "graphql-update" and store it as "graphqlDisplayName" + And I generate a unique API version from "graphql-update" and store it as "graphqlVersion" + And I generate a unique API context from "/graphql-update" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | ${CTX:graphqlVersion} | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + + When I update GraphQL API "${CTX:graphqlName}" from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} v2 | + | spec.version | ${CTX:graphqlVersion} | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql-v2 | + Then the response should be successful + And the response should be valid JSON + And the JSON response field "spec.displayName" should be "${CTX:graphqlDisplayName} v2" + + And I send a "POST" request to "${CTX:graphqlContext}" until status 200 with body: + """ + {"query":"{ countries { code } }"} + """ + + When I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ countries { code } }"} + """ + Then the response should be successful + And the response body should contain "/graphql-v2" + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + # There is no separate "mutation support" at the gateway-controller/Envoy layer, and the + # artifact carries no schema field at all: a mutation is just another POST body sent to + # the same single route a query uses, since GraphQL always resolves to exactly one route, + # never a per-operation list like REST's. This proves that pass-through directly, against + # an artifact byte-for-byte identical in shape to every query-only artifact in this file. + Scenario: A mutation query is proxied through the same single route as a query, unmodified + Given I generate a unique resource name from "graphql-mutation" and store it as "graphqlName" + And I generate a unique value from "graphql-mutation" and store it as "graphqlDisplayName" + And I generate a unique API version from "graphql-mutation" and store it as "graphqlVersion" + And I generate a unique API context from "/graphql-mutation" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | ${CTX:graphqlVersion} | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + + And I send a "POST" request to "${CTX:graphqlContext}" until status 200 with body: + """ + {"query":"mutation { createPost(input: { title: \"hi\", body: \"hi\" }) { post { id } } }"} + """ + + When I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"mutation { createPost(input: { title: \"hi\", body: \"hi\" }) { post { id } } }"} + """ + Then the response should be successful + And the response should be valid JSON + And the response body should contain "createPost(input:" + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + Scenario: Deploy a GraphQL API with labels and verify they are stored + Given I generate a unique resource name from "graphql-labeled" and store it as "graphqlName" + And I generate a unique value from "graphql-labeled" and store it as "graphqlDisplayName" + And I generate a unique API version from "graphql-labeled" and store it as "graphqlVersion" + And I generate a unique API context from "/graphql-labeled" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | ${CTX:graphqlVersion} | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + | metadata.labels | {"environment":"production","team":"graphql-team"} | + Then the response should be successful + And the response should be valid JSON + And the JSON response field "status.state" should be "deployed" + + When I get the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + And the JSON response field "metadata.labels.environment" should be "production" + And the JSON response field "metadata.labels.team" should be "graphql-team" + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + Scenario: Deploy a GraphQL API with invalid labels (spaces in keys) fails + Given I generate a unique resource name from "graphql-invalid-labels" and store it as "graphqlName" + And I generate a unique API version from "graphql-invalid-labels" and store it as "graphqlVersion" + And I generate a unique API context from "/graphql-invalid-labels" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | Invalid Labels GraphQL | + | spec.version | ${CTX:graphqlVersion} | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + | metadata.labels | {"Invalid Key":"value"} | + Then the response should be a client error + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Configuration validation failed" + + # ==================== LIST ==================== + + Scenario: List GraphQL APIs when none exist matching a filter + Given I generate a unique value from "no-such-graphql-api" and store it as "missingDisplayName" + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis?displayName=${CTX:missingDisplayName}" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "status" should be "success" + And the JSON response field "count" should be 0 + + Scenario: List GraphQL APIs with pagination parameters + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis?limit=10&offset=0" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "status" should be "success" + + Scenario: List GraphQL APIs filtered by displayName + Given I generate a unique resource name from "graphql-filter" and store it as "graphqlName" + And I generate a unique value from "UniqueGraphQLFilterTest" and store it as "graphqlDisplayName" + And I generate a unique API version from "graphql-filter" and store it as "graphqlVersion" + And I generate a unique API context from "/graphql-filter" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | ${CTX:graphqlVersion} | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis?displayName=${CTX:graphqlDisplayName}" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "status" should be "success" + And the response body should contain "${CTX:graphqlDisplayName}" + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + Scenario: List GraphQL APIs filtered by version + Given I generate a unique resource name from "graphql-version-filter" and store it as "graphqlName" + And I generate a unique value from "graphql-version-filter" and store it as "graphqlDisplayName" + And I generate a unique API version from "graphql-version-filter" and store it as "graphqlVersion" + And I generate a unique API context from "/graphql-version-filter" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | ${CTX:graphqlVersion} | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis?version=${CTX:graphqlVersion}" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "status" should be "success" + And the response body should contain "${CTX:graphqlDisplayName}" + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + Scenario: List GraphQL APIs filtered by context + Given I generate a unique resource name from "graphql-context-filter" and store it as "graphqlName" + And I generate a unique value from "graphql-context-filter" and store it as "graphqlDisplayName" + And I generate a unique API version from "graphql-context-filter" and store it as "graphqlVersion" + And I generate a unique API context from "/graphql-context-filter" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | ${CTX:graphqlVersion} | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis?context=${CTX:graphqlContext}" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "status" should be "success" + And the response body should contain "${CTX:graphqlName}" + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + # ==================== GET / UPDATE / DELETE ERROR CASES ==================== + + Scenario: Get a non-existent GraphQL API returns 404 + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/non-existent-graphql-id" + Then the response status code should be 404 + And the response should be valid JSON + And the JSON response field "status" should be "error" + + Scenario: Get a GraphQL API with an invalid ID format returns 404 + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/invalid@graphql#id" + Then the response status code should be 404 + And the response should be valid JSON + + Scenario: Update a non-existent GraphQL API returns 404 + Given I generate a unique resource name from "graphql-nonexistent-update" and store it as "graphqlName" + When I update GraphQL API "${CTX:graphqlName}" from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | Nonexistent GraphQL Update | + | spec.version | v1.0 | + | spec.context | /nonexistent-graphql-update | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response status code should be 404 + And the response should be valid JSON + + Scenario: Update a GraphQL API with a metadata.name that does not match the path id returns 400 + Given I generate a unique resource name from "graphql-mismatch" and store it as "graphqlName" + And I generate a unique resource name from "graphql-mismatch-other" and store it as "otherGraphqlName" + And I generate a unique API context from "/graphql-mismatch" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | Mismatch GraphQL | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + + When I update GraphQL API "${CTX:graphqlName}" from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:otherGraphqlName} | + | spec.displayName | Mismatch GraphQL | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response status code should be 400 + And the response should be valid JSON + And the response body should contain "does not match path id" + + # A rejected mismatched update must not persist under either handle: the original + # resource must still exist unchanged under its own path handle... + When I get the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + And the JSON response field "spec.displayName" should be "Mismatch GraphQL" + + # ...and the rejected body's handle must never have been created. + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/${CTX:otherGraphqlName}" + Then the response status code should be 404 + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + Scenario: Update a GraphQL API with an invalid JSON body returns an error + When I send a "PUT" request to the "gateway-controller" service at "/graphql-apis/some-graphql" with body: + """ + { invalid json body + """ + Then the response should be a client error + And the response should be valid JSON + + Scenario: Delete a non-existent GraphQL API returns 404 + When I delete the GraphQL API "non-existent-graphql-delete" + Then the response status code should be 404 + And the response should be valid JSON + And the JSON response field "status" should be "error" + + # ==================== CREATE VALIDATION ERROR CASES ==================== + + Scenario: Deploy a GraphQL API with missing required fields returns an error + Given I generate a unique resource name from "graphql-incomplete" and store it as "graphqlName" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | Incomplete GraphQL | + Then the response should be a client error + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Configuration validation failed" + + Scenario: Deploy a GraphQL API with a context that does not start with '/' returns 400 + Given I generate a unique resource name from "graphql-bad-context" and store it as "graphqlName" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | Bad Context GraphQL | + | spec.version | v1.0 | + | spec.context | bad-context-no-slash | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be a client error + And the response should be valid JSON + And the response body should contain "context must start with" + + Scenario: Deploy a GraphQL API without an upstream returns 400 + Given I generate a unique resource name from "graphql-missing-upstream" and store it as "graphqlName" + And I generate a unique API context from "/graphql-missing-upstream" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | Missing Upstream GraphQL | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + Then the response should be a client error + And the response should be valid JSON + And the response body should contain "Upstream URL is required" + + Scenario: Deploy a GraphQL API with an invalid upstream URL scheme returns 400 + Given I generate a unique resource name from "graphql-bad-scheme" and store it as "graphqlName" + And I generate a unique API context from "/graphql-bad-scheme" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | Bad Scheme GraphQL | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | ftp://testbench:3000/graphql | + Then the response should be a client error + And the response should be valid JSON + And the response body should contain "must use http or https" + + Scenario: Deploy a GraphQL API with an upstream URL missing a host returns 400 + Given I generate a unique resource name from "graphql-no-host" and store it as "graphqlName" + And I generate a unique API context from "/graphql-no-host" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | No Host GraphQL | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http:///graphql | + Then the response should be a client error + And the response should be valid JSON + And the response body should contain "must include a host" + + Scenario: Deploy a GraphQL API with an unsupported kind value returns an error + Given I generate a unique resource name from "graphql-wrong-kind" and store it as "graphqlName" + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis" with body: + """ + { + "apiVersion": "gateway.api-platform.wso2.com/v1", + "kind": "NotAGraphQLApi", + "metadata": { "name": "${CTX:graphqlName}" }, + "spec": { + "displayName": "Wrong Kind GraphQL", + "version": "v1.0", + "context": "/wrong-kind-graphql", + "upstream": { "main": { "url": "http://testbench:3000/graphql" } } + } + } + """ + Then the response should be a client error + And the response should be valid JSON + + Scenario: Deploy a GraphQL API with an invalid JSON body returns an error + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis" with body: + """ + { this is not valid json content + """ + Then the response should be a client error + And the response should be valid JSON + + Scenario: Deploying a duplicate GraphQL API returns a conflict + Given I generate a unique resource name from "graphql-duplicate" and store it as "graphqlName" + And I generate a unique value from "graphql-duplicate" and store it as "graphqlDisplayName" + And I generate a unique API context from "/graphql-duplicate" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response status code should be 409 + And the response should be valid JSON + And the JSON response field "status" should be "error" + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + # ==================== ROUTING CORRECTNESS: SINGLE POST ROUTE ONLY ==================== + + Scenario: A GraphQL API exposes exactly one POST route - other methods to the same context are not routed + Given I generate a unique resource name from "graphql-single-route" and store it as "graphqlName" + And I generate a unique value from "graphql-single-route" and store it as "graphqlDisplayName" + And I generate a unique API context from "/graphql-single-route" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + + And I send a "POST" request to "${CTX:graphqlContext}" until status 200 with body: + """ + {"query":"{ ping }"} + """ + + When I send a "GET" request to "${CTX:graphqlContext}" + Then the response status code should be 404 + + When I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ ping }"} + """ + Then the response should be successful + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + # ==================== POLICY ENFORCEMENT ==================== + # jwt-auth against the "mock-jwks" issuer is covered separately in + # graphql_policies.feature, which runs in the mcp-policies block where the + # mcp-jwt-auth.toml overlay registers that issuer. This block's platform-gateway + # has no such overlay, so a jwt-auth scenario here would 500 on an unknown issuer. + + Scenario: A GraphQL API with set-headers correctly mutates the proxied response + Given I generate a unique resource name from "graphql-set-headers" and store it as "graphqlName" + And I generate a unique value from "graphql-set-headers" and store it as "graphqlDisplayName" + And I generate a unique API context from "/graphql-set-headers" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + | spec.policies | [{"name":"set-headers","version":"v1","params":{"response":{"headers":[{"name":"X-GraphQL-Test-Marker","value":"graphql-policy-works"}]}}}] | + Then the response should be successful + + And I send a "POST" request to "${CTX:graphqlContext}" until status 200 with body: + """ + {"query":"{ ping }"} + """ + + When I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ ping }"} + """ + Then the response should be successful + And the response header "X-GraphQL-Test-Marker" should be "graphql-policy-works" + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + # CONFIRMED via this test (not assumed): a GraphQL API resolves to exactly one POST route + # with an Exact path/method match, so an OPTIONS preflight never matches that route at + # all - Envoy 404s before the cors policy, or any policy, ever runs. REST's cors preflight + # support (which relies on an explicit "- method: OPTIONS" entry in operations[]) does not + # carry over to GraphQL; there is no operations[] to add one to. This is a genuine, current + # limitation, not yet supported. + Scenario: A GraphQL API with cors does not handle a preflight request - confirmed limitation + Given I generate a unique resource name from "graphql-cors" and store it as "graphqlName" + And I generate a unique value from "graphql-cors" and store it as "graphqlDisplayName" + And I generate a unique API context from "/graphql-cors" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + | spec.policies | [{"name":"cors","version":"v1","params":{"allowedOrigins":["http://example.com"],"allowedMethods":["POST"],"allowedHeaders":["Content-Type"]}}] | + Then the response should be successful + + And I send a "POST" request to "${CTX:graphqlContext}" until status 200 with body: + """ + {"query":"{ ping }"} + """ + + When I clear all headers + And I set header "Origin" to "http://example.com" + And I set header "Access-Control-Request-Method" to "POST" + And I send a "OPTIONS" request to "${CTX:graphqlContext}" + Then the response status code should be 404 + + When I clear all headers + And I authenticate using basic auth as "admin" + And I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + Scenario: A GraphQL API with basic-ratelimit enforces its configured limit + Given I generate a unique resource name from "graphql-ratelimit" and store it as "graphqlName" + And I generate a unique value from "graphql-ratelimit" and store it as "graphqlDisplayName" + And I generate a unique API context from "/graphql-ratelimit" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + | spec.policies | [{"name":"basic-ratelimit","version":"v1","params":{"limits":[{"requests":3,"duration":"1h"}]}}] | + Then the response should be successful + + # The readiness wait below itself counts as the 1st request against the 3-request + # limit - only 2 more successful requests remain before the limit trips. + And I send a "POST" request to "${CTX:graphqlContext}" until status 200 with body: + """ + {"query":"{ ping }"} + """ + + When I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ ping }"} + """ + Then the response should be successful + When I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ ping }"} + """ + Then the response should be successful + When I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ ping }"} + """ + Then the response status code should be 429 + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + # ==================== SANDBOX ROUTING ==================== + # GraphQLAPIConfigData has no vhosts override field (unlike RestApi) - the transformer + # always resolves sandbox routing against the gateway's own default main/sandbox vhosts. + # The gateway's built-in default sandbox vhost is the wildcard pattern "sandbox-*", not a + # fixed literal like REST's per-API "sandbox.local" example, so the Host header used below + # must actually match "sandbox-*" (start with "sandbox-"). + Scenario: A GraphQL API with a sandbox upstream routes sandbox-host traffic to the sandbox cluster + Given I generate a unique resource name from "graphql-sandbox" and store it as "graphqlName" + And I generate a unique value from "graphql-sandbox" and store it as "graphqlDisplayName" + And I generate a unique API context from "/graphql-sandbox" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + | spec.upstream.sandbox.url | http://testbench:3000/sandbox/graphql | + Then the response should be successful + + And I send a "POST" request to "${CTX:graphqlContext}" until status 200 with body: + """ + {"query":"{ ping }"} + """ + + When I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ ping }"} + """ + Then the response should be successful + And the JSON response field "path" should be "/graphql" + + When I clear all headers + And I set request host to "sandbox-graphql-e2e" + And I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ ping }"} + """ + Then the response should be successful + And the JSON response field "path" should be "/sandbox/graphql" + + When I clear all headers + And I authenticate using basic auth as "admin" + And I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful diff --git a/tests/framework/suites/it/features/graphql_policies.feature b/tests/framework/suites/it/features/graphql_policies.feature new file mode 100644 index 0000000000..20cd263c5c --- /dev/null +++ b/tests/framework/suites/it/features/graphql_policies.feature @@ -0,0 +1,63 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# -------------------------------------------------------------------- + +# Runs in the mcp-policies block, whose platform-gateway carries the +# mcp-jwt-auth.toml overlay registering the "mock-jwks" issuer that jwt-auth +# validates against. graphql_deploy.feature's own policy scenarios (set-headers, +# cors, basic-ratelimit) need no such overlay and stay in gateway-core. +@graphql-policies +Feature: GraphQL API policy enforcement requiring a registered JWT issuer + As a gateway operator + I want to secure a GraphQL API with jwt-auth + So that only requests carrying a valid token reach the upstream + + Background: + Given the gateway services are running + And I authenticate using basic auth as "admin" + + Scenario: A GraphQL API with jwt-auth rejects requests without a token and accepts a valid one + Given I generate a unique resource name from "graphql-jwt-auth" and store it as "graphqlName" + And I generate a unique value from "graphql-jwt-auth" and store it as "graphqlDisplayName" + And I generate a unique API context from "/graphql-jwt-auth" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + | spec.policies | [{"name":"jwt-auth","version":"v1","params":{"issuers":["mock-jwks"]}}] | + Then the response should be successful + + And I send a "POST" request to "${CTX:graphqlContext}" until status 401 with body: + """ + {"query":"{ ping }"} + """ + + When I get a JWT token from the mock JWKS server with issuer "http://testbench:3001/token" and store it as "graphqlToken" + And I set header "Authorization" to "Bearer ${CTX:graphqlToken}" + And I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ ping }"} + """ + Then the response status code should be 200 + + When I clear all headers + And I authenticate using basic auth as "admin" + And I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful diff --git a/tests/framework/suites/it/it-suite.yaml b/tests/framework/suites/it/it-suite.yaml index ba959dc69f..8c78a70cae 100644 --- a/tests/framework/suites/it/it-suite.yaml +++ b/tests/framework/suites/it/it-suite.yaml @@ -383,6 +383,14 @@ blocks: - name: mcp-deploy features: - features/mcp_deploy.feature + - name: graphql-deploy + tags: "gateway-version>=1.2.0" + features: + - features/graphql_deploy.feature + - name: graphql-api-keys + tags: "gateway-version>=1.2.0" + features: + - features/graphql_api_keys.feature - name: oauth2-auth tags: "gateway-version>1.2.0" features: @@ -577,6 +585,10 @@ blocks: tags: "gateway-version>=1.2.0" features: - features/jwt_auth_scope_claim_rules.feature + - name: graphql-policies + tags: "gateway-version>=1.2.0" + features: + - features/graphql_policies.feature - name: mcp-auth-gateway-host parallel: 1 diff --git a/tests/framework/suites/it/resources/templates/graphql-api.yaml b/tests/framework/suites/it/resources/templates/graphql-api.yaml new file mode 100644 index 0000000000..c2f61ed8b8 --- /dev/null +++ b/tests/framework/suites/it/resources/templates/graphql-api.yaml @@ -0,0 +1,5 @@ +apiVersion: ${VALUE:apiVersion} +kind: GraphQLApi +metadata: + name: ${VALUE:name} +spec: {} diff --git a/tests/framework/suites/it/steps/common/common_test.go b/tests/framework/suites/it/steps/common/common_test.go index e31b859b6b..88b0afd5c6 100644 --- a/tests/framework/suites/it/steps/common/common_test.go +++ b/tests/framework/suites/it/steps/common/common_test.go @@ -274,6 +274,7 @@ func TestCanonicalResourceTemplates(t *testing.T) { root := filepath.Join(filepath.Dir(source), "..", "..", "resources", "templates") want := map[string]string{ + "graphql-api.yaml": "GraphQLApi", "llm-provider-template.yaml": "LlmProviderTemplate", "llm-provider.yaml": "LlmProvider", "llm-proxy.yaml": "LlmProxy", diff --git a/tests/framework/suites/it/steps/platformgateway/gateway.go b/tests/framework/suites/it/steps/platformgateway/gateway.go index f60db9851e..d1d89617fe 100644 --- a/tests/framework/suites/it/steps/platformgateway/gateway.go +++ b/tests/framework/suites/it/steps/platformgateway/gateway.go @@ -972,12 +972,12 @@ func (g *Gateway) register(sc *godog.ScenarioContext) { g.createResource) sc.Step(`^I create API with JSON configuration:$`, g.createJSONAPI) g.registerResourceTemplateSteps(sc) - sc.Step(`^I get the (API|LLM provider|LLM provider template|MCP proxy|LLM proxy) "([^"]*)"$`, + sc.Step(`^I get the (API|LLM provider|LLM provider template|MCP proxy|LLM proxy|GraphQL API) "([^"]*)"$`, g.getResource) sc.Step(`^I list all (LLM providers|LLM provider templates|MCP proxies|LLM proxies)$`, g.listResources) sc.Step(`^I update the (API|LLM provider|LLM provider template|MCP proxy|LLM proxy) "([^"]*)" with configuration:$`, g.updateResource) - sc.Step(`^I delete the (API|LLM provider|LLM provider template|MCP proxy|LLM proxy) "([^"]*)"$`, g.deleteResource) + sc.Step(`^I delete the (API|LLM provider|LLM provider template|MCP proxy|LLM proxy|GraphQL API) "([^"]*)"$`, g.deleteResource) sc.Step(`^I send a "([^"]*)" request to the "([^"]*)" service at "([^"]*)"$`, g.serviceRequest) sc.Step(`^I send a "([^"]*)" request to the "([^"]*)" service at "([^"]*)" with body:$`, g.serviceRequestWithBody) sc.Step(`^I send a "([^"]*)" request to the "([^"]*)" service at "([^"]*)" until status (\d+)$`, @@ -1295,6 +1295,7 @@ var resourceKinds = map[string]struct{ declared, collection string }{ "LLM provider template": {"LlmProviderTemplate", collLLMTemplates}, "MCP proxy": {"Mcp", collMCPProxies}, "LLM proxy": {"LlmProxy", collLLMProxies}, + "GraphQL API": {"GraphQLApi", collGraphQLAPIs}, } // kindFromDefinition returns the top-level kind a definition declares. @@ -2145,6 +2146,7 @@ const ( collLLMTemplates = "/llm-provider-templates" collMCPProxies = "/mcp-proxies" collLLMProxies = "/llm-proxies" + collGraphQLAPIs = "/graphql-apis" ) // mutateResource creates, replaces or removes a controller resource and waits for the change @@ -2307,6 +2309,8 @@ func cleanupKindForCollection(collection string) (cleanup.Kind, bool) { return cleanup.KindLLMProviderTemplate, true case collMCPProxies: return cleanup.KindMCPProxy, true + case collGraphQLAPIs: + return cleanup.KindGraphQLAPI, true default: return cleanup.Kind{}, false } diff --git a/tests/framework/suites/it/steps/platformgateway/resource_template.go b/tests/framework/suites/it/steps/platformgateway/resource_template.go index 79c9bae766..6ec1bc3b8e 100644 --- a/tests/framework/suites/it/steps/platformgateway/resource_template.go +++ b/tests/framework/suites/it/steps/platformgateway/resource_template.go @@ -36,9 +36,9 @@ import ( ) func (g *Gateway) registerResourceTemplateSteps(sc *godog.ScenarioContext) { - sc.Step(`^I create (API|LLM provider|LLM provider template|MCP proxy|LLM proxy) from "([^"]*)" with values:$`, + sc.Step(`^I create (API|LLM provider|LLM provider template|MCP proxy|LLM proxy|GraphQL API) from "([^"]*)" with values:$`, g.createResourceFromTemplate) - sc.Step(`^I update (API|LLM provider|LLM provider template|MCP proxy|LLM proxy) "([^"]*)" from "([^"]*)" with values:$`, + sc.Step(`^I update (API|LLM provider|LLM provider template|MCP proxy|LLM proxy|GraphQL API) "([^"]*)" from "([^"]*)" with values:$`, g.updateResourceFromTemplate) sc.Step(`^the first attached LLM provider policy should be "([^"]*)" version "([^"]*)"$`, g.firstLLMProviderPolicyIs) diff --git a/tests/framework/suites/it/steps/steps_test.go b/tests/framework/suites/it/steps/steps_test.go index 5c0beabd74..f96a60dcb2 100644 --- a/tests/framework/suites/it/steps/steps_test.go +++ b/tests/framework/suites/it/steps/steps_test.go @@ -218,6 +218,7 @@ func TestCanonicalResourceTemplates(t *testing.T) { root := filepath.Join(filepath.Dir(source), "..", "resources", "templates") want := map[string]string{ + "graphql-api.yaml": "GraphQLApi", "llm-provider-template.yaml": "LlmProviderTemplate", "llm-provider.yaml": "LlmProvider", "llm-proxy.yaml": "LlmProxy", diff --git a/tests/mock-servers/mock-graphql-backend/Dockerfile b/tests/mock-servers/mock-graphql-backend/Dockerfile new file mode 100644 index 0000000000..480a69fbdd --- /dev/null +++ b/tests/mock-servers/mock-graphql-backend/Dockerfile @@ -0,0 +1,38 @@ +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +FROM golang:1.26.5-alpine AS builder + +WORKDIR /app + +COPY go.mod ./ +RUN go mod download + +COPY main.go ./ + +RUN CGO_ENABLED=0 GOOS=linux go build -o mock-graphql-backend . + +FROM alpine:3.24 + +RUN apk --no-cache add ca-certificates + +WORKDIR /app + +COPY --from=builder /app/mock-graphql-backend . + +EXPOSE 8080 + +CMD ["./mock-graphql-backend"] diff --git a/tests/mock-servers/mock-graphql-backend/go.mod b/tests/mock-servers/mock-graphql-backend/go.mod new file mode 100644 index 0000000000..1aae1a132a --- /dev/null +++ b/tests/mock-servers/mock-graphql-backend/go.mod @@ -0,0 +1,3 @@ +module github.com/wso2/api-platform/tests/mock-servers/mock-graphql-backend + +go 1.26.5 diff --git a/tests/mock-servers/mock-graphql-backend/main.go b/tests/mock-servers/mock-graphql-backend/main.go new file mode 100644 index 0000000000..4adc201583 --- /dev/null +++ b/tests/mock-servers/mock-graphql-backend/main.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package main + +import ( + "io" + "log" + "net/http" + "strconv" +) + +// handleGraphQL echoes the raw request body back verbatim as the response body. +// +// This stands in for a real GraphQL server in E2E tests that need to assert on the +// gateway's response-phase analytics enrichment: the shared sample-service fixture +// always wraps every response in a fixed {method,path,query,headers,body} envelope, +// so it can never produce a literal top-level "errors" array the way a real GraphQL +// server does. Echoing the request body verbatim lets a test fully control the +// response shape (including a GraphQL-style {"data":...,"errors":[...]} body) simply +// by choosing what it sends as the request. +func handleGraphQL(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + w.Header().Set("Content-Type", "application/json") + if codeStr := r.URL.Query().Get("statusCode"); codeStr != "" { + if code, err := strconv.Atoi(codeStr); err == nil && code >= 100 && code <= 999 { + w.WriteHeader(code) + } + } + + log.Printf("Mock GraphQL Backend: echoing request body (%d bytes)", len(body)) + w.Write(body) +} + +func handleHealth(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +func main() { + http.HandleFunc("/health", handleHealth) + http.HandleFunc("/", handleGraphQL) + + log.Println("Mock GraphQL Backend listening on :8080") + log.Println("Endpoints:") + log.Println(" ANY /* - echoes the request body back verbatim as the response body") + log.Println(" GET /health - health check") + + if err := http.ListenAndServe(":8080", nil); err != nil { + log.Fatal(err) + } +}