diff --git a/cli/src/cmd/gateway/apply.go b/cli/src/cmd/gateway/apply.go index 94d248172a..e8b7273964 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 { @@ -243,3 +243,4 @@ func resourceExists(client *gateway.Client, handler gateway.ResourceHandler, han // Any other status code is an error — delegate to centralized formatter return false, utils.FormatHTTPError("query", resp, "Gateway Controller") } + 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..d2427ea476 --- /dev/null +++ b/cli/src/cmd/gateway/graphqlapi/apikey/commands_test.go @@ -0,0 +1,425 @@ +/* + * 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" + "os" + "path/filepath" + "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", + }, + }, + }) +} + +// writeAPIKeyCR writes an ApiKey CR file (the shape runCreateCommand parses +// via gateway.ParseResourceCR) to a temp directory and returns its path. +func writeAPIKeyCR(t *testing.T, name, parentKind, parentName string, extraSpec string) string { + t.Helper() + + extra := "" + if extraSpec != "" { + extra = "\n" + extraSpec + } + content := "apiVersion: gateway.api-platform.wso2.com/v1\n" + + "kind: ApiKey\n" + + "metadata:\n" + + " name: " + name + "\n" + + "spec:\n" + + " parentRef:\n" + + " kind: " + parentKind + "\n" + + " name: " + parentName + extra + "\n" + + path := filepath.Join(t.TempDir(), "apikey.yaml") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write ApiKey CR fixture: %v", err) + } + return path +} + +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) + + createFilePath = writeAPIKeyCR(t, "smoke-key-1", "GraphQLApi", "countries-graphql-api", "") + + 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 CR's metadata.name, got %v", gotBody["name"]) + } +} + +func TestRunCreateCommand_ForwardsExtraSpecFields(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","message":"ok"}`)) + }) + writeGatewayConfig(t, server.URL) + + createFilePath = writeAPIKeyCR(t, "smoke-key-2", "GraphQLApi", "countries-graphql-api", " apiKey: external-key-value-that-is-at-least-36-characters-long") + + if err := runCreateCommand(newTestCommand()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotBody["apiKey"] != "external-key-value-that-is-at-least-36-characters-long" { + t.Fatalf("expected spec.apiKey to be forwarded into the request body, got %v", gotBody["apiKey"]) + } + if _, present := gotBody["parentRef"]; present { + t.Fatalf("parentRef must not be forwarded into the request body, got %v", gotBody) + } +} + +func TestRunCreateCommand_RejectsNonGraphQLParentKind(t *testing.T) { + testutil.WithTempHome(t) + + createFilePath = writeAPIKeyCR(t, "smoke-key-3", "RestApi", "some-rest-api", "") + + err := runCreateCommand(newTestCommand()) + if err == nil || !strings.Contains(err.Error(), "RestApi") { + t.Fatalf("expected a parentRef.kind validation error mentioning RestApi, got %v", err) + } +} + +func TestRunCreateCommand_RequiresParentRefName(t *testing.T) { + testutil.WithTempHome(t) + + path := filepath.Join(t.TempDir(), "apikey.yaml") + content := "apiVersion: gateway.api-platform.wso2.com/v1\n" + + "kind: ApiKey\n" + + "metadata:\n" + + " name: smoke-key-4\n" + + "spec:\n" + + " parentRef:\n" + + " kind: GraphQLApi\n" + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write fixture: %v", err) + } + createFilePath = path + + err := runCreateCommand(newTestCommand()) + if err == nil || !strings.Contains(err.Error(), "parentRef.name") { + t.Fatalf("expected a parentRef.name validation error, 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..2ebe75acf2 --- /dev/null +++ b/cli/src/cmd/gateway/graphqlapi/apikey/create.go @@ -0,0 +1,151 @@ +/* + * 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 ( + // kindApiKey is the CR kind accepted by the create command. + kindApiKey = "ApiKey" + // parentKindGraphQLApi is the only parentRef.kind supported by this command, + // which targets the /graphql-apis/{id}/api-keys management endpoint. + parentKindGraphQLApi = "GraphQLApi" +) + +const ( + CreateCmdLiteral = "create" + CreateCmdExample = `# Generate an API key from a CR file +ap gateway graphql-api api-key create --file api-key.yaml +ap gateway graphql-api api-key create -f api-key.json + +# The file is an ApiKey custom resource, e.g.: +# apiVersion: gateway.api-platform.wso2.com/v1 +# kind: ApiKey +# metadata: +# name: countries-key-acme +# spec: +# parentRef: +# kind: GraphQLApi +# name: countries-graphql-api +# expiresIn: +# duration: 30 +# unit: days` +) + +var createFilePath string + +var createCmd = &cobra.Command{ + Use: CreateCmdLiteral, + Short: "Generate an API key for a GraphQL API", + Long: "Generates a new API key from an ApiKey custom resource file (YAML or JSON). The parent GraphQL API is taken from spec.parentRef.name and the key name from metadata.name. 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.FlagFile, &createFilePath, "", "Path to the ApiKey CR file (YAML or JSON)") + createCmd.MarkFlagRequired(utils.FlagFile) +} + +func runCreateCommand(cmd *cobra.Command) error { + if strings.TrimSpace(createFilePath) == "" { + return fmt.Errorf("--%s is required", utils.FlagFile) + } + + cr, err := gateway.ParseResourceCR(createFilePath, kindApiKey) + if err != nil { + return err + } + + // The parent GraphQL API id comes from spec.parentRef.name; the key name from + // metadata.name. Everything else in the spec is forwarded as the request body. + apiID, err := graphQLAPIParentName(cr) + if err != nil { + return err + } + + body := map[string]interface{}{} + for k, v := range cr.Spec { + if k == "parentRef" { + continue + } + body[k] = v + } + body["name"] = cr.Metadata.Name + + 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(apiID)) + resp, err := client.Post(endpoint, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("failed to create API key: %w", err) + } + + fmt.Printf("API key %q generated successfully.\n", cr.Metadata.Name) + return gateway.PrintJSONResponse(resp) +} + +// graphQLAPIParentName extracts and validates spec.parentRef.name, requiring the +// parent kind to be GraphQLApi (or unset) since this command targets the GraphQL +// API api-key endpoint. +func graphQLAPIParentName(cr *gateway.ResourceCR) (string, error) { + parentRef, ok := cr.Spec["parentRef"].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("invalid %s: spec.parentRef is required", kindApiKey) + } + + if kind, ok := parentRef["kind"].(string); ok && strings.TrimSpace(kind) != "" && kind != parentKindGraphQLApi { + return "", fmt.Errorf("unsupported spec.parentRef.kind %q: 'ap gateway graphql-api api-key' only supports %s", kind, parentKindGraphQLApi) + } + + name, ok := parentRef["name"].(string) + if !ok || strings.TrimSpace(name) == "" { + return "", fmt.Errorf("invalid %s: spec.parentRef.name is required", kindApiKey) + } + + return strings.TrimSpace(name), nil +} 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..eb6c648c04 --- /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 from a CR file +ap gateway graphql-api api-key create --file api-key.yaml` +) + +// 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/event-gateway/gateway-controller/cmd/controller/main.go b/event-gateway/gateway-controller/cmd/controller/main.go index dcee5ace3b..e14c1bdc1f 100644 --- a/event-gateway/gateway-controller/cmd/controller/main.go +++ b/event-gateway/gateway-controller/cmd/controller/main.go @@ -430,7 +430,15 @@ func main() { policyVersionResolver := utils.NewLoadedPolicyVersionResolver(policyDefinitions) restTransformer := transform.NewRestAPITransformer(&cfg.Router, cfg, policyDefinitions) llmTransformer := transform.NewLLMTransformer(configStore, db, &cfg.Router, cfg, policyDefinitions, policyVersionResolver) - transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer) + // 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 already reachable here via the + // shared *handlers.APIServer. 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, graphqlTransformer) policyManager.SetTransformers(transformerRegistry) xdsTranslator.SetTransformers(map[string]models.ConfigTransformer{ @@ -438,6 +446,7 @@ func main() { "Mcp": transformerRegistry, "LlmProvider": transformerRegistry, "LlmProxy": transformerRegistry, + "GraphQLApi": transformerRegistry, }) loadedAPIs := configStore.GetAll() diff --git a/gateway/examples/blog-graphql-api.yaml b/gateway/examples/blog-graphql-api.yaml new file mode 100644 index 0000000000..ad420284e5 --- /dev/null +++ b/gateway/examples/blog-graphql-api.yaml @@ -0,0 +1,52 @@ +# -------------------------------------------------------------------- +# 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 +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..711e2f4763 --- /dev/null +++ b/gateway/examples/countries-graphql-api.yaml @@ -0,0 +1,36 @@ +# -------------------------------------------------------------------- +# 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 +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 6ea472d7e5..e6803967d4 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: @@ -3016,6 +3515,151 @@ 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 + 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: @@ -5165,6 +5809,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: Certificate Management diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index 1baaf9f679..e1f0527ad5 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -430,7 +430,8 @@ func main() { policyVersionResolver := utils.NewLoadedPolicyVersionResolver(policyDefinitions) restTransformer := transform.NewRestAPITransformer(&cfg.Router, cfg, policyDefinitions) llmTransformer := transform.NewLLMTransformer(configStore, db, &cfg.Router, cfg, policyDefinitions, policyVersionResolver) - transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer) + graphqlTransformer := transform.NewGraphQLAPITransformer(&cfg.Router, cfg, policyDefinitions) + transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer, graphqlTransformer) policyManager.SetTransformers(transformerRegistry) // Wire the same transformer into the Envoy xDS translator so Envoy routes are built from the @@ -446,6 +447,7 @@ func main() { "Mcp": transformerRegistry, "LlmProvider": transformerRegistry, "LlmProxy": transformerRegistry, + "GraphQLApi": transformerRegistry, }) // Load runtime configs from existing API configurations on startup. @@ -865,6 +867,12 @@ func generateAuthConfig(config *config.Config) (commonmodels.AuthConfig, error) "PUT /mcp-proxies/{id}": {"admin", "developer"}, "DELETE /mcp-proxies/{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"}, @@ -889,6 +897,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 d9286dca1c..26e4a8a8e0 100644 --- a/gateway/gateway-controller/cmd/controller/main_test.go +++ b/gateway/gateway-controller/cmd/controller/main_test.go @@ -736,10 +736,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/pkg/api/handlers/graphql_api_handler.go b/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go new file mode 100644 index 0000000000..e94d24a9f4 --- /dev/null +++ b/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go @@ -0,0 +1,585 @@ +/* + * 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 + } + 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 { + 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 { + 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 { + 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. +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..d3b2164f40 --- /dev/null +++ b/gateway/gateway-controller/pkg/api/handlers/graphql_apikey_handler_test.go @@ -0,0 +1,551 @@ +/* + * 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) +} + +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/resource_response.go b/gateway/gateway-controller/pkg/api/handlers/resource_response.go index ccb8999484..a11324974d 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 c2c071374e..2d2cd48790 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -79,6 +79,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" @@ -406,6 +432,12 @@ const ( UpstreamAuthAuthTypeOther UpstreamAuthAuthType = "other" ) +// Defines values for ListGraphQLAPIsParamsStatus. +const ( + ListGraphQLAPIsParamsStatusDeployed ListGraphQLAPIsParamsStatus = "deployed" + ListGraphQLAPIsParamsStatusUndeployed ListGraphQLAPIsParamsStatus = "undeployed" +) + // Defines values for ListLLMProvidersParamsStatus. const ( ListLLMProvidersParamsStatusDeployed ListLLMProvidersParamsStatus = "deployed" @@ -699,6 +731,76 @@ 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 + 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 @@ -1745,6 +1847,24 @@ type ValidationError struct { Message *string `json:"message,omitempty" yaml:"message,omitempty"` } +// 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 @@ -1843,6 +1963,21 @@ type ListSubscriptionsParamsStatus string // 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 @@ -2332,6 +2467,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) @@ -2599,6 +2764,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) { @@ -4539,6 +5056,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) @@ -4601,270 +5128,289 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "/22YsUw64QEA", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/gateway-controller/pkg/models/data_version.go b/gateway/gateway-controller/pkg/models/data_version.go index 802431fe05..71914f8ca6 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, } diff --git a/gateway/gateway-controller/pkg/models/data_version_test.go b/gateway/gateway-controller/pkg/models/data_version_test.go index 8aec9c6e94..358e49de47 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, } diff --git a/gateway/gateway-controller/pkg/models/stored_config.go b/gateway/gateway-controller/pkg/models/stored_config.go index 4decf15948..6e8f89a559 100644 --- a/gateway/gateway-controller/pkg/models/stored_config.go +++ b/gateway/gateway-controller/pkg/models/stored_config.go @@ -39,6 +39,7 @@ const ( KindLlmProxy ArtifactKind = "LlmProxy" KindLlmProvider ArtifactKind = "LlmProvider" KindLlmProviderTemplate ArtifactKind = "LlmProviderTemplate" + KindGraphQLApi ArtifactKind = "GraphQLApi" ) // DesiredState represents the intended deployment state of an API configuration. @@ -135,6 +136,8 @@ func apiVersionOf(cfg any) string { return string(sc.ApiVersion) case api.MCPProxyConfiguration: return string(sc.ApiVersion) + case api.GraphQLAPI: + return string(sc.ApiVersion) } return "" } @@ -159,12 +162,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 } // TODO: enable when policies are supported for WebSubHub @@ -176,6 +184,8 @@ func (c *StoredConfig) GetMetadata() *api.Metadata { switch cfg := c.Configuration.(type) { case api.RestAPI: return &cfg.Metadata + case api.GraphQLAPI: + return &cfg.Metadata } return nil } @@ -185,6 +195,8 @@ func (c *StoredConfig) GetLabels() *map[string]string { switch cfg := c.Configuration.(type) { case api.RestAPI: return cfg.Metadata.Labels + case api.GraphQLAPI: + return cfg.Metadata.Labels } return nil } @@ -194,6 +206,8 @@ func (c *StoredConfig) GetAnnotations() *map[string]string { switch cfg := c.Configuration.(type) { case api.RestAPI: 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 2a1e6539d7..6f30670569 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.postgres.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.postgres.sql @@ -1,5 +1,5 @@ -- PostgreSQL Schema for Gateway-Controller API Configurations --- Version: 4 +-- Version: 5 -- Base table for all artifact types CREATE TABLE IF NOT EXISTS artifacts ( @@ -68,6 +68,18 @@ CREATE TABLE IF NOT EXISTS mcp_proxies ( 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 3f94f14395..88e265e866 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql @@ -1,5 +1,5 @@ -- SQLite Schema for Gateway-Controller API Configurations --- Version: 4 +-- Version: 5 -- Base table for all artifact types (REST APIs, WebSub APIs, LLM Providers, LLM Proxies, MCP Proxies) CREATE TABLE IF NOT EXISTS artifacts ( @@ -68,6 +68,18 @@ CREATE TABLE IF NOT EXISTS mcp_proxies ( 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. @@ -281,4 +293,4 @@ CREATE TABLE IF NOT EXISTS secrets ( -- Note: webhook_secrets (per-API HMAC secrets for the websub-hmac-auth policy) -- is also owned by event-gateway/gateway-controller/pkg/dbschema — see note above. -PRAGMA user_version = 4; +PRAGMA user_version = 5; 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 756eaffdc1..371c423bd9 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql @@ -1,5 +1,5 @@ -- SQL Server Schema for Gateway-Controller API Configurations --- Version: 4 +-- Version: 5 -- -- Portable counterpart of gateway-controller-db.postgres.sql. Type mapping: -- TEXT (keyed) -> NVARCHAR(64)/NVARCHAR(255) (NVARCHAR(MAX) cannot be indexed; @@ -100,6 +100,19 @@ CREATE TABLE dbo.mcp_proxies ( 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/sql_store.go b/gateway/gateway-controller/pkg/storage/sql_store.go index 656c338b67..c5412f56d6 100644 --- a/gateway/gateway-controller/pkg/storage/sql_store.go +++ b/gateway/gateway-controller/pkg/storage/sql_store.go @@ -287,6 +287,8 @@ func kindToResourceTable(kind string) (string, error) { return "llm_proxies", nil case "Mcp": return "mcp_proxies", nil + case "GraphQLApi": + return "graphql_apis", nil default: if table, ok := extraResourceTables[kind]; ok { return table, nil @@ -306,7 +308,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"} +var builtinResourceTables = []string{"rest_apis", "llm_providers", "llm_proxies", "mcp_proxies", "graphql_apis"} // RegisterKindResourceTable registers the resource table name for an artifact // kind not known to core. Intended to be called from an init() (or equivalent @@ -370,6 +372,16 @@ func unmarshalSourceConfig(cfg *models.StoredConfig, jsonData string) error { return fmt.Errorf("failed to unmarshal source configuration: %w", err) } cfg.SourceConfiguration = 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) diff --git a/gateway/gateway-controller/pkg/storage/sqlite.go b/gateway/gateway-controller/pkg/storage/sqlite.go index 839425bd15..522362557b 100644 --- a/gateway/gateway-controller/pkg/storage/sqlite.go +++ b/gateway/gateway-controller/pkg/storage/sqlite.go @@ -73,7 +73,7 @@ func newSQLiteStorage(dbPath string, logger *slog.Logger) (*SQLiteStorage, error return storage, nil } -const currentSchemaVersion = 4 +const currentSchemaVersion = 5 // initSchema creates the database schema if it doesn't exist func (s *SQLiteStorage) initSchema() error { diff --git a/gateway/gateway-controller/pkg/storage/sqlite_test.go b/gateway/gateway-controller/pkg/storage/sqlite_test.go index 2f786c20fc..d4260b0e95 100644 --- a/gateway/gateway-controller/pkg/storage/sqlite_test.go +++ b/gateway/gateway-controller/pkg/storage/sqlite_test.go @@ -78,7 +78,7 @@ func TestSQLiteStorage_SchemaInitialization(t *testing.T) { var version int err = storage.db.QueryRow("PRAGMA user_version").Scan(&version) assert.NilError(t, err) - assert.Equal(t, version, 4) // Current schema version + assert.Equal(t, version, 5) // Current schema version // Verify tables exist tables := []string{ @@ -87,6 +87,7 @@ func TestSQLiteStorage_SchemaInitialization(t *testing.T) { "llm_providers", "llm_proxies", "mcp_proxies", + "graphql_apis", "certificates", "llm_provider_templates", "api_keys", @@ -120,14 +121,14 @@ func TestSQLiteStorage_RejectsUnsupportedSchemaVersion(t *testing.T) { storage := store.(*sqlStore) // Set schema version to an unsupported value - _, err = storage.db.Exec("PRAGMA user_version = 5") + _, err = storage.db.Exec("PRAGMA user_version = 6") assert.NilError(t, err) storage.db.Close() // Reopen — should fail with unsupported version error _, err = NewStorage(BackendConfig{Type: "sqlite", SQLitePath: dbPath}, logger) assert.Assert(t, err != nil) - assert.ErrorContains(t, err, "failed to initialize schema: unsupported schema version 5, expected 4; delete the database to recreate") + assert.ErrorContains(t, err, "failed to initialize schema: unsupported schema version 6, expected 5; delete the database to recreate") } func TestSQLiteStorage_DeleteConfig_NotFound(t *testing.T) { diff --git a/gateway/gateway-controller/pkg/transform/graphql.go b/gateway/gateway-controller/pkg/transform/graphql.go new file mode 100644 index 0000000000..84fd34ddbe --- /dev/null +++ b/gateway/gateway-controller/pkg/transform/graphql.go @@ -0,0 +1,204 @@ +/* + * 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) + policyChain := sdkChainToModel(injected) + + // 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 := resolveUpstreamCluster(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.Manual { + mainAutoHostRewrite = false + } + + mainRouteKey := xds.GenerateRouteName("POST", apiData.Context, apiData.Version, "", mainVhost) + rdc.Routes[mainRouteKey] = &models.Route{ + Method: "POST", + Path: fullPath, + PathMatchType: "Exact", + Vhost: mainVhost, + AutoHostRewrite: mainAutoHostRewrite, + Upstream: models.RouteUpstream{ + ClusterKey: mainUpstream.ClusterKey, + Default: &mainUpstreamInfo, + }, + } + rdc.PolicyChains[mainRouteKey] = policyChain + + // 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 := resolveUpstreamCluster(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.Manual { + sbAutoHostRewrite = false + } + + sandboxRouteKey := xds.GenerateRouteName("POST", apiData.Context, apiData.Version, "", sandboxVhost) + rdc.Routes[sandboxRouteKey] = &models.Route{ + Method: "POST", + Path: fullPath, + PathMatchType: "Exact", + Vhost: sandboxVhost, + AutoHostRewrite: sbAutoHostRewrite, + Upstream: models.RouteUpstream{ + ClusterKey: sbUpstream.ClusterKey, + Default: &sbUpstreamInfo, + }, + } + rdc.PolicyChains[sandboxRouteKey] = policyChain + } + + 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 +// resolveUpstreamCluster, 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 ee4f4b4191..2d8660803f 100644 --- a/gateway/gateway-controller/pkg/transform/registry.go +++ b/gateway/gateway-controller/pkg/transform/registry.go @@ -26,13 +26,14 @@ import ( // Registry dispatches StoredConfig → RuntimeDeployConfig by API kind. type Registry struct { - restT *RestAPITransformer - llmT *LLMTransformer + restT *RestAPITransformer + llmT *LLMTransformer + graphqlT *GraphQLAPITransformer } // NewRegistry creates a new transformer Registry. -func NewRegistry(restT *RestAPITransformer, llmT *LLMTransformer) *Registry { - return &Registry{restT: restT, llmT: llmT} +func NewRegistry(restT *RestAPITransformer, llmT *LLMTransformer, graphqlT *GraphQLAPITransformer) *Registry { + return &Registry{restT: restT, llmT: llmT, graphqlT: graphqlT} } // Transform converts a StoredConfig to a RuntimeDeployConfig using the appropriate transformer. @@ -42,6 +43,11 @@ func (r *Registry) Transform(cfg *models.StoredConfig) (*models.RuntimeDeployCon return r.restT.Transform(cfg) case "LlmProvider", "LlmProxy": return r.llmT.Transform(cfg) + case "GraphQLApi": + if r.graphqlT == nil { + return nil, fmt.Errorf("unsupported kind for runtime config: %s", cfg.Kind) + } + return r.graphqlT.Transform(cfg) default: return nil, fmt.Errorf("unsupported kind for runtime config: %s", cfg.Kind) } diff --git a/gateway/gateway-controller/pkg/transform/restapi.go b/gateway/gateway-controller/pkg/transform/restapi.go index 953f053802..975c9d3d36 100644 --- a/gateway/gateway-controller/pkg/transform/restapi.go +++ b/gateway/gateway-controller/pkg/transform/restapi.go @@ -124,7 +124,7 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim } // Build main upstream cluster - mainUpstream, err := t.addUpstreamCluster(rdc, "main", &apiData.Upstream.Main, apiData.UpstreamDefinitions) + mainUpstream, err := resolveUpstreamCluster(rdc, "main", &apiData.Upstream.Main, apiData.UpstreamDefinitions) if err != nil { return nil, fmt.Errorf("failed to resolve main upstream: %w", err) } @@ -293,7 +293,7 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim // Add sandbox upstream and update sandbox routes if present if hasSandbox { - sbUpstream, err := t.addUpstreamCluster(rdc, "sandbox", apiData.Upstream.Sandbox, apiData.UpstreamDefinitions) + sbUpstream, err := resolveUpstreamCluster(rdc, "sandbox", apiData.Upstream.Sandbox, apiData.UpstreamDefinitions) if err != nil { return nil, fmt.Errorf("failed to resolve sandbox upstream: %w", err) } @@ -467,8 +467,13 @@ func (r *upstreamClusterResult) UpstreamInfo() policyenginev1.UpstreamInfo { } } -// addUpstreamCluster resolves an upstream and adds it to the RuntimeDeployConfig. -func (t *RestAPITransformer) addUpstreamCluster( +// resolveUpstreamCluster resolves an upstream and adds it to the RuntimeDeployConfig. +// It is a package-level function (not a method) because it does not depend on any +// RestAPITransformer state — this lets GraphQLAPITransformer call it directly to +// resolve its own main/sandbox upstream clusters, sharing the exact same resolution +// logic (URL/ref lookup, port defaulting, TLS detection, connect-timeout resolution) +// instead of duplicating it. +func resolveUpstreamCluster( rdc *models.RuntimeDeployConfig, upstreamName string, up *api.Upstream, 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..c5a100c1da --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/graphql_deployment.go @@ -0,0 +1,152 @@ +/* + * 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.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/tests/integration/schema_test.go b/gateway/gateway-controller/tests/integration/schema_test.go index 7d22caef6b..920900d81c 100644 --- a/gateway/gateway-controller/tests/integration/schema_test.go +++ b/gateway/gateway-controller/tests/integration/schema_test.go @@ -104,7 +104,7 @@ func TestSchemaInitialization(t *testing.T) { var version int err := rawDB.QueryRow("PRAGMA user_version").Scan(&version) assert.NoError(t, err) - assert.Equal(t, 4, version, "Schema version should be 4") + assert.Equal(t, 5, version, "Schema version should be 5") }) // Verify artifacts table exists @@ -160,7 +160,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"} + tables := []string{"rest_apis", "llm_providers", "llm_proxies", "mcp_proxies", "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 279ca81fda..eb324edcd3 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 @@ -291,6 +293,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: + - "8088: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/gateway/it/features/graphql-api-keys.feature b/gateway/it/features/graphql-api-keys.feature new file mode 100644 index 0000000000..6c5d719b8f --- /dev/null +++ b/gateway/it/features/graphql-api-keys.feature @@ -0,0 +1,388 @@ +# -------------------------------------------------------------------- +# 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 features/api-keys.feature (RestApi) scenario-for-scenario against the +# /graphql-apis/{id}/api-keys endpoints added to close the gap identified in +# docs/specs/graphql-api-support.md - 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 (a route missing from that map is denied as +# a 404 before ever reaching the handler - the exact bug this suite would have +# caught). +Feature: GraphQL API Key Management Operations + 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" + + # ==================== API KEY LIFECYCLE - SUCCESS PATH ==================== + + Scenario: Complete API key lifecycle - generate, list, regenerate, and revoke + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: graphql-apikey-lifecycle-api + spec: + displayName: GraphQL APIKey Lifecycle API + version: v1.0 + context: /graphql-apikey-lifecycle + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + + # Generate API key + When I send a POST request to the "gateway-controller" service at "/graphql-apis/graphql-apikey-lifecycle-api/api-keys" with body: + """ + { + "name": "test-key-1" + } + """ + 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" + And I wait for 2 seconds + + # List API keys - should have 1 key + When I send a GET request to the "gateway-controller" service at "/graphql-apis/graphql-apikey-lifecycle-api/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 "test-key-1" + + # Regenerate API key + When I send a POST request to the "gateway-controller" service at "/graphql-apis/graphql-apikey-lifecycle-api/api-keys/test-key-1/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" + + # Revoke API key + When I send a DELETE request to the "gateway-controller" service at "/graphql-apis/graphql-apikey-lifecycle-api/api-keys/test-key-1" + Then the response status should be 200 + And the response should be valid JSON + And the JSON response field "status" should be "success" + + # Verify key is revoked - list should be empty + When I send a GET request to the "gateway-controller" service at "/graphql-apis/graphql-apikey-lifecycle-api/api-keys" + Then the response status should be 200 + And the response should be valid JSON + And the response body should not contain "test-key-1" + + # Cleanup + When I delete the GraphQL API "graphql-apikey-lifecycle-api" + Then the response should be successful + + Scenario: Generate multiple API keys for same GraphQL API + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: graphql-multi-key-api + spec: + displayName: GraphQL Multi Key API + version: v1.0 + context: /graphql-multi-key + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + + # Generate first key + When I send a POST request to the "gateway-controller" service at "/graphql-apis/graphql-multi-key-api/api-keys" with body: + """ + { + "name": "key-one" + } + """ + Then the response status should be 201 + And the response should be valid JSON + + # Generate second key + When I send a POST request to the "gateway-controller" service at "/graphql-apis/graphql-multi-key-api/api-keys" with body: + """ + { + "name": "key-two" + } + """ + Then the response status should be 201 + And the response should be valid JSON + And I wait for 2 seconds + + # List should show both keys + When I send a GET request to the "gateway-controller" service at "/graphql-apis/graphql-multi-key-api/api-keys" + Then the response status should be 200 + And the response should be valid JSON + And the response body should contain "key-one" + And the response body should contain "key-two" + + # Cleanup + When I delete the GraphQL API "graphql-multi-key-api" + Then the response should be successful + + Scenario: List API keys for GraphQL API with no keys returns empty list + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: graphql-no-keys-api + spec: + displayName: GraphQL No Keys API + version: v1.0 + context: /graphql-no-keys + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + When I send a GET request to the "gateway-controller" service at "/graphql-apis/graphql-no-keys-api/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" + # Cleanup + When I delete the GraphQL API "graphql-no-keys-api" + Then the response should be successful + + # ==================== GENERATE API KEY - ERROR CASES ==================== + + 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 + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: graphql-key-validation-api + spec: + displayName: GraphQL Key Validation API + version: v1.0 + context: /graphql-key-validation + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + When I send a POST request to the "gateway-controller" service at "/graphql-apis/graphql-key-validation-api/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" + # Cleanup + When I delete the GraphQL API "graphql-key-validation-api" + Then the response should be successful + + # ==================== LIST API KEYS - ERROR CASES ==================== + + 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 + + # ==================== REVOKE API KEY - ERROR CASES ==================== + + 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) + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: graphql-revoke-error-api + spec: + displayName: GraphQL Revoke Error API + version: v1.0 + context: /graphql-revoke-error + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + # Revoking non-existent key is idempotent - returns success + When I send a DELETE request to the "gateway-controller" service at "/graphql-apis/graphql-revoke-error-api/api-keys/non-existent-key" + Then the response status should be 200 + And the response should be valid JSON + # Cleanup + When I delete the GraphQL API "graphql-revoke-error-api" + Then the response should be successful + + # ==================== REGENERATE API KEY - ERROR CASES ==================== + + 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 + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: graphql-test-regenerate-api + spec: + displayName: GraphQL Test Regenerate API + version: v1.0 + context: /graphql-test-regen + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + When I send a POST request to the "gateway-controller" service at "/graphql-apis/graphql-test-regenerate-api/api-keys/non-existent-key/regenerate" with body: + """ + {} + """ + Then the response status should be 404 + # Cleanup + When I delete the GraphQL API "graphql-test-regenerate-api" + 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 + + # ==================== GENERATE API KEY - ADDITIONAL ERROR CASES ==================== + + Scenario: Generate API key with invalid JSON body returns error + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: graphql-invalid-json-key-api + spec: + displayName: GraphQL Invalid JSON Key API + version: v1.0 + context: /graphql-invalid-json-key + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + When I send a POST request to the "gateway-controller" service at "/graphql-apis/graphql-invalid-json-key-api/api-keys" with body: + """ + { this is not valid json + """ + Then the response should be a client error + And the response should be valid JSON + # Cleanup + When I delete the GraphQL API "graphql-invalid-json-key-api" + Then the response should be successful + + Scenario: API key with special characters in name + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: graphql-special-char-key-api + spec: + displayName: GraphQL Special Char Key API + version: v1.0 + context: /graphql-special-char-key + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + # Generate key with hyphens and underscores (should be allowed) + When I send a POST request to the "gateway-controller" service at "/graphql-apis/graphql-special-char-key-api/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" + # Cleanup + When I delete the GraphQL API "graphql-special-char-key-api" + Then the response should be successful + + # ==================== LIST API KEYS WITH PAGINATION ==================== + + Scenario: List API keys with pagination parameters + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: graphql-paginated-keys-api + spec: + displayName: GraphQL Paginated Keys API + version: v1.0 + context: /graphql-paginated-keys + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + When I send a GET request to the "gateway-controller" service at "/graphql-apis/graphql-paginated-keys-api/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" + # Cleanup + When I delete the GraphQL API "graphql-paginated-keys-api" + Then the response should be successful diff --git a/gateway/it/features/graphql_deploy.feature b/gateway/it/features/graphql_deploy.feature new file mode 100644 index 0000000000..b752f7ea9e --- /dev/null +++ b/gateway/it/features/graphql_deploy.feature @@ -0,0 +1,862 @@ +# -------------------------------------------------------------------- +# 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. +# -------------------------------------------------------------------- + +Feature: Test GraphQL API CRUD and connectivity (gateway-only path) + As a gateway operator + I want to deploy a GraphQLApi configuration directly against the gateway-controller + So that I can verify routing, policy enforcement, and CRUD behavior with no control plane involved + + Background: + Given the gateway services are running + + # ==================== HAPPY PATH: DEPLOY, INVOKE, UPDATE, DELETE ==================== + + Scenario: Deploy a GraphQL API and invoke it successfully + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: countries-graphql-e2e-v1 + spec: + displayName: Countries E2E + version: v1 + context: /countries-e2e + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + And the response should be valid JSON + And the JSON response field "kind" should be "GraphQLApi" + And I wait for the endpoint "http://localhost:8080/countries-e2e" to be ready with method "POST" and body '{"query":"{ countries { code name } }"}' + + When I send a POST request to "http://localhost:8080/countries-e2e" 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 } }" + + Given I authenticate using basic auth as "admin" + When I delete the GraphQL API "countries-graphql-e2e-v1" + 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 authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: countries-update-e2e-v1 + spec: + displayName: Countries Update E2E + version: v1 + context: /countries-update-e2e + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + And I wait for 2 seconds + + Given I authenticate using basic auth as "admin" + When I update the GraphQL API "countries-update-e2e-v1" with: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: countries-update-e2e-v1 + spec: + displayName: Countries Update E2E v2 + version: v1 + context: /countries-update-e2e + upstream: + main: + url: http://sample-backend:9080/graphql-v2 + """ + Then the response should be successful + And the response should be valid JSON + And the JSON response field "spec.displayName" should be "Countries Update E2E v2" + And I wait for the endpoint "http://localhost:8080/countries-update-e2e" to be ready with method "POST" and body '{"query":"{ countries { code } }"}' + + When I send a POST request to "http://localhost:8080/countries-update-e2e" with body: + """ + {"query":"{ countries { code } }"} + """ + Then the response should be successful + And the response body should contain "/graphql-v2" + + # Cleanup + Given I authenticate using basic auth as "admin" + When I delete the GraphQL API "countries-update-e2e-v1" + Then the response should be successful + + # ==================== MUTATIONS ==================== + # There is no separate "mutation support" at the gateway-controller/Envoy + # layer, and the artifact carries no schema field at all (docs/specs/ + # graphql-api-support.md §6.1/§6.2): a mutation is just another POST body + # sent to the same single route a query uses. This scenario proves that + # pass-through directly by sending a mutation-shaped body against an + # artifact that is 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 authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: blog-mutation-e2e-v1 + spec: + displayName: Blog Mutation E2E + version: v1 + context: /blog-mutation-e2e + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/blog-mutation-e2e" to be ready with method "POST" and body '{"query":"mutation { createPost(input: { title: \"hi\", body: \"hi\" }) { post { id } } }"}' + + When I send a POST request to "http://localhost:8080/blog-mutation-e2e" 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:" + + # Cleanup + Given I authenticate using basic auth as "admin" + When I delete the GraphQL API "blog-mutation-e2e-v1" + Then the response should be successful + + # ==================== LABELS ==================== + + Scenario: Deploy a GraphQL API with labels and verify they are stored + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: labeled-graphql-v1 + labels: + environment: production + team: graphql-team + spec: + displayName: Labeled GraphQL + version: v1 + context: /labeled-graphql + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + And I wait for 2 seconds + + Given I authenticate using basic auth as "admin" + When I get the GraphQL API "labeled-graphql-v1" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "metadata.labels.environment" should be "production" + And the JSON response field "metadata.labels.team" should be "graphql-team" + + Given I authenticate using basic auth as "admin" + When I delete the GraphQL API "labeled-graphql-v1" + Then the response should be successful + + Scenario: Deploy a GraphQL API with invalid labels (spaces in keys) should fail + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: invalid-labels-graphql-v1 + labels: + "Invalid Key": value + spec: + displayName: Invalid Labels GraphQL + version: v1 + context: /invalid-labels-graphql + upstream: + main: + url: http://sample-backend:9080/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" + + # ==================== LIST ==================== + + Scenario: List GraphQL APIs when none exist + Given I authenticate using basic auth as "admin" + When I send a GET request to the "gateway-controller" service at "/graphql-apis?displayName=NoSuchGraphQLAPIDisplayName" + 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 + Given I authenticate using basic auth as "admin" + 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 with displayName filter + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: filter-test-graphql-v1 + spec: + displayName: UniqueGraphQLFilterTest + version: v1 + context: /filter-test-graphql + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + When I send a GET request to the "gateway-controller" service at "/graphql-apis?displayName=UniqueGraphQLFilterTest" + 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 "UniqueGraphQLFilterTest" + # Cleanup + Given I authenticate using basic auth as "admin" + When I delete the GraphQL API "filter-test-graphql-v1" + Then the response should be successful + + Scenario: List GraphQL APIs with version filter + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: version-test-graphql-v99 + spec: + displayName: Version Test GraphQL + version: v99 + context: /version-test-graphql + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + When I send a GET request to the "gateway-controller" service at "/graphql-apis?version=v99" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "status" should be "success" + # Cleanup + Given I authenticate using basic auth as "admin" + When I delete the GraphQL API "version-test-graphql-v99" + Then the response should be successful + + Scenario: List GraphQL APIs with context filter + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: context-filter-graphql-v1 + spec: + displayName: Context Filter GraphQL + version: v1 + context: /context-filter-graphql + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + When I send a GET request to the "gateway-controller" service at "/graphql-apis?context=/context-filter-graphql" + 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 "context-filter-graphql-v1" + # Cleanup + Given I authenticate using basic auth as "admin" + When I delete the GraphQL API "context-filter-graphql-v1" + Then the response should be successful + + # ==================== GET ERROR CASES ==================== + + Scenario: Get non-existent GraphQL API returns 404 + Given I authenticate using basic auth as "admin" + When I send a GET request to the "gateway-controller" service at "/graphql-apis/non-existent-graphql-id" + Then the response status should be 404 + And the response should be valid JSON + And the JSON response field "status" should be "error" + + Scenario: Get GraphQL API with invalid ID format returns 404 + Given I authenticate using basic auth as "admin" + When I send a GET request to the "gateway-controller" service at "/graphql-apis/invalid@graphql#id" + Then the response status should be 404 + And the response should be valid JSON + + # ==================== UPDATE ERROR CASES ==================== + + Scenario: Update non-existent GraphQL API returns 404 + Given I authenticate using basic auth as "admin" + When I update the GraphQL API "non-existent-graphql-update" with: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: non-existent-graphql-update + spec: + displayName: Ghost + version: v1 + context: /ghost + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response status should be 404 + And the response should be valid JSON + + Scenario: Update GraphQL API with a metadata.name that does not match the path id returns 400 + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: mismatch-graphql-v1 + spec: + displayName: Mismatch GraphQL + version: v1 + context: /mismatch-graphql + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + + Given I authenticate using basic auth as "admin" + When I update the GraphQL API "mismatch-graphql-v1" with: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: a-different-name-v1 + spec: + displayName: Mismatch GraphQL + version: v1 + context: /mismatch-graphql + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response status 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... + Given I authenticate using basic auth as "admin" + When I get the GraphQL API "mismatch-graphql-v1" + 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. + Given I authenticate using basic auth as "admin" + When I get the GraphQL API "a-different-name-v1" + Then the response status should be 404 + + # Cleanup + Given I authenticate using basic auth as "admin" + When I delete the GraphQL API "mismatch-graphql-v1" + Then the response should be successful + + Scenario: Update GraphQL API with invalid JSON body returns error + Given I authenticate using basic auth as "admin" + 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 + + # ==================== DELETE ERROR CASES ==================== + + Scenario: Delete non-existent GraphQL API returns 404 + Given I authenticate using basic auth as "admin" + When I delete the GraphQL API "non-existent-graphql-delete" + Then the response status 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 GraphQL API with missing required fields returns error + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: incomplete-graphql-v1 + 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 GraphQL API with a context that does not start with '/' returns error + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: bad-context-graphql-v1 + spec: + displayName: Bad Context GraphQL + version: v1 + context: bad-context-no-slash + upstream: + main: + url: http://sample-backend:9080/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 GraphQL API without an upstream returns error + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: missing-upstream-graphql-v1 + spec: + displayName: Missing Upstream GraphQL + version: v1 + context: /missing-upstream-graphql + """ + 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 GraphQL API with an invalid upstream URL scheme returns error + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: bad-scheme-graphql-v1 + spec: + displayName: Bad Scheme GraphQL + version: v1 + context: /bad-scheme-graphql + upstream: + main: + url: ftp://sample-backend:9080/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 GraphQL API with an upstream URL missing a host returns error + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: no-host-graphql-v1 + spec: + displayName: No Host GraphQL + version: v1 + context: /no-host-graphql + 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 GraphQL API with an unsupported kind value returns error + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: NotAGraphQLApi + metadata: + name: wrong-kind-graphql-v1 + spec: + displayName: Wrong Kind GraphQL + version: v1 + context: /wrong-kind-graphql + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be a client error + And the response should be valid JSON + + Scenario: Deploy GraphQL API with invalid JSON body returns error + Given I authenticate using basic auth as "admin" + 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: Deploy duplicate GraphQL API returns conflict + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: duplicate-graphql-v1 + spec: + displayName: Duplicate GraphQL + version: v1 + context: /duplicate-graphql + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: duplicate-graphql-v1 + spec: + displayName: Duplicate GraphQL + version: v1 + context: /duplicate-graphql + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response status should be 409 + And the response should be valid JSON + And the JSON response field "status" should be "error" + + # Cleanup + Given I authenticate using basic auth as "admin" + When I delete the GraphQL API "duplicate-graphql-v1" + 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 authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: single-route-graphql-v1 + spec: + displayName: Single Route GraphQL + version: v1 + context: /single-route-graphql + upstream: + main: + url: http://sample-backend:9080/graphql + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/single-route-graphql" to be ready with method "POST" and body '{"query":"{ ping }"}' + + When I send a GET request to "http://localhost:8080/single-route-graphql" + Then the response status code should be 404 + + # POST still works on the same context + When I send a POST request to "http://localhost:8080/single-route-graphql" with body: + """ + {"query":"{ ping }"} + """ + Then the response should be successful + + # Cleanup + Given I authenticate using basic auth as "admin" + When I delete the GraphQL API "single-route-graphql-v1" + Then the response should be successful + + # ==================== POLICY ENFORCEMENT ==================== + + Scenario: GraphQL API with jwt-auth rejects requests without a token and accepts a valid one + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: jwt-auth-graphql-v1 + spec: + displayName: JWT Auth GraphQL + version: v1 + context: /jwt-auth-graphql + upstream: + main: + url: http://sample-backend:9080/graphql + policies: + - name: jwt-auth + version: v1 + params: + issuers: + - mock-jwks + """ + Then the response should be successful + And I wait for 5 seconds + + And I clear all headers + When I send a POST request to "http://localhost:8080/jwt-auth-graphql" with body: + """ + {"query":"{ ping }"} + """ + Then the response status code should be 401 + + When I get a JWT token from the mock JWKS server with issuer "http://mock-jwks:8080/token" + And I send a POST request to "http://localhost:8080/jwt-auth-graphql" with the JWT token and body: + """ + {"query":"{ ping }"} + """ + Then the response status code should be 200 + + # Cleanup + And I clear all headers + Given I authenticate using basic auth as "admin" + When I delete the GraphQL API "jwt-auth-graphql-v1" + Then the response should be successful + + Scenario: GraphQL API with set-headers correctly mutates the proxied response + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: set-headers-graphql-v1 + spec: + displayName: Set Headers GraphQL + version: v1 + context: /set-headers-graphql + upstream: + main: + url: http://sample-backend:9080/graphql + 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 wait for the endpoint "http://localhost:8080/set-headers-graphql" to be ready with method "POST" and body '{"query":"{ ping }"}' + + When I send a POST request to "http://localhost:8080/set-headers-graphql" with body: + """ + {"query":"{ ping }"} + """ + Then the response should be successful + And the response header "X-GraphQL-Test-Marker" should be "graphql-policy-works" + + # Cleanup + Given I authenticate using basic auth as "admin" + When I delete the GraphQL API "set-headers-graphql-v1" + Then the response should be successful + + Scenario: GraphQL API with cors does not handle a preflight request - confirmed limitation, not yet supported + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: cors-graphql-v1 + spec: + displayName: CORS GraphQL + version: v1 + context: /cors-graphql + upstream: + main: + url: http://sample-backend:9080/graphql + policies: + - name: cors + version: v1 + params: + allowedOrigins: + - "http://example.com" + allowedMethods: + - "POST" + allowedHeaders: + - "Content-Type" + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/cors-graphql" to be ready with method "POST" and body '{"query":"{ ping }"}' + + # CONFIRMED via this test (not assumed): a GraphQL API resolves to + # exactly one POST route (docs/specs/graphql-api-support.md §6.1) 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 — tracked + # in docs/specs/graphql-api-support.md §7's QoS table. + Given I clear all headers + When I set header "Origin" to "http://example.com" + And I set header "Access-Control-Request-Method" to "POST" + And I send an OPTIONS request to "http://localhost:8080/cors-graphql" + Then the response status code should be 404 + + # Cleanup + Given I authenticate using basic auth as "admin" + When I delete the GraphQL API "cors-graphql-v1" + Then the response should be successful + + Scenario: GraphQL API with basic-ratelimit enforces its configured limit + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: ratelimit-graphql-v1 + spec: + displayName: RateLimit GraphQL + version: v1 + context: /ratelimit-graphql + upstream: + main: + url: http://sample-backend:9080/graphql + 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, not 3. + And I wait for the endpoint "http://localhost:8080/ratelimit-graphql" to be ready with method "POST" and body '{"query":"{ ping }"}' + + When I send a POST request to "http://localhost:8080/ratelimit-graphql" with body: + """ + {"query":"{ ping }"} + """ + Then the response should be successful + When I send a POST request to "http://localhost:8080/ratelimit-graphql" with body: + """ + {"query":"{ ping }"} + """ + Then the response should be successful + When I send a POST request to "http://localhost:8080/ratelimit-graphql" with body: + """ + {"query":"{ ping }"} + """ + Then the response status code should be 429 + + # Cleanup + Given I authenticate using basic auth as "admin" + When I delete the GraphQL API "ratelimit-graphql-v1" + 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 (gateway-controller/pkg/transform/graphql.go, + # t.routerConfig.VHosts.{Main,Sandbox}.Default), proven at the unit level + # by TestGraphQLAPITransformer_SandboxProducesSecondRoute. This scenario + # is the missing E2E half: does traffic carrying the sandbox Host header + # actually land on the sandbox cluster, not just "does the route exist." + # + # The gateway's built-in default sandbox vhost is the WILDCARD pattern + # "sandbox-*" (gateway-controller/pkg/config/config.go), not a fixed + # literal like REST's per-API "sandbox.local" example — GraphQL has no + # per-API vhosts override to set a literal, so the Host header used below + # must actually match "sandbox-*" (start with "sandbox-"), matching what + # this codebase's own default resolves to. + + Scenario: A GraphQL API with a sandbox upstream routes sandbox-host traffic to the sandbox cluster + Given I authenticate using basic auth as "admin" + When I deploy this GraphQL configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: GraphQLApi + metadata: + name: sandbox-graphql-v1 + spec: + displayName: Sandbox GraphQL + version: v1 + context: /sandbox-graphql + upstream: + main: + url: http://sample-backend:9080/graphql + sandbox: + url: http://sample-backend:9080/sandbox/graphql + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/sandbox-graphql" to be ready with method "POST" and body '{"query":"{ ping }"}' + + When I clear all headers + And I send a POST request to "http://localhost:8080/sandbox-graphql" 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 "http://localhost:8080/sandbox-graphql" with body: + """ + {"query":"{ ping }"} + """ + Then the response should be successful + And the JSON response field "path" should be "/sandbox/graphql" + + # Cleanup + Given I authenticate using basic auth as "admin" + When I delete the GraphQL API "sandbox-graphql-v1" + Then the response should be successful diff --git a/gateway/it/steps_graphql.go b/gateway/it/steps_graphql.go new file mode 100644 index 0000000000..0f01a8ef2a --- /dev/null +++ b/gateway/it/steps_graphql.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 it + +import ( + "fmt" + "net/url" + "time" + + "github.com/cucumber/godog" + "github.com/wso2/api-platform/gateway/it/steps" +) + +// RegisterGraphQLSteps registers all GraphQL API deployment step definitions. +// Mirrors RegisterAPISteps (RestApi) / RegisterMCPSteps (Mcp) — GraphQLApi is a +// core kind on the gateway-controller with the same generic +// create/list/get/update/delete surface at /graphql-apis, just with no +// per-operation routes (docs/specs/graphql-api-support.md §6.1). +func RegisterGraphQLSteps(ctx *godog.ScenarioContext, state *TestState, httpSteps *steps.HTTPSteps, jwtSteps *JWTSteps) { + deployGraphQLAPI := func(body *godog.DocString) error { + httpSteps.SetHeader("Content-Type", "application/yaml") + err := httpSteps.SendPOSTToService("gateway-controller", "/graphql-apis", body) + if err != nil { + return err + } + time.Sleep(policyPropagationDelay) + return nil + } + + deleteGraphQLAPI := func(name string) error { + err := httpSteps.SendDELETEToService("gateway-controller", "/graphql-apis/"+url.PathEscape(name)) + if err != nil { + return err + } + time.Sleep(policyPropagationDelay) + return nil + } + + ctx.Step(`^I deploy this GraphQL configuration:$`, deployGraphQLAPI) + + ctx.Step(`^I list all GraphQL APIs$`, func() error { + return httpSteps.SendGETToService("gateway-controller", "/graphql-apis") + }) + + ctx.Step(`^I get the GraphQL API "([^"]*)"$`, func(name string) error { + return httpSteps.SendGETToService("gateway-controller", "/graphql-apis/"+url.PathEscape(name)) + }) + + ctx.Step(`^I update the GraphQL API "([^"]*)" with:$`, func(name string, body *godog.DocString) error { + httpSteps.SetHeader("Content-Type", "application/yaml") + err := httpSteps.SendPUTToService("gateway-controller", "/graphql-apis/"+url.PathEscape(name), body) + if err != nil { + return err + } + time.Sleep(policyPropagationDelay) + return nil + }) + + ctx.Step(`^I delete the GraphQL API "([^"]*)"$`, deleteGraphQLAPI) + + // Invoking the deployed single-route GraphQL endpoint with a bearer token — + // the generic "I send a POST request... with the JWT token" step (steps_jwt.go) + // has no body variant, and a GraphQL query is always a POST with a JSON body. + ctx.Step(`^I send a POST request to "([^"]*)" with the JWT token and body:$`, func(url string, body *godog.DocString) error { + if jwtSteps == nil || jwtSteps.currentToken == "" { + return fmt.Errorf("no JWT token available - call 'I get a JWT token from the mock JWKS server' first") + } + httpSteps.SetHeader("Content-Type", "application/json") + httpSteps.SetHeader("Authorization", "Bearer "+jwtSteps.currentToken) + return httpSteps.ISendPOSTRequestWithBody(url, body) + }) +} diff --git a/gateway/it/suite_test.go b/gateway/it/suite_test.go index beff2452dc..a544b361d3 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -127,6 +127,8 @@ func getFeaturePaths() []string { "features/api-management.feature", "features/api-error-responses.feature", "features/api-keys.feature", + "features/graphql_deploy.feature", + "features/graphql-api-keys.feature", "features/api-with-policies.feature", "features/interceptor-service.feature", "features/llm-proxies.feature", @@ -349,6 +351,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) { RegisterAPISteps(ctx, testState, httpSteps) RegisterTimeoutSteps(ctx, testState) RegisterMCPSteps(ctx, testState, httpSteps, jwtSteps) + RegisterGraphQLSteps(ctx, testState, httpSteps, jwtSteps) RegisterLLMSteps(ctx, testState, httpSteps) RegisterJWTSteps(ctx, testState, httpSteps, jwtSteps) RegisterPolicyEngineSteps(ctx, testState, httpSteps) diff --git a/go.work.sum b/go.work.sum index 830fd22f60..f155e7e30d 100644 --- a/go.work.sum +++ b/go.work.sum @@ -2092,6 +2092,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= @@ -2748,7 +2749,6 @@ github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372/go.mod h1:evDBbvNR/KaVFZ2ZlDSOWWXIUKq0wCOEtzLxRM8SG3k= github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= @@ -4622,7 +4622,6 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index e5050eb044..2fcf2cdcef 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -119,6 +119,12 @@ const ( GatewayResponseFunctionalityTypeRegular GatewayResponseFunctionalityType = "regular" ) +// Defines values for GraphQLIntrospectionMode. +const ( + ENDPOINT GraphQLIntrospectionMode = "ENDPOINT" + SDL GraphQLIntrospectionMode = "SDL" +) + // Defines values for LLMAccessControlMode. const ( AllowAll LLMAccessControlMode = "allow_all" @@ -382,6 +388,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" @@ -433,24 +461,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. @@ -767,6 +795,73 @@ 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. + 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"` + + // Sdl The GraphQL schema in SDL form, supplied directly (pasted/uploaded) or + // resolved from `sdlUrl`. Optional on create — if all of `sdl`, `sdlUrl`, + // and a reachable `upstream.main.url` are omitted, creation fails; if only + // `upstream.main.url` is given, it must expose standard GraphQL + // introspection and the schema is derived server-side. Always + // the *resolved* schema, never a document-supplied schema-location + // reference. `sdl` and `sdlUrl` are mutually exclusive on a request; this + // field always holds the resolved text on every read regardless of which + // input path produced it. + Sdl *string `json:"sdl,omitempty" yaml:"sdl,omitempty"` + + // SdlUrl A URL to a raw SDL document to fetch and use as `sdl` — 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 with the same public-internet-only SSRF hardening as an + // OpenAPI-spec-by-URL fetch (loopback/private/link-local/metadata addresses + // refused) — it is not meant for a tenant's own in-cluster backend. Mutually + // exclusive with `sdl`. 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"` +} + // CreateLLMProviderAPIKeyRequest defines model for CreateLLMProviderAPIKeyRequest. type CreateLLMProviderAPIKeyRequest struct { // AllowedTargets Comma-separated list of gateways this key is valid for. @@ -1248,6 +1343,165 @@ 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. + 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"` + + // Sdl The GraphQL schema in SDL form, supplied directly (pasted/uploaded) or + // resolved from `sdlUrl`. Optional on create — if all of `sdl`, `sdlUrl`, + // and a reachable `upstream.main.url` are omitted, creation fails; if only + // `upstream.main.url` is given, it must expose standard GraphQL + // introspection and the schema is derived server-side. Always + // the *resolved* schema, never a document-supplied schema-location + // reference. `sdl` and `sdlUrl` are mutually exclusive on a request; this + // field always holds the resolved text on every read regardless of which + // input path produced it. + Sdl *string `json:"sdl,omitempty" yaml:"sdl,omitempty"` + + // SdlUrl A URL to a raw SDL document to fetch and use as `sdl` — 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 with the same public-internet-only SSRF hardening as an + // OpenAPI-spec-by-URL fetch (loopback/private/link-local/metadata addresses + // refused) — it is not meant for a tenant's own in-cluster backend. Mutually + // exclusive with `sdl`. 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"` +} + +// 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. + 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. When a non-empty `sdlFile` part is + // uploaded, it overrides any `sdl`/`sdlUrl` included here. When no + // `sdlFile` part is uploaded, this metadata's own `sdl`/`sdlUrl` (or + // upstream introspection) is used unchanged. + Metadata string `binding:"required" json:"metadata" yaml:"metadata"` + + // SdlFile The GraphQL SDL document as a file upload (e.g. schema.graphql). + 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 + // LLMAccessControl defines model for LLMAccessControl. type LLMAccessControl struct { // Exceptions Path exceptions to the access control mode @@ -1312,7 +1566,7 @@ type LLMProvider struct { // AssociatedGateways Optional list of gateways this LLM provider can be deployed to, along with per-gateway configuration overrides. This field is optional; omitting it does not change existing behaviour. AssociatedGateways *[]AssociatedGateway `json:"associatedGateways,omitempty" yaml:"associatedGateways,omitempty"` - // Context Base path for all routes exposed by this proxy. Must start with / and carry no trailing slash; the single exception is the root path "/", which is the default. + // Context Base path for all routes exposed by this provider. Must start with / and carry no trailing slash; the single exception is the root path "/", which is the default. Context *string `json:"context,omitempty" yaml:"context,omitempty"` // CreatedAt Timestamp when the resource was created @@ -1818,22 +2072,31 @@ type MCPProxyListResponse struct { Pagination Pagination `json:"pagination" yaml:"pagination"` } -// MCPServerInfoFetchRequest defines model for MCPServerInfoFetchRequest. +// MCPServerInfoFetchRequest Target MCP server to introspect, and the credentials to introspect it with. At least +// one of `url`/`proxyId` must be provided type MCPServerInfoFetchRequest struct { // Auth Authentication configuration for upstream endpoints Auth *UpstreamAuth `json:"auth,omitempty" yaml:"auth,omitempty"` - // ProxyId MCP proxy handle (identifier) for refresh operations. When provided, - // the server fetches URL and auth from the stored proxy configuration. - // Auth override is not allowed in refetch mode. + // ProxyId MCP proxy handle (identifier) for refresh operations. The stored credentials of + // this proxy are used for the fetch, and its stored upstream URL too unless `url` + // overrides it. Required unless `url` is given. ProxyId *string `json:"proxyId,omitempty" yaml:"proxyId,omitempty"` - // Url Endpoint URL of the MCP server to fetch information from. - // Required when proxyId is not provided. When proxyId is provided, - // the URL from the stored proxy configuration is used. - Url *string `json:"url,omitempty" yaml:"url,omitempty"` + // Url Endpoint URL of the MCP server to fetch information from. Required unless + // `proxyId` is given. When sent together with `proxyId` it overrides that proxy's + // stored upstream URL, while the proxy's stored credentials are still used — this + // validates an unsaved endpoint edit without re-sending a write-only secret. + Url *string `json:"url,omitempty" yaml:"url,omitempty"` + union json.RawMessage } +// MCPServerInfoFetchRequest0 defines model for . +type MCPServerInfoFetchRequest0 = interface{} + +// MCPServerInfoFetchRequest1 defines model for . +type MCPServerInfoFetchRequest1 = interface{} + // MCPServerInfoFetchResponse defines model for MCPServerInfoFetchResponse. type MCPServerInfoFetchResponse struct { Prompts *[]map[string]interface{} `json:"prompts,omitempty" yaml:"prompts,omitempty"` @@ -2256,7 +2519,7 @@ type SecretCreateRequest struct { Type *SecretCreateRequestType `json:"type,omitempty" yaml:"type,omitempty"` // Value Plaintext secret value — encrypted at rest, never returned in any response - Value string `binding:"required" json:"value" yaml:"value"` + Value *string `binding:"required" json:"value,omitempty" yaml:"value,omitempty"` } // SecretCreateRequestType defines model for SecretCreateRequest.Type. @@ -2324,7 +2587,7 @@ type SecretUpdateRequest struct { Id *string `json:"id,omitempty" yaml:"id,omitempty"` // Value New plaintext secret value — re-encrypted at rest - Value string `binding:"required" json:"value" yaml:"value"` + Value *string `binding:"required" json:"value,omitempty" yaml:"value,omitempty"` } // SecurityConfig Defines security mechanisms (API key, OAuth2) applicable to the API @@ -2833,6 +3096,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 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). @@ -2883,7 +3215,7 @@ type ListLLMProviderAPIKeysParams struct { // GetLLMProviderDeploymentsParams defines parameters for GetLLMProviderDeployments. type GetLLMProviderDeploymentsParams struct { - // GatewayId **Gateway ID** (handle — unique slug identifier) of the Gateway to filter deployments by. + // 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) @@ -2943,7 +3275,7 @@ type ListLLMProxyAPIKeysParams struct { // GetLLMProxyDeploymentsParams defines parameters for GetLLMProxyDeployments. type GetLLMProxyDeploymentsParams struct { - // GatewayId **Gateway ID** (handle — unique slug identifier) of the Gateway to filter deployments by. + // 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) @@ -2985,7 +3317,7 @@ type ListMCPProxiesParams struct { // GetMCPProxyDeploymentsParams defines parameters for GetMCPProxyDeployments. type GetMCPProxyDeploymentsParams struct { - // GatewayId **Gateway ID** (handle — unique slug identifier) of the Gateway to filter deployments by. + // 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) @@ -3091,7 +3423,7 @@ type ListRESTAPIsParamsSortOrder string // GetDeploymentsParams defines parameters for GetDeployments. type GetDeploymentsParams struct { - // GatewayId **Gateway ID** (handle — unique slug identifier) of the Gateway to filter deployments by. + // 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) @@ -3206,6 +3538,30 @@ type CreateGatewayJSONRequestBody = CreateGatewayRequest // UpdateGatewayJSONRequestBody defines body for UpdateGateway for application/json ContentType. type UpdateGatewayJSONRequestBody = GatewayResponse +// CreateGraphQLAPIJSONRequestBody defines body for CreateGraphQLAPI for application/json ContentType. +type CreateGraphQLAPIJSONRequestBody = CreateGraphQLAPIRequest + +// CreateGraphQLAPIMultipartRequestBody defines body for CreateGraphQLAPI for multipart/form-data ContentType. +type CreateGraphQLAPIMultipartRequestBody = GraphQLAPIMultipartRequest + +// UpdateGraphQLAPIJSONRequestBody defines body for UpdateGraphQLAPI for application/json ContentType. +type UpdateGraphQLAPIJSONRequestBody = GraphQLAPI + +// 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 @@ -3299,6 +3655,130 @@ type CreateSubscriptionJSONRequestBody = CreateSubscriptionRequest // UpdateSubscriptionJSONRequestBody defines body for UpdateSubscription for application/json ContentType. type UpdateSubscriptionJSONRequestBody = Subscription +// AsMCPServerInfoFetchRequest0 returns the union data inside the MCPServerInfoFetchRequest as a MCPServerInfoFetchRequest0 +func (t MCPServerInfoFetchRequest) AsMCPServerInfoFetchRequest0() (MCPServerInfoFetchRequest0, error) { + var body MCPServerInfoFetchRequest0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromMCPServerInfoFetchRequest0 overwrites any union data inside the MCPServerInfoFetchRequest as the provided MCPServerInfoFetchRequest0 +func (t *MCPServerInfoFetchRequest) FromMCPServerInfoFetchRequest0(v MCPServerInfoFetchRequest0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeMCPServerInfoFetchRequest0 performs a merge with any union data inside the MCPServerInfoFetchRequest, using the provided MCPServerInfoFetchRequest0 +func (t *MCPServerInfoFetchRequest) MergeMCPServerInfoFetchRequest0(v MCPServerInfoFetchRequest0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsMCPServerInfoFetchRequest1 returns the union data inside the MCPServerInfoFetchRequest as a MCPServerInfoFetchRequest1 +func (t MCPServerInfoFetchRequest) AsMCPServerInfoFetchRequest1() (MCPServerInfoFetchRequest1, error) { + var body MCPServerInfoFetchRequest1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromMCPServerInfoFetchRequest1 overwrites any union data inside the MCPServerInfoFetchRequest as the provided MCPServerInfoFetchRequest1 +func (t *MCPServerInfoFetchRequest) FromMCPServerInfoFetchRequest1(v MCPServerInfoFetchRequest1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeMCPServerInfoFetchRequest1 performs a merge with any union data inside the MCPServerInfoFetchRequest, using the provided MCPServerInfoFetchRequest1 +func (t *MCPServerInfoFetchRequest) MergeMCPServerInfoFetchRequest1(v MCPServerInfoFetchRequest1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t MCPServerInfoFetchRequest) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + if err != nil { + return nil, err + } + object := make(map[string]json.RawMessage) + if t.union != nil { + err = json.Unmarshal(b, &object) + if err != nil { + return nil, err + } + } + + if t.Auth != nil { + object["auth"], err = json.Marshal(t.Auth) + if err != nil { + return nil, fmt.Errorf("error marshaling 'auth': %w", err) + } + } + + if t.ProxyId != nil { + object["proxyId"], err = json.Marshal(t.ProxyId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'proxyId': %w", err) + } + } + + if t.Url != nil { + object["url"], err = json.Marshal(t.Url) + if err != nil { + return nil, fmt.Errorf("error marshaling 'url': %w", err) + } + } + b, err = json.Marshal(object) + return b, err +} + +func (t *MCPServerInfoFetchRequest) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + if err != nil { + return err + } + object := make(map[string]json.RawMessage) + err = json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["auth"]; found { + err = json.Unmarshal(raw, &t.Auth) + if err != nil { + return fmt.Errorf("error reading 'auth': %w", err) + } + } + + if raw, found := object["proxyId"]; found { + err = json.Unmarshal(raw, &t.ProxyId) + if err != nil { + return fmt.Errorf("error reading 'proxyId': %w", err) + } + } + + if raw, found := object["url"]; found { + err = json.Unmarshal(raw, &t.Url) + if err != nil { + return fmt.Errorf("error reading 'url': %w", err) + } + } + + return err +} + // AsRateLimitingScopeConfig0 returns the union data inside the RateLimitingScopeConfig as a RateLimitingScopeConfig0 func (t RateLimitingScopeConfig) AsRateLimitingScopeConfig0() (RateLimitingScopeConfig0, error) { var body RateLimitingScopeConfig0 diff --git a/platform-api/go.mod b/platform-api/go.mod index 6bfad8f6a2..e5e757eab3 100644 --- a/platform-api/go.mod +++ b/platform-api/go.mod @@ -17,6 +17,7 @@ require ( github.com/microsoft/go-mssqldb v1.10.0 github.com/oapi-codegen/runtime v1.5.0 github.com/stretchr/testify v1.11.1 + github.com/vektah/gqlparser/v2 v2.5.36 github.com/wso2/api-platform/common v0.0.0 github.com/wso2/api-platform/httpkit v0.0.0-local golang.org/x/crypto v0.54.0 @@ -26,6 +27,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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect diff --git a/platform-api/go.sum b/platform-api/go.sum index 5e8ecebd2a..322212380a 100644 --- a/platform-api/go.sum +++ b/platform-api/go.sum @@ -17,13 +17,19 @@ 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/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= 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.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= @@ -112,6 +118,10 @@ 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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vektah/gqlparser/v2 v2.5.36 h1:CN9mKVHgMkc+XftdOWIhb4HEL8wKSYkFAqhf8booa7s= +github.com/vektah/gqlparser/v2 v2.5.36/go.mod h1:cAJ9qwVgPaUkWv6Gn8vn0mqOE0Ui5Pn56wNy5396XWo= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= diff --git a/platform-api/internal/apperror/catalog.go b/platform-api/internal/apperror/catalog.go index 9f8af379da..48ecf8ef49 100644 --- a/platform-api/internal/apperror/catalog.go +++ b/platform-api/internal/apperror/catalog.go @@ -198,6 +198,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 05334ba3cb..299d03d6d6 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(ArtifactReadOnly): 1, CodeOf(ArtifactRuntimeImmutable): 1, diff --git a/platform-api/internal/apperror/codes.go b/platform-api/internal/apperror/codes.go index 2ca8714885..cf5e010b8a 100644 --- a/platform-api/internal/apperror/codes.go +++ b/platform-api/internal/apperror/codes.go @@ -189,6 +189,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 00330f75ae..13fc525d7b 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 @@ -215,6 +220,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 a37fb640bc..bbec88646c 100644 --- a/platform-api/internal/database/schema.postgres.sql +++ b/platform-api/internal/database/schema.postgres.sql @@ -399,6 +399,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, @@ -473,6 +495,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_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 f360c3f0f3..f24bcd2e6e 100644 --- a/platform-api/internal/database/schema.sql +++ b/platform-api/internal/database/schema.sql @@ -391,6 +391,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, @@ -467,6 +489,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 9f009f6262..5fb2648f22 100644 --- a/platform-api/internal/database/schema.sqlite.sql +++ b/platform-api/internal/database/schema.sqlite.sql @@ -399,6 +399,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, @@ -472,6 +494,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.sqlserver.sql b/platform-api/internal/database/schema.sqlserver.sql index 454a229575..73fbc0d854 100644 --- a/platform-api/internal/database/schema.sqlserver.sql +++ b/platform-api/internal/database/schema.sqlserver.sql @@ -450,6 +450,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, @@ -554,6 +578,10 @@ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_mcp_proxies_project' CREATE INDEX idx_mcp_proxies_project ON dbo.mcp_proxies(project_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_mcp_proxies_org' AND object_id = OBJECT_ID(N'dbo.mcp_proxies')) CREATE INDEX idx_mcp_proxies_org ON dbo.mcp_proxies(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..d04e312b63 --- /dev/null +++ b/platform-api/internal/dto/graphql_api.go @@ -0,0 +1,72 @@ +/* + * 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 — a single logical endpoint (no sandbox split, unlike REST). +type GraphQLUpstream struct { + Main *GraphQLUpstreamTarget `yaml:"main,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/graphql_api.go b/platform-api/internal/handler/graphql_api.go new file mode 100644 index 0000000000..0e96882a53 --- /dev/null +++ b/platform-api/internal/handler/graphql_api.go @@ -0,0 +1,349 @@ +/* + * 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 +} + +// 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") + } + + limit, offset := parsePagination(r) + + resp, err := h.graphqlAPIService.List(orgId, projectId, limit, offset) + 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)) + } + + httputil.WriteJSON(w, http.StatusNoContent, nil) + 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 either +// application/json (the metadata struct directly) or multipart/form-data +// (a JSON "metadata" field plus an optional "sdlFile" upload) — see +// GraphQLAPIMultipartRequest in resources/openapi.yaml. A file part always +// wins over any sdl/sdlUrl present in metadata. +func decodeCreateGraphQLAPIRequest(r *http.Request, req *api.CreateGraphQLAPIRequest) error { + if !utils.IsMultipartFormRequest(r) { + return json.NewDecoder(r.Body).Decode(req) + } + 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 + req.SdlUrl = nil + } + return nil +} + +// decodeUpdateGraphQLAPIRequest is decodeCreateGraphQLAPIRequest's update +// counterpart — same multipart/JSON split, 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 json.NewDecoder(r.Body).Decode(req) + } + 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 + req.SdlUrl = nil + } + 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("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..65b3b99382 --- /dev/null +++ b/platform-api/internal/handler/graphql_api_test.go @@ -0,0 +1,156 @@ +/* + * 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] }" + +func TestDecodeCreateGraphQLAPIRequest_JSON(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.Fatalf("unexpected error: %v", err) + } + if out.DisplayName != "Countries" || out.Sdl == nil || *out.Sdl != "type Query { x: String }" { + t.Errorf("unexpected decode result: %+v", out) + } +} + +func TestDecodeCreateGraphQLAPIRequest_Multipart_FileWinsOverMetadataSDLUrl(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 come from the uploaded file, got %v", out.Sdl) + } + if out.SdlUrl != nil { + t.Errorf("expected sdlUrl to be cleared when a file part is uploaded, 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") + } +} + +func TestDecodeUpdateGraphQLAPIRequest_JSON(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.Fatalf("unexpected error: %v", err) + } + if out.Sdl == nil || *out.Sdl != "type Query { x: String }" { + t.Errorf("unexpected decode result: %+v", out) + } +} + +func TestDecodeUpdateGraphQLAPIRequest_Multipart_FileWinsOverMetadataSDLUrl(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 come from the uploaded file, got %v", out.Sdl) + } + if out.SdlUrl != nil { + t.Errorf("expected sdlUrl to be cleared when a file part is uploaded, 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") + } +} diff --git a/platform-api/internal/handler/graphql_apikey.go b/platform-api/internal/handler/graphql_apikey.go new file mode 100644 index 0000000000..2e9644402b --- /dev/null +++ b/platform-api/internal/handler/graphql_apikey.go @@ -0,0 +1,244 @@ +/* + * 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.ApiKey == "" { + return apperror.ValidationFailed.New("API key value is required") + } + + 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 + } + + if err := h.apiKeyService.CreateAPIKey(r.Context(), apiHandle, constants.GraphQLApi, orgId, userId, &req); 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, api.CreateAPIKeyResponse{ + Status: api.CreateAPIKeyResponseStatusSuccess, + KeyId: req.Id, + Message: "API key created and broadcasted to gateways successfully", + }) + 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 115179d061..443c91a2ee 100644 --- a/platform-api/internal/repository/api.go +++ b/platform-api/internal/repository/api.go @@ -568,9 +568,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 == "" { @@ -581,34 +614,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 } @@ -631,8 +665,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, @@ -660,7 +696,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..3c2bc79d91 --- /dev/null +++ b/platform-api/internal/repository/graphql_api.go @@ -0,0 +1,389 @@ +/* + * 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, limit, offset int) ([]*model.GraphQLAPI, error) { + var query string + var args []interface{} + pageClause, pageArgs := r.db.PaginationClause(limit, offset) + + if projectUUID != "" { + 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 = ? AND project_uuid = ? + ORDER BY created_at DESC + ` + pageClause + args = append([]interface{}{orgUUID, projectUUID}, pageArgs...) + } else { + 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 = ? + ORDER BY created_at DESC + ` + pageClause + args = append([]interface{}{orgUUID}, 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. +func (r *GraphQLAPIRepo) CountByProject(orgUUID, projectUUID string) (int, error) { + var count int + query := ` + SELECT COUNT(*) FROM graphql_apis + WHERE organization_uuid = ? AND project_uuid = ?` + if err := r.db.QueryRow(r.db.Rebind(query), orgUUID, projectUUID).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..4160d0002a --- /dev/null +++ b/platform-api/internal/repository/graphql_api_test.go @@ -0,0 +1,610 @@ +/* + * 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, "", 100, 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, 100, 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, "", 100, 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, "", 1, 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, "", 1, 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, "", 10, 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) + } +} + +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 13354e371d..642f8bddbf 100644 --- a/platform-api/internal/repository/interfaces.go +++ b/platform-api/internal/repository/interfaces.go @@ -309,6 +309,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, limit, offset int) ([]*model.GraphQLAPI, error) + Count(orgUUID string) (int, error) + CountByProject(orgUUID, projectUUID 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 0056cdbec7..25d1ef3796 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 bfc443a1ac..c1fb1f231c 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -138,6 +138,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) @@ -261,6 +262,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) @@ -300,6 +302,16 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, cfg, slogger, ) + graphqlAPIDeploymentService := service.NewGraphQLAPIDeploymentService( + graphqlAPIRepo, + deploymentRepo, + gatewayRepo, + orgRepo, + apiKeyRepo, + gatewayEventsService, + cfg, + slogger, + ) artifactImportService := service.NewArtifactImportService( apiRepo, llmProviderRepo, @@ -346,11 +358,16 @@ 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) 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{ @@ -405,6 +422,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_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/deployment_test.go b/platform-api/internal/service/deployment_test.go index 3df5c45381..ffd0c96af6 100644 --- a/platform-api/internal/service/deployment_test.go +++ b/platform-api/internal/service/deployment_test.go @@ -276,6 +276,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) { @@ -335,6 +336,7 @@ func (m *mockDeploymentRepo) Delete(deploymentID, artifactUUID, orgUUID string) } 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/graphql_api.go b/platform-api/internal/service/graphql_api.go new file mode 100644 index 0000000000..db842da7b8 --- /dev/null +++ b/platform-api/internal/service/graphql_api.go @@ -0,0 +1,729 @@ +/* + * 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) + sdl, introspectionMode, err := s.resolveSchema(utils.ValueOrEmpty(req.Sdl), utils.ValueOrEmpty(req.SdlUrl), upstream) + if err != nil { + return nil, err + } + + 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: sdl, + IntrospectionMode: 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) +} + +// resolveSchema implements the onboarding paths: a directly supplied SDL +// (pasted inline, uploaded as a file, or fetched from sdlUrl — the caller has +// already collapsed all three into suppliedSDL/sdlURL by the time this runs) +// is parsed/validated as-is; when neither is given, upstream.main.url is +// required and the schema is derived via introspection. Exactly one of +// sdl/mode is returned on success; on failure the error is always the sterile +// GraphQLAPISchemaResolveFailed catalog entry (422) — the specific +// parser/fetch/introspection failure reason is never surfaced to the client +// (error-handling.md / ssrf-prevention.md). +func (s *GraphQLAPIService) resolveSchema(suppliedSDL, sdlURL string, upstream *model.UpstreamConfig) (sdl string, introspectionMode string, err error) { + suppliedSDL = strings.TrimSpace(suppliedSDL) + sdlURL = strings.TrimSpace(sdlURL) + + if suppliedSDL != "" && sdlURL != "" { + return "", "", apperror.ValidationFailed.New("The sdl and sdlUrl fields are mutually exclusive — provide only one.") + } + + if sdlURL != "" { + 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 "", "", apperror.GraphQLAPISchemaResolveFailed.Wrap(err) + } + suppliedSDL = strings.TrimSpace(fetched) + } + + if suppliedSDL != "" { + if err := validateGraphQLSDL(suppliedSDL); err != nil { + s.slogger.Warn("Supplied GraphQL SDL failed validation", "error", err) + return "", "", apperror.GraphQLAPISchemaResolveFailed.Wrap(err) + } + return suppliedSDL, "SDL", nil + } + + if upstream == nil || upstream.Main == nil || strings.TrimSpace(upstream.Main.URL) == "" { + return "", "", apperror.ValidationFailed.New("One of sdl, sdlUrl, or upstream.main.url must be provided.") + } + + derived, err := fetchAndConvertGraphQLSchema(upstream.Main.URL) + if err != nil { + s.slogger.Warn("GraphQL introspection failed", "error", err) + return "", "", apperror.GraphQLAPISchemaResolveFailed.Wrap(err) + } + return derived, "ENDPOINT", 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, limit, offset int) (*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, limit, offset) + 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) + } 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: limit, + Offset: 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) + sdl, introspectionMode, err := s.resolveSchema(utils.ValueOrEmpty(req.Sdl), utils.ValueOrEmpty(req.SdlUrl), upstream) + if err != nil { + return nil, err + } + + 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..29ef024517 --- /dev/null +++ b/platform-api/internal/service/graphql_api_test.go @@ -0,0 +1,1608 @@ +/* + * 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) + + 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 string } + + 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) { + return nil, nil +} + +func (m *mockGraphQLAPIRepo) List(orgUUID, projectUUID string, limit, offset int) ([]*model.GraphQLAPI, error) { + return m.listResult, m.listErr +} + +func (m *mockGraphQLAPIRepo) Count(orgUUID string) (int, error) { return m.countResult, m.countErr } + +func (m *mockGraphQLAPIRepo) CountByProject(orgUUID, projectUUID string) (int, error) { + m.countByProjectCapture.orgUUID = orgUUID + m.countByProjectCapture.projectUUID = projectUUID + 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 policy as MCP) — unlike sdlUrl's public-only fetcher, so a local +// httptest.Server genuinely exercises this path rather than tripping an SSRF +// block first. +func TestGraphQLCreate_IntrospectionFailure_UnprocessableEntity(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)}}, + } + + _, err := svc.Create("org-1", "creator-uuid", req) + if err == nil { + t.Fatal("expected an error for a failed introspection") + } + var appErr *apperror.Error + if !errors.As(err, &appErr) { + t.Fatalf("expected an *apperror.Error, got %T: %v", err, err) + } + if appErr.Code != apperror.CodeGraphQLAPISchemaResolveFailed { + t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPISchemaResolveFailed, appErr.Code) + } + if appErr.HTTPStatus != http.StatusUnprocessableEntity { + t.Errorf("expected 422, got %d", appErr.HTTPStatus) + } + if strings.Contains(appErr.Message, "not json at all") || strings.Contains(appErr.Message, server.URL) { + t.Errorf("client message leaks introspection internals: %q", appErr.Message) + } + if repo.created != nil { + t.Error("expected no repository write when introspection fails") + } +} + +// TestGraphQLCreate_SchemaResolveFailure_IdenticalShapeRegardlessOfCause pins +// the CSV's "422 introspection failure and 422 SDL parse failure return the +// identical generic response shape" scenario directly: both failure causes +// route through the exact same apperror.GraphQLAPISchemaResolveFailed catalog +// entry, so the client-visible {code, httpStatus, message} triple must be +// byte-for-byte identical no matter which cause produced it — verified here +// rather than left to code inspection alone. +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"} + _, sdlErr := newGraphQLTestService(&mockGraphQLAPIRepo{}, project).Create("org-1", "creator-uuid", malformedSDLReq) + _, introspectErr := newGraphQLTestService(&mockGraphQLAPIRepo{}, project).Create("org-1", "creator-uuid", introspectionFailureReq) + + var sdlAppErr, introspectAppErr *apperror.Error + if !errors.As(sdlErr, &sdlAppErr) || !errors.As(introspectErr, &introspectAppErr) { + t.Fatalf("expected both errors to be *apperror.Error, got %T and %T", sdlErr, introspectErr) + } + if sdlAppErr.Code != introspectAppErr.Code { + t.Errorf("expected identical error codes, got %q vs %q", sdlAppErr.Code, introspectAppErr.Code) + } + if sdlAppErr.HTTPStatus != introspectAppErr.HTTPStatus { + t.Errorf("expected identical HTTP status, got %d vs %d", sdlAppErr.HTTPStatus, introspectAppErr.HTTPStatus) + } + if sdlAppErr.Message != introspectAppErr.Message { + t.Errorf("expected identical generic message regardless of cause, got %q vs %q", sdlAppErr.Message, introspectAppErr.Message) + } +} + +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", "", 25, 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", "", 100, 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", 100, 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", 100, 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) + } +} + +func TestGraphQLCreate_MalformedSDL_UnprocessableEntity(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")}}, + } + + _, err := svc.Create("org-1", "creator-uuid", req) + if err == nil { + t.Fatal("expected an error for malformed SDL") + } + var appErr *apperror.Error + if !errors.As(err, &appErr) { + t.Fatalf("expected an *apperror.Error, got %T: %v", err, err) + } + if appErr.Code != apperror.CodeGraphQLAPISchemaResolveFailed { + t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPISchemaResolveFailed, appErr.Code) + } + if appErr.HTTPStatus != http.StatusUnprocessableEntity { + t.Errorf("expected 422, got %d", appErr.HTTPStatus) + } + // Sterile response: the client message must never echo raw parser internals. + if strings.Contains(strings.ToLower(appErr.Message), "expected") || strings.Contains(appErr.Message, "{") { + t.Errorf("client message leaks parser internals: %q", appErr.Message) + } + if repo.created != nil { + t.Error("expected no repository write for a schema that failed validation") + } +} + +// TestGraphQLCreate_SDLWithNoQueryRoot_UnprocessableEntity 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. +func TestGraphQLCreate_SDLWithNoQueryRoot_UnprocessableEntity(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")}}, + } + + _, err := svc.Create("org-1", "creator-uuid", req) + if err == nil { + t.Fatal("expected an error for SDL with no Query root type") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPISchemaResolveFailed { + t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPISchemaResolveFailed, code) + } + if repo.created != nil { + t.Error("expected no repository write for a schema with no Query root type") + } +} + +// 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 +// precedence check for the third onboarding input (sdlUrl) — sdl and sdlUrl +// must never both be honored silently. +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_SchemaResolveFailed covers the +// sdlUrl decision logic itself: a URL the SSRF guard refuses (loopback, +// standing in for "unreachable/disallowed") surfaces as the sterile +// GraphQLAPISchemaResolveFailed error, not a raw network error. 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_SchemaResolveFailed(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"), + } + + _, err := svc.Create("org-1", "creator-uuid", req) + if err == nil { + t.Fatal("expected an error for a blocked sdlUrl") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPISchemaResolveFailed { + t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPISchemaResolveFailed, code) + } + if repo.created != nil { + t.Error("expected no repository write when sdlUrl fetch fails") + } +} + +// TestGraphQLCreate_SDLUrlFetchFailure_DoesNotFallBackToIntrospection locks in +// a real design decision in resolveSchema: a failed sdlUrl fetch fails the +// request outright — it does NOT silently fall back to introspecting +// upstream.main.url, even when that upstream is present and reachable. The +// introspection endpoint must never be called in this case. +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)}}, + } + + _, err := svc.Create("org-1", "creator-uuid", req) + if err == nil { + t.Fatal("expected an error for a blocked sdlUrl, even with a reachable upstream present") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPISchemaResolveFailed { + t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPISchemaResolveFailed, code) + } + if repo.created != nil { + t.Error("expected no repository write when sdlUrl fetch fails") + } +} + +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) + } +} + +// 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_NoPartialWrite pins the "no partial +// write" guarantee: resolveSchema runs — and can fail — before Update +// mutates the in-memory existing record or calls repo.Update, so a failed +// re-introspection must leave the stored config completely untouched. +func TestGraphQLUpdate_ReIntrospectFails_NoPartialWrite(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)}}, + } + + _, err := svc.Update("org-1", "countries-graphql-api", "updater-uuid", req) + if err == nil { + t.Fatal("expected an error when re-introspection fails") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPISchemaResolveFailed { + t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPISchemaResolveFailed, code) + } + if repo.updated != nil { + t.Error("expected no repository write when re-introspection fails") + } + if stored.Configuration.SDL != validCountriesGraphQLSDL { + t.Errorf("expected the in-memory existing record to be left unchanged, got sdl: %q", stored.Configuration.SDL) + } +} + +// TestGraphQLUpdate_MalformedSDL_UnprocessableEntity is Update's counterpart +// to TestGraphQLCreate_MalformedSDL_UnprocessableEntity — resolveSchema's SDL +// parse validation is shared by both entry points, but only Create had a test +// pinning it; a broken update must be rejected without touching storage. +func TestGraphQLUpdate_MalformedSDL_UnprocessableEntity(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")}}, + } + + _, err := svc.Update("org-1", "countries-graphql-api", "updater-uuid", req) + if err == nil { + t.Fatal("expected an error for malformed SDL") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPISchemaResolveFailed { + t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPISchemaResolveFailed, code) + } + if repo.updated != nil { + t.Error("expected no repository write for malformed SDL") + } +} + +// TestGraphQLUpdate_SDLWithNoQueryRoot_UnprocessableEntity is Update's +// counterpart to TestGraphQLCreate_SDLWithNoQueryRoot_UnprocessableEntity. +func TestGraphQLUpdate_SDLWithNoQueryRoot_UnprocessableEntity(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")}}, + } + + _, err := svc.Update("org-1", "countries-graphql-api", "updater-uuid", req) + if err == nil { + t.Fatal("expected an error for SDL with no Query root type") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPISchemaResolveFailed { + t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPISchemaResolveFailed, code) + } + if repo.updated != nil { + t.Error("expected no repository write for a schema with no Query root type") + } +} + +// TestGraphQLUpdate_SDLAndSDLUrlMutuallyExclusive is Update's counterpart to +// TestGraphQLCreate_SDLAndSDLUrlMutuallyExclusive — the same resolveSchema +// validation is shared by both entry points. +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_SchemaResolveFailed is Update's +// counterpart to TestGraphQLCreate_SDLUrlFetchFailure_SchemaResolveFailed. +func TestGraphQLUpdate_SDLUrlFetchFailure_SchemaResolveFailed(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"), + } + + _, err := svc.Update("org-1", "countries-graphql-api", "updater-uuid", req) + if err == nil { + t.Fatal("expected an error for a blocked sdlUrl") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPISchemaResolveFailed { + t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPISchemaResolveFailed, code) + } + if repo.updated != nil { + t.Error("expected no repository write when sdlUrl fetch fails") + } +} + +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..2e65f5998e --- /dev/null +++ b/platform-api/internal/service/graphql_apikey_test.go @@ -0,0 +1,114 @@ +/* + * 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()) + + createReq := &api.CreateAPIKeyRequest{ + ApiKey: "test-plaintext-key", + 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()) + + createReq := &api.CreateAPIKeyRequest{ApiKey: "test-plaintext-key", 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..8e27ea987b --- /dev/null +++ b/platform-api/internal/service/graphql_deployment.go @@ -0,0 +1,592 @@ +/* + * 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 + 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, + gatewayEventsService *GatewayEventsService, + cfg *config.Server, + slogger *slog.Logger, +) *GraphQLAPIDeploymentService { + return &GraphQLAPIDeploymentService{ + graphqlRepo: graphqlRepo, + deploymentRepo: deploymentRepo, + gatewayRepo: gatewayRepo, + orgRepo: orgRepo, + apiKeyRepo: apiKeyRepo, + gatewayEventsService: gatewayEventsService, + 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 + }, + } + } + + 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.") + } + if req.Base == "" { + return nil, apperror.GraphQLAPIDeploymentValidationFailed.New("Base is required (use 'current' or a deploymentId).") + } + 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 + } + + var baseDeploymentID *string + var contentBytes []byte + + if req.Base == "current" { + apiDeployment, err := generateGraphQLAPIDeploymentYAML(apiModel) + if err != nil { + return nil, fmt.Errorf("failed to generate GraphQL API deployment YAML: %w", err) + } + sourceDataVersion := gatewaytranslator.PlatformDataVersion(apiModel.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) + } + yamlBytes, marshalErr := yaml.Marshal(apiDeployment) + if marshalErr != nil { + return nil, fmt.Errorf("failed to marshal GraphQL API deployment YAML: %w", marshalErr) + } + contentBytes = yamlBytes + } else { + baseDeployment, err := s.deploymentRepo.GetWithContent(req.Base, apiModel.ID, orgUUID) + if err != nil { + if apperror.DeploymentNotFound.Is(err) { + return nil, apperror.DeploymentBaseNotFound.Wrap(err) + } + return nil, fmt.Errorf("failed to get base deployment: %w", err) + } + contentBytes = baseDeployment.Content + baseDeploymentID = &req.Base + } + + 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, + BaseDeploymentID: baseDeploymentID, + 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 + if err := s.deploymentRepo.CreateWithLimitEnforcement(deployment, hardLimit); err != nil { + 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..80db15e7eb --- /dev/null +++ b/platform-api/internal/service/graphql_deployment_test.go @@ -0,0 +1,352 @@ +/* + * 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" +) + +// 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, + nil, + &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 }, + } + 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 }, + } + // 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) + } +} + +// 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..26f87fd417 --- /dev/null +++ b/platform-api/internal/service/graphql_introspection.go @@ -0,0 +1,428 @@ +/* + * 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" + "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) + } + + httpReq, err := http.NewRequest(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 — not the stricter public-only policy + // FetchOpenAPISpecFromURL uses for fetching a public vendor's OpenAPI doc. + 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 ae96fc115e..148f405384 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/graphql_multipart.go b/platform-api/internal/utils/graphql_multipart.go new file mode 100644 index 0000000000..fe3e493711 --- /dev/null +++ b/platform-api/internal/utils/graphql_multipart.go @@ -0,0 +1,113 @@ +/* + * 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 — mirrors the 5 MiB + // ceiling the CLI's standalone-gateway sdlFile path already uses + // (cli/src/cmd/gateway/apply.go's maxGraphQLSDLFileBytes), so the limit is + // consistent regardless of which onboarding surface supplied the file. + 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/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 46f76544e1..aec53e1081 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -1013,6 +1013,837 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /graphql-apis: + get: + summary: 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. + operationId: ListGraphQLAPIs + security: + - OAuth2Security: + - ap:graphql_api:read + - ap:graphql_api:manage + tags: + - 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. The schema can be supplied in one + of three ways: `sdl` (pasted inline), `sdlUrl` (fetched from a raw SDL + document URL), or a `multipart/form-data` file upload (`sdlFile`) — exactly + one of these, or none at all, in which case `upstream.main.url` must expose + standard GraphQL introspection and the schema is derived server-side. 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: + description: | + GraphQL API object that needs to be added. Use `application/json` for + inline `sdl`/`sdlUrl`/introspection-only requests. Use + `multipart/form-data` to upload the SDL as a file instead — see + GraphQLAPIMultipartRequest. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateGraphQLAPIRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/GraphQLAPIMultipartRequest' + required: true + responses: + '201': + description: GraphQL API created successfully + headers: + Location: + $ref: '#/components/headers/Location' + content: + application/json: + schema: + $ref: '#/components/schemas/GraphQLAPI' + '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' + '422': + description: The provided endpoint could not be used to derive a GraphQL schema (introspection failed or was rejected), or the supplied SDL failed to parse. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalServerError' + + /graphql-apis/{graphqlApiId}: + get: + summary: 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`. + operationId: GetGraphQLAPI + security: + - OAuth2Security: + - ap:graphql_api:read + - 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: + '200': + description: GraphQL API retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GraphQLAPIDetail' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + put: + summary: Update GraphQL API + description: | + Updates an existing GraphQL API's details, including re-supplying `sdl` or + re-introspecting `upstream.main.url` to pick up a changed backend schema. + operationId: UpdateGraphQLAPI + security: + - OAuth2Security: + - ap:graphql_api:update + - 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 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GraphQLAPI' + multipart/form-data: + schema: + $ref: '#/components/schemas/GraphQLAPIMultipartRequest' + responses: + '200': + description: GraphQL API updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GraphQLAPI' + '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' + '422': + description: Re-introspection or re-parsing of the updated schema failed. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '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' + + /graphql-apis/{graphqlApiId}/sdl: + get: + summary: 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). + operationId: GetGraphQLAPISDL + security: + - OAuth2Security: + - ap:graphql_api:read + - 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: + '200': + description: SDL retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GraphQLAPISDLResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /graphql-apis/{graphqlApiId}/gateways: + get: + summary: 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. + operationId: GetGraphQLAPIGateways + security: + - OAuth2Security: + - ap:graphql_api:gateway:read + - ap:graphql_api:gateway:manage + - ap:graphql_api:manage + - ap:gateway:read + - ap:gateway:manage + tags: + - GraphQL APIs + - Gateways + 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 + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' + responses: + '200': + description: List of gateways associated with the API, including deployment details + content: + application/json: + schema: + $ref: '#/components/schemas/RESTAPIGatewayListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + post: + summary: 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. + operationId: AddGatewaysToGraphQLAPI + security: + - OAuth2Security: + - ap:graphql_api:gateway:create + - ap:graphql_api:gateway:manage + - ap:graphql_api:manage + tags: + - GraphQL APIs + - Gateways + 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: List of gateways to associate with the API + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AddGatewayToRESTAPIRequest' + responses: + '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': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /graphql-apis/{graphqlApiId}/api-keys: + post: + summary: 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. + operationId: CreateGraphQLAPIKey + security: + - OAuth2Security: + - ap:graphql_api:api_key:create + - ap:graphql_api:api_key:manage + - ap:graphql_api:manage + - ap:api_key:all:manage + tags: + - 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/CreateAPIKeyRequest' + responses: + '201': + description: API key created successfully + headers: + Location: + $ref: '#/components/headers/Location' + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAPIKeyResponse' + '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' + '500': + $ref: '#/components/responses/InternalServerError' + + /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:graphql_api:api_key:update + - ap:graphql_api:api_key:manage + - ap:graphql_api:manage + - ap:api_key:all:manage + tags: + - 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 + - name: apiKeyId + in: path + required: true + 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/UpdateAPIKeyRequest' + responses: + '200': + description: API key updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateAPIKeyResponse' + '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' + '500': + $ref: '#/components/responses/InternalServerError' + + delete: + 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:graphql_api:api_key:delete + - ap:graphql_api:api_key:manage + - ap:graphql_api:manage + - ap:api_key:all:manage + tags: + - 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 + - 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: API key revoked successfully (no content) + '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' + '500': + $ref: '#/components/responses/InternalServerError' + + /graphql-apis/{graphqlApiId}/deployments: + post: + summary: 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. + operationId: DeployGraphQLAPI + security: + - OAuth2Security: + - ap:graphql_api:deployment:create + - ap:graphql_api:deployment:manage + - ap:graphql_api:manage + tags: + - GraphQL API Deployments + - Deployments + 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: Deployment request with gateway ID, base reference, and metadata + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DeployRequest' + responses: + '201': + description: GraphQL API 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 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. + operationId: GetGraphQLAPIDeployments + security: + - OAuth2Security: + - ap:graphql_api:deployment:read + - ap:graphql_api:deployment:manage + - ap:graphql_api:manage + tags: + - GraphQL API Deployments + - Deployments + 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 + - $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' + + /graphql-apis/{graphqlApiId}/deployments/{deploymentId}: + get: + summary: 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. + operationId: GetGraphQLAPIDeployment + security: + - OAuth2Security: + - ap:graphql_api:deployment:read + - ap:graphql_api:deployment:manage + - ap:graphql_api:manage + tags: + - GraphQL API Deployments + - Deployments + 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 + - $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 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: DeleteGraphQLAPIDeployment + security: + - OAuth2Security: + - ap:graphql_api:deployment:delete + - ap:graphql_api:deployment:manage + - ap:graphql_api:manage + tags: + - GraphQL API Deployments + - Deployments + 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 + - $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' + + /graphql-apis/{graphqlApiId}/deployments/{deploymentId}/undeploy: + post: + summary: 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. + operationId: UndeployGraphQLAPIDeployment + security: + - OAuth2Security: + - ap:graphql_api:deployment:undeploy + - ap:graphql_api:deployment:manage + - ap:graphql_api:manage + tags: + - GraphQL API Deployments + - Deployments + 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 + - 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: + '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' + + /graphql-apis/{graphqlApiId}/deployments/{deploymentId}/restore: + post: + 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. + 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: RestoreGraphQLAPIDeployment + security: + - OAuth2Security: + - ap:graphql_api:deployment:restore + - ap:graphql_api:deployment:manage + - ap:graphql_api:manage + tags: + - GraphQL API Deployments + - Deployments + 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 + - 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: Restore initiated successfully. Returns the deployment with initial status DEPLOYING. Poll status for final result. + 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' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + /llm-provider-templates: post: summary: Create a new LLM provider template family @@ -4760,6 +5591,24 @@ components: ap:gateway:token:manage: Full access to gateway tokens ap:gateway:token:read: Read gateway tokens ap:gateway:update: Update a gateway + 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 @@ -6182,6 +7031,394 @@ components: - version - projectId + 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 + pattern: '^[a-z0-9-]+$' + minLength: 3 + maxLength: 63 + 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 `sdl`/`introspectionMode` below). + kind: + type: string + description: Kind of the API based on its communication protocol or architectural style + example: GraphQLApi + default: GraphQLApi + sdl: + type: string + description: | + The GraphQL schema in SDL form, supplied directly (pasted/uploaded) or + resolved from `sdlUrl`. Optional on create — if all of `sdl`, `sdlUrl`, + and a reachable `upstream.main.url` are omitted, creation fails; if only + `upstream.main.url` is given, it must expose standard GraphQL + introspection and the schema is derived server-side. Always + the *resolved* schema, never a document-supplied schema-location + reference. `sdl` and `sdlUrl` are mutually exclusive on a request; this + field always holds the resolved text on every read regardless of which + input path produced it. + example: | + type Query { + countries: [Country] + country(code: ID!): Country + } + type Country { + code: String + name: String + capital: String + } + sdlUrl: + type: string + format: uri + writeOnly: true + description: | + A URL to a raw SDL document to fetch and use as `sdl` — 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 with the same public-internet-only SSRF hardening as an + OpenAPI-spec-by-URL fetch (loopback/private/link-local/metadata addresses + refused) — it is not meant for a tenant's own in-cluster backend. Mutually + exclusive with `sdl`. 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. + items: + $ref: '#/components/schemas/Policy' + subscriptionPlans: + type: array + description: List of subscription plan names enabled for this API. + items: + type: string + example: [Gold, Silver] + + # 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: + - displayName + - context + - version + - projectId + - upstream + type: object + properties: + id: + type: string + description: Unique handle/identifier for the API. + 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 + example: "john.doe" + projectId: + type: string + pattern: '^[a-z0-9-]+$' + minLength: 3 + maxLength: 63 + 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. + items: + $ref: '#/components/schemas/Policy' + subscriptionPlans: + type: array + description: List of subscription plan names enabled for this API. + items: + type: string + example: [Gold, Silver] + + GraphQLAPISDLResponse: + title: GraphQL API SDL + type: object + required: + - sdl + properties: + 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. When a non-empty `sdlFile` part is + uploaded, it overrides any `sdl`/`sdlUrl` included here. When no + `sdlFile` part is uploaded, this metadata's own `sdl`/`sdlUrl` (or + upstream introspection) is used unchanged. + example: | + {"displayName":"Countries GraphQL API","context":"/countries","version":"v1.0","projectId":"default-project","upstream":{"main":{"url":"https://countries.trevorblades.com/graphql"}}} + sdlFile: + type: string + format: binary + description: The GraphQL SDL document as a file upload (e.g. schema.graphql). + + GraphQLAPIListItem: + title: GraphQL API list item + type: object + required: + - displayName + - context + - version + - projectId + properties: + 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 + 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/GraphQLAPIListItem' + pagination: + $ref: '#/components/schemas/Pagination' + TimeUnit: type: string description: Time unit for API key expiration duration @@ -9004,6 +10241,10 @@ tags: description: API management operations - name: REST API Deployments description: API deployment artifact management and lifecycle operations + - 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 @@ -9025,7 +10266,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 5861ef6d7e..eae98ae8a2 100644 --- a/platform-api/resources/role-to-scope-mapping.yaml +++ b/platform-api/resources/role-to-scope-mapping.yaml @@ -81,6 +81,7 @@ roles: - ap:api_key:all:manage # - 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 @@ -133,6 +134,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 @@ -178,6 +181,7 @@ roles: - ap:api_key: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 @@ -209,6 +213,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 @@ -260,6 +265,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/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) + } +}