From da6a112af718b3bb4c235af37dbda2d34fedf7fa Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Tue, 25 Aug 2026 12:24:37 +0530 Subject: [PATCH 01/38] Add graphQL API related DB schema changes --- .../gateway-controller-db.postgres.sql | 12 +++++++ .../pkg/storage/gateway-controller-db.sql | 12 +++++++ .../gateway-controller-db.sqlserver.sql | 13 ++++++++ go.work.sum | 4 +++ .../internal/database/schema.postgres.sql | 26 ++++++++++++++++ platform-api/internal/database/schema.sql | 26 ++++++++++++++++ .../internal/database/schema.sqlite.sql | 26 ++++++++++++++++ .../internal/database/schema.sqlserver.sql | 31 +++++++++++++++++++ 8 files changed, 150 insertions(+) diff --git a/gateway/gateway-controller/pkg/storage/gateway-controller-db.postgres.sql b/gateway/gateway-controller/pkg/storage/gateway-controller-db.postgres.sql index ded871c419..7f50419b10 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.postgres.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.postgres.sql @@ -87,6 +87,18 @@ CREATE TABLE IF NOT EXISTS agents ( FOREIGN KEY(gateway_id, uuid) REFERENCES artifacts(gateway_id, uuid) ON DELETE CASCADE ); +-- GraphQL is not a separate product the way event-gateway is (see the websub_apis/ +-- webbroker_apis note above), so graphql_apis is defined directly here as a +-- one-column-identical clone of rest_apis, instead of being owned by a separate +-- supplemental-DDL module. +CREATE TABLE IF NOT EXISTS graphql_apis ( + uuid TEXT NOT NULL, + gateway_id TEXT NOT NULL, + configuration TEXT NOT NULL, + PRIMARY KEY (gateway_id, uuid), + FOREIGN KEY(gateway_id, uuid) REFERENCES artifacts(gateway_id, uuid) ON DELETE CASCADE +); + -- Table for custom TLS certificates CREATE TABLE IF NOT EXISTS certificates ( uuid TEXT NOT NULL, diff --git a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql index 6b426cb6bf..7c337765e2 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql @@ -91,6 +91,18 @@ CREATE TABLE IF NOT EXISTS agents ( FOREIGN KEY(gateway_id, uuid) REFERENCES artifacts(gateway_id, uuid) ON DELETE CASCADE ); +-- GraphQL is not a separate product the way event-gateway is (see the websub_apis/ +-- webbroker_apis note above), so graphql_apis is defined directly here as a +-- one-column-identical clone of rest_apis, instead of being owned by a separate +-- supplemental-DDL module. +CREATE TABLE IF NOT EXISTS graphql_apis ( + uuid TEXT NOT NULL, + gateway_id TEXT NOT NULL, + configuration TEXT NOT NULL, + PRIMARY KEY (gateway_id, uuid), + FOREIGN KEY(gateway_id, uuid) REFERENCES artifacts(gateway_id, uuid) ON DELETE CASCADE +); + -- Note: Policy definitions are no longer stored in the database. -- They are loaded from files at controller startup (see policies/ directory). -- The policy_definitions table has been removed as of schema version 3. diff --git a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql index 39ab21f2fb..a4f9c01ea6 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sqlserver.sql @@ -120,6 +120,19 @@ CREATE TABLE dbo.agents ( FOREIGN KEY(gateway_id, uuid) REFERENCES dbo.artifacts(gateway_id, uuid) ON DELETE CASCADE ); +-- GraphQL is not a separate product the way event-gateway is (see the websub_apis/ +-- webbroker_apis note above), so graphql_apis is defined directly here as a +-- one-column-identical clone of rest_apis, instead of being owned by a separate +-- supplemental-DDL module. +IF OBJECT_ID(N'dbo.graphql_apis', N'U') IS NULL +CREATE TABLE dbo.graphql_apis ( + uuid NVARCHAR(64) NOT NULL, + gateway_id NVARCHAR(64) NOT NULL, + configuration NVARCHAR(MAX) NOT NULL, + PRIMARY KEY (gateway_id, uuid), + FOREIGN KEY(gateway_id, uuid) REFERENCES dbo.artifacts(gateway_id, uuid) ON DELETE CASCADE +); + -- Table for custom TLS certificates IF OBJECT_ID(N'dbo.certificates', N'U') IS NULL CREATE TABLE dbo.certificates ( diff --git a/go.work.sum b/go.work.sum index 51cce59f5c..b3374a5d04 100644 --- a/go.work.sum +++ b/go.work.sum @@ -2214,6 +2214,7 @@ github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agnivade/levenshtein v1.2.0/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9 h1:7kQgkwGRoLzC9K0oyXdJo7nve/bynv/KwUsxbiTlzAM= @@ -2290,6 +2291,7 @@ github.com/apparentlymart/go-cidr v1.0.1 h1:NmIwLZ/KdsjIUlhf+/Np40atNXm/+lZ5txfT github.com/apparentlymart/go-cidr v1.0.1/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/ardielle/ardielle-go v1.5.2 h1:TilHTpHIQJ27R1Tl/iITBzMwiUGSlVfiVhwDNGM3Zj4= github.com/ardielle/ardielle-go v1.5.2/go.mod h1:I4hy1n795cUhaVt/ojz83SNVCYIGsAFAONtv2Dr7HUI= @@ -2823,6 +2825,7 @@ github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczC github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954 h1:RMLoZVzv4GliuWafOuPuQDKSm1SJph7uCRnnS61JAn4= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/digitalocean/godo v1.109.0/go.mod h1:R6EmmWI8CT1+fCtjWY9UCB+L5uufuZH13wk3YhxycCs= github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= @@ -4503,6 +4506,7 @@ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+ github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a h1:0R4NLDRDZX6JcmhJgXi5E4b8Wg84ihbmUKp/GvSPEzc= github.com/vbatts/tar-split v0.11.3/go.mod h1:9QlHN18E+fEH7RdG+QAJJcuya3rqT7eXSTY7wGrAokY= github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= +github.com/vektah/gqlparser/v2 v2.5.36 h1:CN9mKVHgMkc+XftdOWIhb4HEL8wKSYkFAqhf8booa7s= github.com/vektah/gqlparser/v2 v2.5.36/go.mod h1:cAJ9qwVgPaUkWv6Gn8vn0mqOE0Ui5Pn56wNy5396XWo= github.com/veraison/go-cose v1.1.0/go.mod h1:7ziE85vSq4ScFTg6wyoMXjucIGOf4JkFEZi/an96Ct4= github.com/veraison/go-cose v1.3.0/go.mod h1:df09OV91aHoQWLmy1KsDdYiagtXgyAwAl8vFeFn1gMc= diff --git a/platform-api/internal/database/schema.postgres.sql b/platform-api/internal/database/schema.postgres.sql index 0eb5480641..6cb71c1d23 100644 --- a/platform-api/internal/database/schema.postgres.sql +++ b/platform-api/internal/database/schema.postgres.sql @@ -597,6 +597,29 @@ 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), + lifecycle_status VARCHAR(20) NOT NULL DEFAULT 'CREATED', + configuration BYTEA NOT NULL, + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + origin VARCHAR(20) NOT NULL DEFAULT 'control_plane', + created_by VARCHAR(200), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(200), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, + FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, + FOREIGN KEY (project_uuid) REFERENCES projects(uuid) ON DELETE CASCADE, + UNIQUE(organization_uuid, handle) +); + CREATE TABLE IF NOT EXISTS api_keys ( uuid VARCHAR(40) PRIMARY KEY, artifact_uuid VARCHAR(40) NOT NULL, @@ -671,6 +694,9 @@ CREATE INDEX IF NOT EXISTS idx_llm_proxies_org ON llm_proxies(organization_uuid) CREATE INDEX IF NOT EXISTS idx_mcp_proxies_project ON mcp_proxies(project_uuid); CREATE INDEX IF NOT EXISTS idx_mcp_proxies_org ON mcp_proxies(organization_uuid); CREATE INDEX IF NOT EXISTS idx_api_portals_org ON api_portals(organization_uuid); +CREATE INDEX IF NOT EXISTS idx_graphql_apis_project ON graphql_apis(project_uuid); +CREATE INDEX IF NOT EXISTS idx_graphql_apis_org ON graphql_apis(organization_uuid); +CREATE INDEX IF NOT EXISTS idx_graphql_apis_lifecycle_status ON graphql_apis(lifecycle_status); CREATE INDEX IF NOT EXISTS idx_api_keys_artifact ON api_keys(artifact_uuid); CREATE INDEX IF NOT EXISTS idx_applications_org ON applications(organization_uuid); CREATE INDEX IF NOT EXISTS idx_applications_project_id ON applications(organization_uuid, project_uuid); diff --git a/platform-api/internal/database/schema.sql b/platform-api/internal/database/schema.sql index 191d95d97b..e8f8ed6764 100644 --- a/platform-api/internal/database/schema.sql +++ b/platform-api/internal/database/schema.sql @@ -410,6 +410,29 @@ 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), + lifecycle_status VARCHAR(20) NOT NULL DEFAULT 'CREATED', + configuration BLOB NOT NULL, -- JSON: SDL + upstream + policies + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + origin VARCHAR(20) NOT NULL DEFAULT 'control_plane', + created_by VARCHAR(200), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(200), + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, + FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, + FOREIGN KEY (project_uuid) REFERENCES projects(uuid) ON DELETE CASCADE, + UNIQUE(organization_uuid, handle) +); + CREATE TABLE IF NOT EXISTS api_keys ( uuid VARCHAR(40) PRIMARY KEY, @@ -486,6 +509,9 @@ 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_graphql_apis_lifecycle_status ON graphql_apis(lifecycle_status); CREATE INDEX IF NOT EXISTS idx_api_keys_artifact ON api_keys(artifact_uuid); CREATE INDEX IF NOT EXISTS idx_rest_apis_org ON rest_apis(organization_uuid); CREATE INDEX IF NOT EXISTS idx_applications_org ON applications(organization_uuid); diff --git a/platform-api/internal/database/schema.sqlite.sql b/platform-api/internal/database/schema.sqlite.sql index 9eb5ab4908..e6469f2e72 100644 --- a/platform-api/internal/database/schema.sqlite.sql +++ b/platform-api/internal/database/schema.sqlite.sql @@ -599,6 +599,29 @@ 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), + lifecycle_status VARCHAR(20) NOT NULL DEFAULT 'CREATED', + configuration BLOB NOT NULL, -- JSON: SDL + upstream + policies + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + origin VARCHAR(20) NOT NULL DEFAULT 'control_plane', + created_by VARCHAR(200), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(200), + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, + FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE, + FOREIGN KEY (project_uuid) REFERENCES projects(uuid) ON DELETE CASCADE, + UNIQUE(organization_uuid, handle) +); + -- API Keys table (stores API keys for artifacts with hashes as JSON string) CREATE TABLE IF NOT EXISTS api_keys ( uuid VARCHAR(40) PRIMARY KEY, @@ -673,6 +696,9 @@ CREATE INDEX IF NOT EXISTS idx_llm_proxies_org ON llm_proxies(organization_uuid) CREATE INDEX IF NOT EXISTS idx_mcp_proxies_project ON mcp_proxies(project_uuid); CREATE INDEX IF NOT EXISTS idx_mcp_proxies_org ON mcp_proxies(organization_uuid); CREATE INDEX IF NOT EXISTS idx_api_portals_org ON api_portals(organization_uuid); +CREATE INDEX IF NOT EXISTS idx_graphql_apis_project ON graphql_apis(project_uuid); +CREATE INDEX IF NOT EXISTS idx_graphql_apis_org ON graphql_apis(organization_uuid); +CREATE INDEX IF NOT EXISTS idx_graphql_apis_lifecycle_status ON graphql_apis(lifecycle_status); CREATE INDEX IF NOT EXISTS idx_api_keys_artifact ON api_keys(artifact_uuid); CREATE INDEX IF NOT EXISTS idx_rest_apis_org ON rest_apis(organization_uuid); CREATE INDEX IF NOT EXISTS idx_applications_org ON applications(organization_uuid); diff --git a/platform-api/internal/database/schema.sqlserver.sql b/platform-api/internal/database/schema.sqlserver.sql index f6ba87b355..b24738c86b 100644 --- a/platform-api/internal/database/schema.sqlserver.sql +++ b/platform-api/internal/database/schema.sqlserver.sql @@ -683,6 +683,31 @@ 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), + lifecycle_status VARCHAR(20) NOT NULL DEFAULT 'CREATED', + configuration VARBINARY(MAX) NOT NULL, + data_version VARCHAR(20) NOT NULL DEFAULT '1.0', + origin VARCHAR(20) NOT NULL DEFAULT 'control_plane', + created_by VARCHAR(200), + created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), + updated_by VARCHAR(200), + updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), + FOREIGN KEY (uuid) REFERENCES artifacts(uuid) ON DELETE CASCADE, + -- NO ACTION to avoid SQL Server multiple-cascade-paths restriction (error 1785). + FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION, + FOREIGN KEY (project_uuid) REFERENCES projects(uuid) ON DELETE CASCADE, + UNIQUE(organization_uuid, handle) +); + IF OBJECT_ID(N'dbo.api_keys', N'U') IS NULL CREATE TABLE dbo.api_keys ( uuid VARCHAR(40) PRIMARY KEY, @@ -789,6 +814,12 @@ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_mcp_proxies_org' AND CREATE INDEX idx_mcp_proxies_org ON dbo.mcp_proxies(organization_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_portals_org' AND object_id = OBJECT_ID(N'dbo.api_portals')) CREATE INDEX idx_api_portals_org ON dbo.api_portals(organization_uuid); +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_graphql_apis_project' AND object_id = OBJECT_ID(N'dbo.graphql_apis')) +CREATE INDEX idx_graphql_apis_project ON dbo.graphql_apis(project_uuid); +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_graphql_apis_org' AND object_id = OBJECT_ID(N'dbo.graphql_apis')) +CREATE INDEX idx_graphql_apis_org ON dbo.graphql_apis(organization_uuid); +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_graphql_apis_lifecycle_status' AND object_id = OBJECT_ID(N'dbo.graphql_apis')) +CREATE INDEX idx_graphql_apis_lifecycle_status ON dbo.graphql_apis(lifecycle_status); 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')) From 0341397a23e613ce4197eebabf7aad82e2164fac Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Tue, 25 Aug 2026 13:15:06 +0530 Subject: [PATCH 02/38] Update OpenAPI specs for GraphQL API management and regenerate server code --- .../api/management-openapi.yaml | 383 ++++++ .../pkg/api/management/generated.go | 1076 ++++++++++------ platform-api/api/generated.go | 327 ++++- platform-api/resources/openapi.yaml | 1131 ++++++++++++++++- 4 files changed, 2510 insertions(+), 407 deletions(-) diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index 00634da35f..2505c76676 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 + - 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 @@ -3519,6 +3761,145 @@ 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 + 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. + pattern: '^\/([a-zA-Z0-9_\-\/]*[^\/])?$' + minLength: 1 + maxLength: 200 + example: /countries/$version + 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. + 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 + 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 + upstream: + main: + url: https://countries.trevorblades.com/graphql + policies: + - name: jwt-auth + version: v1 + status: + id: countries-graphql-api-v1.0 + state: deployed + createdAt: 2026-08-11T10:00:00Z + updatedAt: 2026-08-11T10:00:00Z + deployedAt: 2026-08-11T10:00:00Z + UpstreamDefinition: type: object required: @@ -6494,6 +6875,8 @@ components: tags: - name: Rest API Management description: CRUD operations for Rest APIs + - name: GraphQL API Management + description: CRUD operations for GraphQL APIs - name: MCP Proxy Management description: CRUD operations for MCPProxies - name: Agent Management diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index 9d8e9d5542..e0959c4e58 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -157,6 +157,32 @@ const ( QueryParam ExtractionIdentifierLocation = "queryParam" ) +// Defines values for GraphQLAPIApiVersion. +const ( + GraphQLAPIApiVersionGatewayApiPlatformWso2Comv1 GraphQLAPIApiVersion = "gateway.api-platform.wso2.com/v1" +) + +// Defines values for GraphQLAPIKind. +const ( + GraphQLAPIKindGraphQLApi GraphQLAPIKind = "GraphQLApi" +) + +// Defines values for GraphQLAPIConfigDataDeploymentState. +const ( + GraphQLAPIConfigDataDeploymentStateDeployed GraphQLAPIConfigDataDeploymentState = "deployed" + GraphQLAPIConfigDataDeploymentStateUndeployed GraphQLAPIConfigDataDeploymentState = "undeployed" +) + +// Defines values for GraphQLAPIRequestApiVersion. +const ( + GraphQLAPIRequestApiVersionGatewayApiPlatformWso2Comv1 GraphQLAPIRequestApiVersion = "gateway.api-platform.wso2.com/v1" +) + +// Defines values for GraphQLAPIRequestKind. +const ( + GraphQLAPIRequestKindGraphQLApi GraphQLAPIRequestKind = "GraphQLApi" +) + // Defines values for LLMAccessControlMode. const ( AllowAll LLMAccessControlMode = "allow_all" @@ -494,6 +520,12 @@ const ( ListAgentsParamsStatusUndeployed ListAgentsParamsStatus = "undeployed" ) +// Defines values for ListGraphQLAPIsParamsStatus. +const ( + ListGraphQLAPIsParamsStatusDeployed ListGraphQLAPIsParamsStatus = "deployed" + ListGraphQLAPIsParamsStatusUndeployed ListGraphQLAPIsParamsStatus = "undeployed" +) + // Defines values for ListLLMProvidersParamsStatus. const ( ListLLMProvidersParamsStatusDeployed ListLLMProvidersParamsStatus = "deployed" @@ -1043,6 +1075,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. + 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. + 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 + Version string `json:"version" yaml:"version"` +} + +// GraphQLAPIConfigDataDeploymentState Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the API is removed from router traffic but configuration, API keys, and policies are preserved for potential redeployment. +type GraphQLAPIConfigDataDeploymentState string + +// GraphQLAPIRequest defines model for GraphQLAPIRequest. +type GraphQLAPIRequest struct { + // ApiVersion API specification version + ApiVersion GraphQLAPIRequestApiVersion `json:"apiVersion" yaml:"apiVersion"` + + // Kind API type + Kind GraphQLAPIRequestKind `json:"kind" yaml:"kind"` + Metadata Metadata `json:"metadata" yaml:"metadata"` + Spec GraphQLAPIConfigData `json:"spec" yaml:"spec"` +} + +// GraphQLAPIRequestApiVersion API specification version +type GraphQLAPIRequestApiVersion string + +// GraphQLAPIRequestKind API type +type GraphQLAPIRequestKind string + // LLMAccessControl defines model for LLMAccessControl. type LLMAccessControl struct { // Exceptions Path exceptions to the access control mode @@ -2194,6 +2296,24 @@ type ListAgentsParams struct { // ListAgentsParamsStatus defines parameters for ListAgents. type ListAgentsParamsStatus string +// ListGraphQLAPIsParams defines parameters for ListGraphQLAPIs. +type ListGraphQLAPIsParams struct { + // DisplayName Filter by API display name + DisplayName *string `form:"displayName,omitempty" json:"displayName,omitempty" yaml:"displayName,omitempty"` + + // Version Filter by API version + Version *string `form:"version,omitempty" json:"version,omitempty" yaml:"version,omitempty"` + + // Context Filter by API context/path + Context *string `form:"context,omitempty" json:"context,omitempty" yaml:"context,omitempty"` + + // Status Filter by deployment status + Status *ListGraphQLAPIsParamsStatus `form:"status,omitempty" json:"status,omitempty" yaml:"status,omitempty"` +} + +// ListGraphQLAPIsParamsStatus defines parameters for ListGraphQLAPIs. +type ListGraphQLAPIsParamsStatus string + // ListLLMProviderTemplatesParams defines parameters for ListLLMProviderTemplates. type ListLLMProviderTemplatesParams struct { // DisplayName Filter by template display name @@ -2307,6 +2427,12 @@ type RegenerateAgentAPIKeyJSONRequestBody = APIKeyRegenerationRequest // UploadCertificateJSONRequestBody defines body for UploadCertificate for application/json ContentType. type UploadCertificateJSONRequestBody = CertificateUploadRequest +// CreateGraphQLAPIJSONRequestBody defines body for CreateGraphQLAPI for application/json ContentType. +type CreateGraphQLAPIJSONRequestBody = GraphQLAPIRequest + +// UpdateGraphQLAPIJSONRequestBody defines body for UpdateGraphQLAPI for application/json ContentType. +type UpdateGraphQLAPIJSONRequestBody = GraphQLAPIRequest + // CreateLLMProviderTemplateJSONRequestBody defines body for CreateLLMProviderTemplate for application/json ContentType. type CreateLLMProviderTemplateJSONRequestBody = LLMProviderTemplateRequest @@ -2964,6 +3090,21 @@ 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) // List all LLM provider templates // (GET /llm-provider-templates) ListLLMProviderTemplates(w http.ResponseWriter, r *http.Request, params ListLLMProviderTemplatesParams) @@ -3583,6 +3724,176 @@ 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) +} + // ListLLMProviderTemplates operation middleware func (siw *ServerInterfaceWrapper) ListLLMProviderTemplates(w http.ResponseWriter, r *http.Request) { @@ -5533,6 +5844,11 @@ 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+"/llm-provider-templates", wrapper.ListLLMProviderTemplates) m.HandleFunc("POST "+options.BaseURL+"/llm-provider-templates", wrapper.CreateLLMProviderTemplate) m.HandleFunc("DELETE "+options.BaseURL+"/llm-provider-templates/{id}", wrapper.DeleteLLMProviderTemplate) @@ -5595,380 +5911,392 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "X/3/AwAgasrCZXsCAA==", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index 4420fefef0..d227e030fd 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" @@ -444,6 +450,28 @@ const ( ListGatewaysParamsSortOrderDesc ListGatewaysParamsSortOrder = "desc" ) +// Defines values for ListGraphQLAPIsParamsSortBy. +const ( + ListGraphQLAPIsParamsSortByCreatedAt ListGraphQLAPIsParamsSortBy = "createdAt" + ListGraphQLAPIsParamsSortByName ListGraphQLAPIsParamsSortBy = "name" +) + +// Defines values for ListGraphQLAPIsParamsSortOrder. +const ( + ListGraphQLAPIsParamsSortOrderAsc ListGraphQLAPIsParamsSortOrder = "asc" + ListGraphQLAPIsParamsSortOrderDesc ListGraphQLAPIsParamsSortOrder = "desc" +) + +// Defines values for GetGraphQLAPIDeploymentsParamsStatus. +const ( + GetGraphQLAPIDeploymentsParamsStatusARCHIVED GetGraphQLAPIDeploymentsParamsStatus = "ARCHIVED" + GetGraphQLAPIDeploymentsParamsStatusDEPLOYED GetGraphQLAPIDeploymentsParamsStatus = "DEPLOYED" + GetGraphQLAPIDeploymentsParamsStatusDEPLOYING GetGraphQLAPIDeploymentsParamsStatus = "DEPLOYING" + GetGraphQLAPIDeploymentsParamsStatusFAILED GetGraphQLAPIDeploymentsParamsStatus = "FAILED" + GetGraphQLAPIDeploymentsParamsStatusUNDEPLOYED GetGraphQLAPIDeploymentsParamsStatus = "UNDEPLOYED" + GetGraphQLAPIDeploymentsParamsStatusUNDEPLOYING GetGraphQLAPIDeploymentsParamsStatus = "UNDEPLOYING" +) + // Defines values for GetLLMProviderDeploymentsParamsStatus. const ( GetLLMProviderDeploymentsParamsStatusARCHIVED GetLLMProviderDeploymentsParamsStatus = "ARCHIVED" @@ -495,24 +523,24 @@ const ( // Defines values for ListRESTAPIsParamsSortBy. const ( - CreatedAt ListRESTAPIsParamsSortBy = "createdAt" - Name ListRESTAPIsParamsSortBy = "name" + ListRESTAPIsParamsSortByCreatedAt ListRESTAPIsParamsSortBy = "createdAt" + ListRESTAPIsParamsSortByName ListRESTAPIsParamsSortBy = "name" ) // Defines values for ListRESTAPIsParamsSortOrder. const ( - Asc ListRESTAPIsParamsSortOrder = "asc" - Desc ListRESTAPIsParamsSortOrder = "desc" + ListRESTAPIsParamsSortOrderAsc ListRESTAPIsParamsSortOrder = "asc" + ListRESTAPIsParamsSortOrderDesc ListRESTAPIsParamsSortOrder = "desc" ) // Defines values for GetDeploymentsParamsStatus. const ( - GetDeploymentsParamsStatusARCHIVED GetDeploymentsParamsStatus = "ARCHIVED" - GetDeploymentsParamsStatusDEPLOYED GetDeploymentsParamsStatus = "DEPLOYED" - GetDeploymentsParamsStatusDEPLOYING GetDeploymentsParamsStatus = "DEPLOYING" - GetDeploymentsParamsStatusFAILED GetDeploymentsParamsStatus = "FAILED" - GetDeploymentsParamsStatusUNDEPLOYED GetDeploymentsParamsStatus = "UNDEPLOYED" - GetDeploymentsParamsStatusUNDEPLOYING GetDeploymentsParamsStatus = "UNDEPLOYING" + ARCHIVED GetDeploymentsParamsStatus = "ARCHIVED" + DEPLOYED GetDeploymentsParamsStatus = "DEPLOYED" + DEPLOYING GetDeploymentsParamsStatus = "DEPLOYING" + FAILED GetDeploymentsParamsStatus = "FAILED" + UNDEPLOYED GetDeploymentsParamsStatus = "UNDEPLOYED" + UNDEPLOYING GetDeploymentsParamsStatus = "UNDEPLOYING" ) // Defines values for ListSubscriptionsParamsStatus. @@ -943,6 +971,76 @@ type CreateGatewayRequest struct { // CreateGatewayRequestFunctionalityType Type of gateway functionality type CreateGatewayRequestFunctionalityType string +// CreateGraphQLAPIRequest defines model for CreateGraphQLAPIRequest. +type CreateGraphQLAPIRequest 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 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"` + + // LifeCycleStatus Current lifecycle status of the API. Reuses REST APIs' lifecycle enum + // unmodified (STAGED, CREATED, PUBLISHED, DEPRECATED, RETIRED, BLOCKED) — + // declaring a second identically-valued enum schema here would collide + // with it at Go-constant generation time. + LifeCycleStatus *RESTAPILifeCycleStatus `json:"lifeCycleStatus,omitempty" yaml:"lifeCycleStatus,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. @@ -1455,6 +1553,122 @@ type GatewayTokenListResponse struct { Pagination Pagination `json:"pagination" yaml:"pagination"` } +// GraphQLAPI defines model for GraphQLAPI. +type GraphQLAPI 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 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"` + + // LifeCycleStatus Current lifecycle status of the API. Reuses REST APIs' lifecycle enum + // unmodified (STAGED, CREATED, PUBLISHED, DEPRECATED, RETIRED, BLOCKED) — + // declaring a second identically-valued enum schema here would collide + // with it at Go-constant generation time. + LifeCycleStatus *RESTAPILifeCycleStatus `json:"lifeCycleStatus,omitempty" yaml:"lifeCycleStatus,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"` +} + +// 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"` + + // LifeCycleStatus Current lifecycle status of the API. Reuses REST APIs' lifecycle enum + // unmodified (STAGED, CREATED, PUBLISHED, DEPRECATED, RETIRED, BLOCKED) — + // declaring a second identically-valued enum schema here would collide + // with it at Go-constant generation time. + LifeCycleStatus *RESTAPILifeCycleStatus `json:"lifeCycleStatus,omitempty" yaml:"lifeCycleStatus,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. Any `sdl`/`sdlUrl` included here is + // ignored; the uploaded `sdlFile` part is always the source of `sdl`. + 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"` +} + +// GraphQLIntrospectionMode defines model for GraphQLIntrospectionMode. +type GraphQLIntrospectionMode string + // ImportOpenAPIRequest defines model for ImportOpenAPIRequest. type ImportOpenAPIRequest struct { Context string `binding:"required" json:"context" yaml:"context"` @@ -3425,6 +3639,75 @@ type ListGatewayTokensParams struct { Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } +// ListGraphQLAPIsParams defines parameters for ListGraphQLAPIs. +type ListGraphQLAPIsParams struct { + // ProjectId **Project ID** consisting of the **handle** (unique slug identifier) of the Project whose resources should be returned. + ProjectId ProjectIdQ `form:"projectId" json:"projectId" yaml:"projectId"` + + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + + // SortBy Field to sort the collection by. An unrecognized value falls back to the default sort (createdAt). + SortBy *ListGraphQLAPIsParamsSortBy `form:"sortBy,omitempty" json:"sortBy,omitempty" yaml:"sortBy,omitempty"` + + // SortOrder Sort direction applied to `sortBy`. + SortOrder *ListGraphQLAPIsParamsSortOrder `form:"sortOrder,omitempty" json:"sortOrder,omitempty" yaml:"sortOrder,omitempty"` + + // Query Case-insensitive substring filter matched against the resource display name and id (handle). + Query *QueryQ `form:"query,omitempty" json:"query,omitempty" yaml:"query,omitempty"` +} + +// ListGraphQLAPIsParamsSortBy defines parameters for ListGraphQLAPIs. +type ListGraphQLAPIsParamsSortBy string + +// ListGraphQLAPIsParamsSortOrder defines parameters for ListGraphQLAPIs. +type ListGraphQLAPIsParamsSortOrder string + +// GetGraphQLAPIDeploymentsParams defines parameters for GetGraphQLAPIDeployments. +type GetGraphQLAPIDeploymentsParams struct { + // GatewayId **Gateway ID** consisting of the **handle** (unique slug identifier) of the Gateway to filter status by. + GatewayId *GatewayIdQ `form:"gatewayId,omitempty" json:"gatewayId,omitempty" yaml:"gatewayId,omitempty"` + + // Status Filter deployments by status (DEPLOYED, UNDEPLOYED, DEPLOYING, UNDEPLOYING, FAILED, or ARCHIVED) + Status *GetGraphQLAPIDeploymentsParamsStatus `form:"status,omitempty" json:"status,omitempty" yaml:"status,omitempty"` + + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` +} + +// GetGraphQLAPIDeploymentsParamsStatus defines parameters for GetGraphQLAPIDeployments. +type GetGraphQLAPIDeploymentsParamsStatus string + +// RestoreGraphQLAPIDeploymentParams defines parameters for RestoreGraphQLAPIDeployment. +type RestoreGraphQLAPIDeploymentParams struct { + // GatewayId Handle (URL-friendly slug) of the gateway (validated against deployment's bound gateway) + GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` +} + +// UndeployGraphQLAPIDeploymentParams defines parameters for UndeployGraphQLAPIDeployment. +type UndeployGraphQLAPIDeploymentParams struct { + // GatewayId Handle (URL-friendly slug) of the gateway (validated against deployment's bound gateway) + GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` +} + +// GetGraphQLAPIGatewaysParams defines parameters for GetGraphQLAPIGateways. +type GetGraphQLAPIGatewaysParams struct { + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` +} + +// AddGatewaysToGraphQLAPIJSONBody defines parameters for AddGatewaysToGraphQLAPI. +type AddGatewaysToGraphQLAPIJSONBody = []AddGatewayToRESTAPIRequest + // ListLLMProviderTemplatesParams defines parameters for ListLLMProviderTemplates. type ListLLMProviderTemplatesParams struct { // Query URL-encoded search DSL. `query=latest:true` lists only the latest version of each family; `query=groupId:` lists that family's versions; adding `&version:` returns the single full template for that version. Terms are `&`-separated `key:value` pairs and the whole value is percent-encoded (e.g. groupId%3Awso2-openai%26version%3Av2.0). @@ -3837,6 +4120,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 diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 88d3f725b6..d1a62970d0 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -1379,8 +1379,6 @@ paths: '500': $ref: '#/components/responses/InternalServerError' - # --- API Publication: rollup across portals --------------------------------- - /api-publications: get: summary: List API publications across API Portals @@ -1870,8 +1868,766 @@ paths: tags: - API Publications responses: - '204': - description: Listing removed + '204': + description: Listing removed + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/PublicationConflict' + '500': + $ref: '#/components/responses/InternalServerError' + '503': + $ref: '#/components/responses/PortalUnavailable' + + /api-portals/{apiPortalId}/apis/rest-api/{apiId}/deprecate: + parameters: + - $ref: '#/components/parameters/apiPortalId' + - $ref: '#/components/parameters/apiHandle' + post: + summary: Deprecate REST API on API Portal + description: Marks the live listing deprecated on the API Portal, where it stays visible and is flagged as deprecated. Nothing is deleted and any draft is untouched. Valid only when currently published. + operationId: deprecateRestApiOnApiPortal + security: + - OAuth2Security: + - ap:api_portal:rest_api:deprecate + - ap:api_portal:rest_api:manage + tags: + - API Publications + responses: + '200': + description: Listing deprecated + content: + application/json: + schema: + $ref: '#/components/schemas/Publication' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/PublicationConflict' + '500': + $ref: '#/components/responses/InternalServerError' + '503': + $ref: '#/components/responses/PortalUnavailable' + + /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 + 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/GraphQLAPI' + '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}/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': @@ -1879,33 +2635,65 @@ paths: '404': $ref: '#/components/responses/NotFound' '409': - $ref: '#/components/responses/PublicationConflict' + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' - '503': - $ref: '#/components/responses/PortalUnavailable' - /api-portals/{apiPortalId}/apis/rest-api/{apiId}/deprecate: - parameters: - - $ref: '#/components/parameters/apiPortalId' - - $ref: '#/components/parameters/apiHandle' + /graphql-apis/{graphqlApiId}/deployments/{deploymentId}/restore: post: - summary: Deprecate REST API on API Portal - description: Marks the live listing deprecated on the API Portal, where it stays visible and is flagged as deprecated. Nothing is deleted and any draft is untouched. Valid only when currently published. - operationId: deprecateRestApiOnApiPortal + 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:api_portal:rest_api:deprecate - - ap:api_portal:rest_api:manage + - ap:graphql_api:deployment:restore + - ap:graphql_api:deployment:manage + - ap:graphql_api:manage tags: - - API Publications + - 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: Listing deprecated + description: Restore initiated successfully. Returns the deployment with initial status DEPLOYING. Poll status for final result. content: application/json: schema: - $ref: '#/components/schemas/Publication' + $ref: '#/components/schemas/DeploymentResponse' + examples: + default: + $ref: '#/components/examples/DeploymentDeploying' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -1913,11 +2701,9 @@ paths: '404': $ref: '#/components/responses/NotFound' '409': - $ref: '#/components/responses/PublicationConflict' + $ref: '#/components/responses/Conflict' '500': $ref: '#/components/responses/InternalServerError' - '503': - $ref: '#/components/responses/PortalUnavailable' /llm-provider-templates: post: @@ -6446,6 +7232,24 @@ components: ap:gateway_custom_policy:delete: Delete a gateway custom policy ap:gateway_custom_policy:manage: Full access to gateway custom policies ap:gateway_custom_policy:read: Read gateway custom policies + ap:graphql_api:api_key:create: Create an API key for a GraphQL API + ap:graphql_api:api_key:delete: Delete an API key of a GraphQL API + ap:graphql_api:api_key:manage: Full access to a GraphQL API's API keys + ap:graphql_api:api_key:update: Update an API key of a GraphQL API + ap:graphql_api:create: Create a GraphQL API + ap:graphql_api:delete: Delete a GraphQL API + ap:graphql_api:deployment:create: Deploy a GraphQL API + ap:graphql_api:deployment:delete: Delete a GraphQL API deployment + ap:graphql_api:deployment:manage: Full access to GraphQL API deployments + ap:graphql_api:deployment:read: Read GraphQL API deployments + ap:graphql_api:deployment:restore: Restore a GraphQL API deployment + ap:graphql_api:deployment:undeploy: Undeploy a GraphQL API deployment + ap:graphql_api:gateway:create: Add gateways to a GraphQL API + ap:graphql_api:gateway:manage: Full access to a GraphQL API's gateways + ap:graphql_api:gateway:read: Read a GraphQL API's gateways + ap:graphql_api:manage: Full access to GraphQL APIs + ap:graphql_api:read: Read GraphQL APIs + ap:graphql_api:update: Update a GraphQL API ap:llm_provider:api_key:create: Create an LLM provider API key ap:llm_provider:api_key:delete: Delete an LLM provider API key ap:llm_provider:api_key:manage: Full access to LLM provider API keys @@ -7993,6 +8797,279 @@ components: type: string description: Raw spec content + GraphQLIntrospectionMode: + type: string + enum: [SDL, ENDPOINT] + example: ENDPOINT + + GraphQLAPI: + title: GraphQL API object + required: + - displayName + - context + - version + - projectId + - upstream + type: object + properties: + id: + type: string + description: Unique handle/identifier for the API. Can be provided during creation or auto-generated. On update (PUT), if provided must match the path parameter — returns 400 if they differ. + minLength: 3 + maxLength: 40 + example: countries-graphql-api + displayName: + description: Human-readable name for the API + pattern: '(^[^~!@#;:%^*()+={}|\\<>"'',&$\[\]\/]*$)' + type: string + minLength: 1 + maxLength: 128 + example: Countries GraphQL API + description: + maxLength: 32766 + type: string + example: Public GraphQL API for querying country/region reference data + context: + maxLength: 232 + minLength: 1 + type: string + example: /countries + 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). + lifeCycleStatus: + type: string + x-go-type: RESTAPILifeCycleStatus + description: | + Current lifecycle status of the API. Reuses REST APIs' lifecycle enum + unmodified (STAGED, CREATED, PUBLISHED, DEPRECATED, RETIRED, BLOCKED) — + declaring a second identically-valued enum schema here would collide + with it at Go-constant generation time. + example: "CREATED" + 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] + + 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. Any `sdl`/`sdlUrl` included here is + ignored; the uploaded `sdlFile` part is always the source of `sdl`. + 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 + version: + type: string + example: v1.0 + projectId: + type: string + example: default-project + upstream: + $ref: "#/components/schemas/Upstream" + introspectionMode: + $ref: '#/components/schemas/GraphQLIntrospectionMode' + lifeCycleStatus: + type: string + x-go-type: RESTAPILifeCycleStatus + description: | + Current lifecycle status of the API. Reuses REST APIs' lifecycle enum + unmodified (STAGED, CREATED, PUBLISHED, DEPRECATED, RETIRED, BLOCKED) — + declaring a second identically-valued enum schema here would collide + with it at Go-constant generation time. + example: "CREATED" + 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 + # sdl is deliberately omitted from list responses — unlike a REST API's + # operations[] (a handful of entries), a GraphQL schema's SDL can run to + # many KB; embedding it in every list-page row would bloat GET + # /graphql-apis for no listing-view benefit. Fetch GET + # /graphql-apis/{graphqlApiId} for the full schema. + + 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 @@ -11497,6 +12574,14 @@ tags: description: Publishing, unpublishing and deprecating an API on an API Portal, and the per-portal draft and live listing that feed those actions - name: API Portals description: API Portal registration and management + - name: GraphQL APIs + description: GraphQL API management operations + - name: GraphQL API Deployments + description: GraphQL API deployment artifact management and lifecycle operations + - name: API Portal + description: API portal publishing and unpublishing operations + - name: DevPortals + description: DevPortal management operations - name: Gateways description: Gateway registration and management operations - name: Gateway Tokens @@ -11514,7 +12599,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 From e7e213dfd4145ba7b6dc012be52eb40de92a35b4 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Tue, 25 Aug 2026 13:36:39 +0530 Subject: [PATCH 03/38] Add gateway-first GraphQL API support to gateway-controller --- gateway/examples/blog-graphql-api.yaml | 44 + gateway/examples/countries-graphql-api.yaml | 36 + .../gateway-controller/cmd/controller/main.go | 9 +- .../cmd/controller/main_test.go | 12 + .../pkg/api/handlers/graphql_api_handler.go | 351 +++++++ .../pkg/api/handlers/resource_response.go | 10 + .../pkg/models/data_version.go | 1 + .../pkg/models/data_version_test.go | 1 + .../pkg/models/stored_config.go | 16 +- .../pkg/storage/sql_store.go | 14 +- .../pkg/storage/sqlite_test.go | 1 + .../pkg/transform/graphql.go | 204 +++++ .../pkg/transform/graphql_test.go | 174 ++++ .../pkg/transform/registry.go | 14 +- .../pkg/transform/restapi.go | 5 +- .../pkg/utils/graphql_deployment.go | 150 +++ gateway/it/features/graphql_deploy.feature | 862 ++++++++++++++++++ gateway/it/steps_graphql.go | 87 ++ gateway/it/suite_test.go | 1 + 19 files changed, 1983 insertions(+), 9 deletions(-) create mode 100644 gateway/examples/blog-graphql-api.yaml create mode 100644 gateway/examples/countries-graphql-api.yaml create mode 100644 gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go create mode 100644 gateway/gateway-controller/pkg/transform/graphql.go create mode 100644 gateway/gateway-controller/pkg/transform/graphql_test.go create mode 100644 gateway/gateway-controller/pkg/utils/graphql_deployment.go create mode 100644 gateway/it/features/graphql_deploy.feature create mode 100644 gateway/it/steps_graphql.go diff --git a/gateway/examples/blog-graphql-api.yaml b/gateway/examples/blog-graphql-api.yaml new file mode 100644 index 0000000000..f5b3240cbd --- /dev/null +++ b/gateway/examples/blog-graphql-api.yaml @@ -0,0 +1,44 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# 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 +# -------------------------------------------------------------------- + +# 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 + 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..ed5128ba83 --- /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 + 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/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index 1895860061..f31da6f965 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -393,7 +393,8 @@ func main() { restTransformer := transform.NewRestAPITransformer(&cfg.Router, cfg, policyDefinitions) llmTransformer := transform.NewLLMTransformer(configStore, db, &cfg.Router, cfg, policyDefinitions, policyVersionResolver) agentTransformer := transform.NewAgentTransformer(&cfg.Router, cfg, policyDefinitions) - transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer, agentTransformer) + graphqlTransformer := transform.NewGraphQLAPITransformer(&cfg.Router, cfg, policyDefinitions) + transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer, agentTransformer, graphqlTransformer) // Wire the transformer into the Envoy xDS translator so Envoy routes are built from the // RuntimeDeployConfig (RDC) path — identical to how the policy engine's RouteConfig/PolicyChain @@ -983,6 +984,12 @@ func generateAuthConfig(config *config.Config) (commonmodels.AuthConfig, error) "PUT /agents/{id}": {"admin", "developer"}, "DELETE /agents/{id}": {"admin", "developer"}, + "POST /graphql-apis": {"admin", "developer"}, + "GET /graphql-apis": {"admin", "developer"}, + "GET /graphql-apis/{id}": {"admin", "developer"}, + "PUT /graphql-apis/{id}": {"admin", "developer"}, + "DELETE /graphql-apis/{id}": {"admin", "developer"}, + "POST /llm-provider-templates": {"admin"}, "GET /llm-provider-templates": {"admin"}, "GET /llm-provider-templates/{id}": {"admin"}, diff --git a/gateway/gateway-controller/cmd/controller/main_test.go b/gateway/gateway-controller/cmd/controller/main_test.go index 6c56f82e65..2bfec376a6 100644 --- a/gateway/gateway-controller/cmd/controller/main_test.go +++ b/gateway/gateway-controller/cmd/controller/main_test.go @@ -737,6 +737,18 @@ 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") 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..39fe37ab7b --- /dev/null +++ b/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go @@ -0,0 +1,351 @@ +/* + * 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" + "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/go-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.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) + 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 { + 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 + } + + 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 { + 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 + } + + // 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.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) + 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 { + 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 + } + + 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)) + } +} + +// 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/resource_response.go b/gateway/gateway-controller/pkg/api/handlers/resource_response.go index a41c7b7d73..e3171d8d72 100644 --- a/gateway/gateway-controller/pkg/api/handlers/resource_response.go +++ b/gateway/gateway-controller/pkg/api/handlers/resource_response.go @@ -68,6 +68,16 @@ func buildResourceResponse(cfg any, status api.ResourceStatus) any { cp := *v cp.Status = &status return cp + case api.GraphQLAPI: + v.Status = &status + return v + case *api.GraphQLAPI: + if v == nil { + return nil + } + cp := *v + cp.Status = &status + return cp case api.MCPProxyConfiguration: v.Status = &status return v diff --git a/gateway/gateway-controller/pkg/models/data_version.go b/gateway/gateway-controller/pkg/models/data_version.go index 8f37ee1512..a67dcb09c4 100644 --- a/gateway/gateway-controller/pkg/models/data_version.go +++ b/gateway/gateway-controller/pkg/models/data_version.go @@ -42,6 +42,7 @@ var dataMinorVersions = map[ArtifactKind]int{ KindWebSubApi: 0, KindWebBrokerApi: 0, KindMcp: 0, + KindGraphQLApi: 0, KindLlmProxy: 0, KindLlmProvider: 0, KindAgent: 0, diff --git a/gateway/gateway-controller/pkg/models/data_version_test.go b/gateway/gateway-controller/pkg/models/data_version_test.go index fd0ca58d6a..2113b2e1a7 100644 --- a/gateway/gateway-controller/pkg/models/data_version_test.go +++ b/gateway/gateway-controller/pkg/models/data_version_test.go @@ -69,6 +69,7 @@ func TestDataMinorVersionsExhaustive(t *testing.T) { KindWebSubApi, KindWebBrokerApi, KindMcp, + KindGraphQLApi, KindLlmProxy, KindLlmProvider, KindAgent, diff --git a/gateway/gateway-controller/pkg/models/stored_config.go b/gateway/gateway-controller/pkg/models/stored_config.go index bf1ffb688b..71eda28b3b 100644 --- a/gateway/gateway-controller/pkg/models/stored_config.go +++ b/gateway/gateway-controller/pkg/models/stored_config.go @@ -40,6 +40,7 @@ const ( KindLlmProvider ArtifactKind = "LlmProvider" KindLlmProviderTemplate ArtifactKind = "LlmProviderTemplate" KindAgent ArtifactKind = "Agent" + KindGraphQLApi ArtifactKind = "GraphQLApi" ) // DesiredState represents the intended deployment state of an API configuration. @@ -194,6 +195,8 @@ func apiVersionOf(cfg any) string { return string(sc.ApiVersion) case api.AgentConfiguration: return string(sc.ApiVersion) + case api.GraphQLAPI: + return string(sc.ApiVersion) } return "" } @@ -223,12 +226,17 @@ func (c *StoredConfig) GetContext() (string, error) { return strings.ReplaceAll(*sc.Spec.Context, "$version", c.Version), nil } return "", nil + case api.GraphQLAPI: + return strings.ReplaceAll(sc.Spec.Context, "$version", c.Version), nil } return "", fmt.Errorf("unsupported source configuration type: %T", c.SourceConfiguration) } func (c *StoredConfig) GetPolicies() *[]api.Policy { - if sc, ok := c.Configuration.(api.RestAPI); ok { + switch sc := c.Configuration.(type) { + case api.RestAPI: + return sc.Spec.Policies + case api.GraphQLAPI: return sc.Spec.Policies } // Agent is deliberately absent: an Agent has no single spec-level policy @@ -247,6 +255,8 @@ func (c *StoredConfig) GetMetadata() *api.Metadata { return &cfg.Metadata case api.AgentConfiguration: return &cfg.Metadata + case api.GraphQLAPI: + return &cfg.Metadata } return nil } @@ -258,6 +268,8 @@ func (c *StoredConfig) GetLabels() *map[string]string { return cfg.Metadata.Labels case api.AgentConfiguration: return cfg.Metadata.Labels + case api.GraphQLAPI: + return cfg.Metadata.Labels } return nil } @@ -269,6 +281,8 @@ func (c *StoredConfig) GetAnnotations() *map[string]string { return cfg.Metadata.Annotations case api.AgentConfiguration: return cfg.Metadata.Annotations + case api.GraphQLAPI: + return cfg.Metadata.Annotations } return nil } diff --git a/gateway/gateway-controller/pkg/storage/sql_store.go b/gateway/gateway-controller/pkg/storage/sql_store.go index 19c2595116..5b6f636fb1 100644 --- a/gateway/gateway-controller/pkg/storage/sql_store.go +++ b/gateway/gateway-controller/pkg/storage/sql_store.go @@ -289,6 +289,8 @@ func kindToResourceTable(kind string) (string, error) { return "mcp_proxies", nil case "Agent": return agentsResourceTable, nil + case "GraphQLApi": + return "graphql_apis", nil default: if table, ok := extraResourceTables[kind]; ok { return table, nil @@ -308,7 +310,7 @@ var extraResourceTables = map[string]string{} // builtinResourceTables lists the per-kind tables core defines natively. // GetAllConfigs unions these with every table in extraResourceTables so // cross-kind listing also covers kinds registered by an external module. -var builtinResourceTables = []string{"rest_apis", "llm_providers", "llm_proxies", "mcp_proxies", agentsResourceTable} +var builtinResourceTables = []string{"rest_apis", "llm_providers", "llm_proxies", "mcp_proxies", agentsResourceTable, "graphql_apis"} // agentsResourceTable holds Agent artifacts. Like llm_proxies, it carries // columns beyond (uuid, gateway_id, configuration) — here the signed Agent Card @@ -391,6 +393,16 @@ func unmarshalSourceConfig(cfg *models.StoredConfig, jsonData string) error { } cfg.SourceConfiguration = config cfg.Configuration = config + case "GraphQLApi": + // GraphQLApi rows can populate Configuration directly, same as RestApi: the + // stored payload is already the deployable shape (see graphql.go's Transform, + // which type-asserts cfg.Configuration.(api.GraphQLAPI) directly). + var config api.GraphQLAPI + if err := json.Unmarshal([]byte(jsonData), &config); err != nil { + return fmt.Errorf("failed to unmarshal configuration: %w", err) + } + cfg.SourceConfiguration = config + cfg.Configuration = config default: if fn, ok := kindUnmarshalers[cfg.Kind]; ok { return fn(cfg, jsonData) diff --git a/gateway/gateway-controller/pkg/storage/sqlite_test.go b/gateway/gateway-controller/pkg/storage/sqlite_test.go index 2073dfa5d3..77513d288d 100644 --- a/gateway/gateway-controller/pkg/storage/sqlite_test.go +++ b/gateway/gateway-controller/pkg/storage/sqlite_test.go @@ -87,6 +87,7 @@ func TestSQLiteStorage_SchemaInitialization(t *testing.T) { "llm_providers", "llm_proxies", "mcp_proxies", + "graphql_apis", "certificates", "llm_provider_templates", "agents", diff --git a/gateway/gateway-controller/pkg/transform/graphql.go b/gateway/gateway-controller/pkg/transform/graphql.go new file mode 100644 index 0000000000..f8bd6df692 --- /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 := addUpstreamCluster(rdc, "main", &apiData.Upstream.Main, nil) + if err != nil { + return nil, fmt.Errorf("failed to resolve main upstream: %w", err) + } + mainUpstreamInfo := mainUpstream.UpstreamInfo() + + mainAutoHostRewrite := true + if apiData.Upstream.Main.HostRewrite != nil && *apiData.Upstream.Main.HostRewrite == api.UpstreamHostRewriteManual { + mainAutoHostRewrite = false + } + + mainRouteKey := xds.GenerateRouteName("POST", apiData.Context, apiData.Version, "", mainVhost) + rdc.Routes[mainRouteKey] = &models.Route{ + Method: "POST", + Path: fullPath, + 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 := addUpstreamCluster(rdc, "sandbox", apiData.Upstream.Sandbox, nil) + if err != nil { + return nil, fmt.Errorf("failed to resolve sandbox upstream: %w", err) + } + sbUpstreamInfo := sbUpstream.UpstreamInfo() + + sbAutoHostRewrite := true + if apiData.Upstream.Sandbox.HostRewrite != nil && *apiData.Upstream.Sandbox.HostRewrite == api.UpstreamHostRewriteManual { + sbAutoHostRewrite = false + } + + sandboxRouteKey := xds.GenerateRouteName("POST", apiData.Context, apiData.Version, "", sandboxVhost) + rdc.Routes[sandboxRouteKey] = &models.Route{ + Method: "POST", + Path: fullPath, + 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 +// addUpstreamCluster, which is a large self-contained block with no transformer +// state — depends on t.policyDefinitions/t.latestVersions, so sharing it would mean +// plumbing those through a standalone helper for a single call site on each side). +func (t *GraphQLAPITransformer) collectAPIPolicies(policies *[]api.Policy) []policyenginev1.PolicyInstance { + var result []policyenginev1.PolicyInstance + if policies == nil { + return result + } + for _, p := range *policies { + resolved, err := config.ResolvePolicyVersion(t.policyDefinitions, t.latestVersions, p.Name, p.Version) + if err != nil { + slog.Error("Failed to resolve policy version for GraphQL API-level policy", "policy_name", p.Name, "error", err) + continue + } + result = append(result, convertAPIPolicyToSDK(p, policyv1alpha.LevelAPI, versionutil.MajorVersion(resolved))) + } + return result +} + +// buildPolicyChain returns the API-level policy chain. A GraphQLApi has no +// operation-level policies to merge in (there are no operations), unlike +// RestAPITransformer.buildPolicyChain. +func (t *GraphQLAPITransformer) buildPolicyChain(apiPolicies []policyenginev1.PolicyInstance) []policyenginev1.PolicyInstance { + result := make([]policyenginev1.PolicyInstance, 0, len(apiPolicies)) + result = append(result, apiPolicies...) + return result +} diff --git a/gateway/gateway-controller/pkg/transform/graphql_test.go b/gateway/gateway-controller/pkg/transform/graphql_test.go new file mode 100644 index 0000000000..9ba66922d4 --- /dev/null +++ b/gateway/gateway-controller/pkg/transform/graphql_test.go @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package transform + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/xds" +) + +// graphqlUpstream builds the anonymous upstream struct api.GraphQLAPIConfigData embeds. +func graphqlUpstream(mainURL string, sandboxURL *string) struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` +} { + u := struct { + Main api.Upstream `json:"main" yaml:"main"` + Sandbox *api.Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: api.Upstream{Url: ptrStr(mainURL)}, + } + if sandboxURL != nil { + u.Sandbox = &api.Upstream{Url: sandboxURL} + } + return u +} + +// makeGraphQLAPIStoredConfig builds a minimal GraphQLApi StoredConfig for transformer +// tests. GraphQLAPIConfigData carries no schema field at all — the artifact never +// describes its own schema, so transformer behavior can only ever depend on +// context/upstream/policies, never on anything schema-shaped. +func makeGraphQLAPIStoredConfig(sandboxURL *string, policies []api.Policy) *models.StoredConfig { + var specPolicies *[]api.Policy + if policies != nil { + specPolicies = &policies + } + + spec := api.GraphQLAPIConfigData{ + DisplayName: "Countries GraphQL API", + Context: "/countries/$version", + Version: "v1.0", + Upstream: graphqlUpstream("http://backend:8080/graphql", sandboxURL), + Policies: specPolicies, + } + + graphqlAPI := api.GraphQLAPI{ + Kind: api.GraphQLAPIKindGraphQLApi, + Metadata: api.Metadata{Name: "countries-graphql-api"}, + Spec: spec, + } + + return &models.StoredConfig{ + UUID: "countries-graphql-api", + Kind: "GraphQLApi", + Configuration: graphqlAPI, + } +} + +func TestGraphQLAPITransformer_SingleRoute(t *testing.T) { + transformer := NewGraphQLAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + + cfg := makeGraphQLAPIStoredConfig(nil, nil) + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + assert.Len(t, rdc.Routes, 1) +} + +func TestGraphQLAPITransformer_RouteShape(t *testing.T) { + transformer := NewGraphQLAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeGraphQLAPIStoredConfig(nil, nil) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + routeKey := xds.GenerateRouteName("POST", "/countries/$version", "v1.0", "", "main.local") + route, ok := rdc.Routes[routeKey] + require.True(t, ok, "expected route keyed %q, got keys %v", routeKey, keysOf(rdc.Routes)) + + assert.Equal(t, "POST", route.Method) + assert.Equal(t, "/countries/v1.0", route.Path) + assert.Equal(t, "Exact", route.PathMatchType) + assert.Equal(t, "main.local", route.Vhost) + assert.NotEmpty(t, route.Upstream.ClusterKey) + require.NotNil(t, route.Upstream.Default) + assert.Equal(t, "http://backend:8080", route.Upstream.Default.URL) +} + +func TestGraphQLAPITransformer_PolicyChainResolver(t *testing.T) { + transformer := NewGraphQLAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeGraphQLAPIStoredConfig(nil, nil) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + + assert.Equal(t, "route-key", rdc.PolicyChainResolver) +} + +func TestGraphQLAPITransformer_SandboxProducesSecondRoute(t *testing.T) { + transformer := NewGraphQLAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeGraphQLAPIStoredConfig(ptrStr("http://sandbox-backend:8080/graphql"), nil) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + assert.Len(t, rdc.Routes, 2) + + mainRouteKey := xds.GenerateRouteName("POST", "/countries/$version", "v1.0", "", "main.local") + sandboxRouteKey := xds.GenerateRouteName("POST", "/countries/$version", "v1.0", "", "sandbox.local") + + mainRoute, ok := rdc.Routes[mainRouteKey] + require.True(t, ok) + sandboxRoute, ok := rdc.Routes[sandboxRouteKey] + require.True(t, ok) + + assert.NotEqual(t, mainRoute.Upstream.ClusterKey, sandboxRoute.Upstream.ClusterKey) + require.NotNil(t, sandboxRoute.Upstream.Default) + assert.Equal(t, "http://sandbox-backend:8080", sandboxRoute.Upstream.Default.URL) + + // Both routes get the same (API-level) policy chain. + require.Contains(t, rdc.PolicyChains, mainRouteKey) + require.Contains(t, rdc.PolicyChains, sandboxRouteKey) +} + +func TestGraphQLAPITransformer_NoOperationsLoop(t *testing.T) { + // A GraphQLAPIConfigData has no Operations field at all (unlike api.APIConfigData) — + // this test documents that expectation by confirming route count tracks upstream + // slots (1 or 2), never anything resembling an operation count. + transformer := NewGraphQLAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := makeGraphQLAPIStoredConfig(nil, nil) + + rdc, err := transformer.Transform(cfg) + require.NoError(t, err) + assert.Len(t, rdc.Routes, 1) +} + +func TestGraphQLAPITransformer_WrongConfigurationType(t *testing.T) { + transformer := NewGraphQLAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}) + cfg := &models.StoredConfig{ + UUID: "bad-config", + Kind: "GraphQLApi", + Configuration: api.RestAPI{}, // wrong type on purpose + } + + _, err := transformer.Transform(cfg) + assert.Error(t, err) +} + +func keysOf(m map[string]*models.Route) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/gateway/gateway-controller/pkg/transform/registry.go b/gateway/gateway-controller/pkg/transform/registry.go index 29b7763fb7..4158039b5d 100644 --- a/gateway/gateway-controller/pkg/transform/registry.go +++ b/gateway/gateway-controller/pkg/transform/registry.go @@ -41,6 +41,7 @@ var registryKinds = []string{ models.KindLlmProvider, models.KindLlmProxy, models.KindAgent, + models.KindGraphQLApi, } // envoyTranslatorExcludedKinds are the kinds that must NOT be wired into the @@ -93,6 +94,8 @@ func (r *Registry) Transform(cfg *models.StoredConfig) (*models.RuntimeDeployCon return nil, fmt.Errorf("%w: %s", ErrUnsupportedKind, cfg.Kind) } return r.agentT.Transform(cfg) + case models.KindGraphQLApi: + return r.graphqlT.Transform(cfg) default: return nil, fmt.Errorf("%w: %s", ErrUnsupportedKind, cfg.Kind) } @@ -100,12 +103,13 @@ func (r *Registry) Transform(cfg *models.StoredConfig) (*models.RuntimeDeployCon // Registry dispatches StoredConfig → RuntimeDeployConfig by API kind. type Registry struct { - restT *RestAPITransformer - llmT *LLMTransformer - agentT *AgentTransformer + restT *RestAPITransformer + llmT *LLMTransformer + agentT *AgentTransformer + graphqlT *GraphQLAPITransformer } // NewRegistry creates a new transformer Registry. -func NewRegistry(restT *RestAPITransformer, llmT *LLMTransformer, agentT *AgentTransformer) *Registry { - return &Registry{restT: restT, llmT: llmT, agentT: agentT} +func NewRegistry(restT *RestAPITransformer, llmT *LLMTransformer, agentT *AgentTransformer, graphqlT *GraphQLAPITransformer) *Registry { + return &Registry{restT: restT, llmT: llmT, agentT: agentT, graphqlT: graphqlT} } diff --git a/gateway/gateway-controller/pkg/transform/restapi.go b/gateway/gateway-controller/pkg/transform/restapi.go index 3457b6625b..ad43d2bfaf 100644 --- a/gateway/gateway-controller/pkg/transform/restapi.go +++ b/gateway/gateway-controller/pkg/transform/restapi.go @@ -501,7 +501,10 @@ func (r *upstreamClusterResult) UpstreamInfo() policyenginev1.UpstreamInfo { // transformer calls it directly so that an Agent's cluster key, base path, Envoy // cluster name and TLS flag are derived by the identical code path as a REST API's // — those four values are what the route rewrite and the policy engine's -// default-upstream both key off. +// default-upstream both key off. GraphQLAPITransformer calls it directly for the +// same reason, to resolve its own main/sandbox upstream clusters without +// duplicating the resolution logic (URL/ref lookup, port defaulting, TLS +// detection, connect-timeout resolution). func addUpstreamCluster( rdc *models.RuntimeDeployConfig, upstreamName string, diff --git a/gateway/gateway-controller/pkg/utils/graphql_deployment.go b/gateway/gateway-controller/pkg/utils/graphql_deployment.go new file mode 100644 index 0000000000..0a033eb9ce --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/graphql_deployment.go @@ -0,0 +1,150 @@ +/* + * 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 = "GraphQLApi" + +// 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 '/'"}) + } + + 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/it/features/graphql_deploy.feature b/gateway/it/features/graphql_deploy.feature new file mode 100644 index 0000000000..ce658e41d9 --- /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 list all GraphQL APIs + 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..cbc9a2c0d0 --- /dev/null +++ b/gateway/it/steps_graphql.go @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package it + +import ( + "fmt" + "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/"+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/"+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/"+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 c33acbe516..aa88572c9e 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -360,6 +360,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) { RegisterMCPSteps(ctx, testState, httpSteps, jwtSteps) RegisterA2ASteps(ctx, testState, httpSteps) RegisterA2AClientSteps(ctx, testState, httpSteps) + RegisterGraphQLSteps(ctx, testState, httpSteps, jwtSteps) RegisterLLMSteps(ctx, testState, httpSteps) RegisterJWTSteps(ctx, testState, httpSteps, jwtSteps) RegisterPolicyEngineSteps(ctx, testState, httpSteps) From b4c9172d33759dcaed3560e3401a3ad0aa7c0fd5 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Tue, 25 Aug 2026 15:11:26 +0530 Subject: [PATCH 04/38] Add ap CLI support for GraphQL API management --- cli/src/cmd/gateway/apply.go | 3 +- .../graphqlapi/apikey/commands_test.go | 425 ++++++++++++++++++ .../cmd/gateway/graphqlapi/apikey/create.go | 151 +++++++ cli/src/cmd/gateway/graphqlapi/apikey/list.go | 128 ++++++ .../gateway/graphqlapi/apikey/regenerate.go | 88 ++++ .../cmd/gateway/graphqlapi/apikey/revoke.go | 88 ++++ cli/src/cmd/gateway/graphqlapi/apikey/root.go | 53 +++ .../cmd/gateway/graphqlapi/apikey/update.go | 104 +++++ .../cmd/gateway/graphqlapi/commands_test.go | 232 ++++++++++ cli/src/cmd/gateway/graphqlapi/delete.go | 81 ++++ cli/src/cmd/gateway/graphqlapi/get.go | 213 +++++++++ cli/src/cmd/gateway/graphqlapi/list.go | 170 +++++++ cli/src/cmd/gateway/graphqlapi/root.go | 49 ++ cli/src/cmd/gateway/root.go | 2 + cli/src/internal/gateway/resources.go | 18 + cli/src/internal/gateway/resources_test.go | 65 +++ cli/src/test/testutil/gateway.go | 18 + cli/src/utils/constants.go | 7 + 18 files changed, 1894 insertions(+), 1 deletion(-) create mode 100644 cli/src/cmd/gateway/graphqlapi/apikey/commands_test.go create mode 100644 cli/src/cmd/gateway/graphqlapi/apikey/create.go create mode 100644 cli/src/cmd/gateway/graphqlapi/apikey/list.go create mode 100644 cli/src/cmd/gateway/graphqlapi/apikey/regenerate.go create mode 100644 cli/src/cmd/gateway/graphqlapi/apikey/revoke.go create mode 100644 cli/src/cmd/gateway/graphqlapi/apikey/root.go create mode 100644 cli/src/cmd/gateway/graphqlapi/apikey/update.go create mode 100644 cli/src/cmd/gateway/graphqlapi/commands_test.go create mode 100644 cli/src/cmd/gateway/graphqlapi/delete.go create mode 100644 cli/src/cmd/gateway/graphqlapi/get.go create mode 100644 cli/src/cmd/gateway/graphqlapi/list.go create mode 100644 cli/src/cmd/gateway/graphqlapi/root.go create mode 100644 cli/src/internal/gateway/resources_test.go create mode 100644 cli/src/test/testutil/gateway.go 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..4d67f330eb --- /dev/null +++ b/cli/src/cmd/gateway/graphqlapi/apikey/update.go @@ -0,0 +1,104 @@ +/* + * 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 (required)") + updateCmd.MarkFlagRequired(utils.FlagID) + updateCmd.MarkFlagRequired(utils.FlagKeyName) + updateCmd.MarkFlagRequired(utils.FlagAPIKey) +} + +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) == "" { + 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..bec333c1be --- /dev/null +++ b/cli/src/cmd/gateway/graphqlapi/list.go @@ -0,0 +1,170 @@ +/* + * 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 + } + + // 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" From 33bc2813ed45036ce2b99e407cd64a36955d0cf3 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Tue, 25 Aug 2026 15:13:49 +0530 Subject: [PATCH 05/38] Add GraphQL API key management to gateway-controller --- .../api/management-openapi.yaml | 257 +++++ .../gateway-controller/cmd/controller/main.go | 6 + .../cmd/controller/main_test.go | 10 + .../pkg/api/handlers/graphql_api_handler.go | 205 ++++ .../handlers/graphql_apikey_handler_test.go | 551 ++++++++++ .../pkg/api/management/generated.go | 987 +++++++++++------- gateway/it/features/graphql-api-keys.feature | 388 +++++++ 7 files changed, 2018 insertions(+), 386 deletions(-) create mode 100644 gateway/gateway-controller/pkg/api/handlers/graphql_apikey_handler_test.go create mode 100644 gateway/it/features/graphql-api-keys.feature diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index 2505c76676..9b97a1c8df 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -757,6 +757,263 @@ paths: 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: + 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: GraphQLApi 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 a GraphQL API + description: List all API keys for a GraphQLApi in the Gateway. + operationId: listGraphQLAPIKeys + x-basicauth-roles: [admin, consumer] + tags: + - GraphQL 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: countries-graphql-api + responses: + "200": + description: List of API keys + content: + application/json: + schema: + $ref: "#/components/schemas/APIKeyListResponse" + "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" + + /graphql-apis/{id}/api-keys/{apiKeyName}/regenerate: + post: + 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: + - 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 + - name: apiKeyName + in: path + required: true + description: | + Name of the API key to regenerate + schema: + type: string + example: countries-graphql-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: GraphQLApi 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/{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: updateGraphQLAPIKey + x-basicauth-roles: [admin, consumer] + tags: + - GraphQL API Management + parameters: + - name: id + in: path + required: true + description: | + Unique public identifier of the API + schema: + type: string + example: countries-graphql-api + - name: apiKeyName + in: path + required: true + description: | + Name of the API key to update + schema: + type: string + example: countries-graphql-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: revokeGraphQLAPIKey + x-basicauth-roles: [admin, consumer] + tags: + - GraphQL 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: countries-graphql-api + - name: apiKeyName + in: path + required: true + description: | + Name of the API key to revoke + schema: + type: string + example: countries-graphql-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: GraphQLApi not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /subscription-plans: post: summary: Create a subscription plan diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index f31da6f965..6659402145 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -1014,6 +1014,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 2bfec376a6..6197d3c1e5 100644 --- a/gateway/gateway-controller/cmd/controller/main_test.go +++ b/gateway/gateway-controller/cmd/controller/main_test.go @@ -753,6 +753,16 @@ func TestGenerateAuthConfig(t *testing.T) { 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 index 39fe37ab7b..78b7b2c0ad 100644 --- a/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go +++ b/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go @@ -24,6 +24,7 @@ import ( "io" "log/slog" "net/http" + "strings" "time" "github.com/wso2/api-platform/common/eventhub" @@ -327,6 +328,210 @@ func (s *APIServer) publishGraphQLAPIEvent(action, entityID, correlationID strin } } +// 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 { + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: err.Error()}) + } + 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 { + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: err.Error()}) + } + 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 { + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: err.Error()}) + } + 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 { + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: err.Error()}) + } + 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 { + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: err.Error()}) + } + 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. 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/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index e0959c4e58..a35510d685 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -2433,6 +2433,15 @@ 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 @@ -3105,6 +3114,21 @@ type ServerInterface interface { // 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) @@ -3894,6 +3918,188 @@ func (siw *ServerInterfaceWrapper) UpdateGraphQLAPI(w http.ResponseWriter, r *ht 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) { @@ -5849,6 +6055,11 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H 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) @@ -5911,392 +6122,396 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "XjWWJyzk6vPV/x8AAP//LO9kPd23AgA=", } // GetSwagger returns the content of the embedded swagger specification file 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 From 06c1014c29798e7709a97695b204016905ca6069 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Tue, 25 Aug 2026 15:18:57 +0530 Subject: [PATCH 06/38] Add control-plane support for GraphQL API management --- platform-api/go.mod | 2 + platform-api/go.sum | 8 + platform-api/internal/apperror/catalog.go | 13 + .../internal/apperror/catalog_test.go | 1 + platform-api/internal/apperror/codes.go | 8 + platform-api/internal/constants/constants.go | 6 + platform-api/internal/dto/graphql_api.go | 72 + .../internal/gatewaytranslator/dataversion.go | 1 + platform-api/internal/handler/graphql_api.go | 323 ++++ .../internal/handler/graphql_api_test.go | 156 ++ .../internal/handler/graphql_apikey.go | 242 +++ .../internal/handler/graphql_deployment.go | 241 +++ .../internal/handler/pagination_test.go | 61 + platform-api/internal/model/gateway_event.go | 33 + platform-api/internal/model/graphql_api.go | 71 + platform-api/internal/repository/api.go | 64 +- .../internal/repository/artifact_tables.go | 7 + .../repository/artifact_tables_test.go | 57 + .../internal/repository/graphql_api.go | 389 +++++ .../internal/repository/graphql_api_test.go | 612 +++++++ .../internal/repository/interfaces.go | 33 + .../server/scope_route_coverage_test.go | 3 + platform-api/internal/server/server.go | 20 + .../service/artifact_dp_apikey_test.go | 18 + .../internal/service/deployment_test.go | 2 + .../internal/service/gateway_events.go | 19 + platform-api/internal/service/graphql_api.go | 677 ++++++++ .../internal/service/graphql_api_test.go | 1416 +++++++++++++++++ .../internal/service/graphql_apikey_test.go | 116 ++ .../internal/service/graphql_deployment.go | 592 +++++++ .../service/graphql_deployment_test.go | 352 ++++ .../internal/service/graphql_gateway_test.go | 145 ++ .../internal/service/graphql_introspection.go | 420 +++++ .../internal/service/graphql_mapping.go | 113 ++ platform-api/internal/service/graphql_sdl.go | 47 + .../internal/utils/graphql_multipart.go | 94 ++ .../internal/utils/graphql_multipart_test.go | 134 ++ .../resources/role-to-scope-mapping.yaml | 6 + 38 files changed, 6560 insertions(+), 14 deletions(-) create mode 100644 platform-api/internal/dto/graphql_api.go create mode 100644 platform-api/internal/handler/graphql_api.go create mode 100644 platform-api/internal/handler/graphql_api_test.go create mode 100644 platform-api/internal/handler/graphql_apikey.go create mode 100644 platform-api/internal/handler/graphql_deployment.go create mode 100644 platform-api/internal/handler/pagination_test.go create mode 100644 platform-api/internal/model/graphql_api.go create mode 100644 platform-api/internal/repository/artifact_tables_test.go create mode 100644 platform-api/internal/repository/graphql_api.go create mode 100644 platform-api/internal/repository/graphql_api_test.go create mode 100644 platform-api/internal/service/graphql_api.go create mode 100644 platform-api/internal/service/graphql_api_test.go create mode 100644 platform-api/internal/service/graphql_apikey_test.go create mode 100644 platform-api/internal/service/graphql_deployment.go create mode 100644 platform-api/internal/service/graphql_deployment_test.go create mode 100644 platform-api/internal/service/graphql_gateway_test.go create mode 100644 platform-api/internal/service/graphql_introspection.go create mode 100644 platform-api/internal/service/graphql_mapping.go create mode 100644 platform-api/internal/service/graphql_sdl.go create mode 100644 platform-api/internal/utils/graphql_multipart.go create mode 100644 platform-api/internal/utils/graphql_multipart_test.go diff --git a/platform-api/go.mod b/platform-api/go.mod index 1372944931..3eb38825ed 100644 --- a/platform-api/go.mod +++ b/platform-api/go.mod @@ -19,6 +19,7 @@ require ( github.com/pb33f/libopenapi v0.38.7 github.com/pb33f/libopenapi-validator v0.14.0 github.com/stretchr/testify v1.12.1 + github.com/vektah/gqlparser/v2 v2.5.36 github.com/wso2/api-platform/common v0.0.0-00010101000000-000000000000 github.com/wso2/api-platform/httpkit v0.0.0-local golang.org/x/crypto v0.57.0 @@ -29,6 +30,7 @@ require ( require ( github.com/MicahParks/jwkset v0.11.0 // indirect github.com/MicahParks/keyfunc/v3 v3.7.0 // indirect + github.com/agnivade/levenshtein v1.2.1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad // indirect diff --git a/platform-api/go.sum b/platform-api/go.sum index 238da3de8b..283ad985ae 100644 --- a/platform-api/go.sum +++ b/platform-api/go.sum @@ -17,8 +17,12 @@ github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7 github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3kITJGM= github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad h1:3swAvbzgfaI6nKuDDU7BiKfZRdF+h2ZwKgMHd8Ha4t8= @@ -31,6 +35,8 @@ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx2 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -137,6 +143,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/vektah/gqlparser/v2 v2.5.36 h1:CN9mKVHgMkc+XftdOWIhb4HEL8wKSYkFAqhf8booa7s= +github.com/vektah/gqlparser/v2 v2.5.36/go.mod h1:cAJ9qwVgPaUkWv6Gn8vn0mqOE0Ui5Pn56wNy5396XWo= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= diff --git a/platform-api/internal/apperror/catalog.go b/platform-api/internal/apperror/catalog.go index 55a1767b1d..42d61e0c2b 100644 --- a/platform-api/internal/apperror/catalog.go +++ b/platform-api/internal/apperror/catalog.go @@ -214,6 +214,19 @@ var ( WebBrokerAPIExists = def(CodeWebBrokerAPIExists, http.StatusConflict, "A WebBroker API with this ID already exists.") ) +// GraphQL API entries (GraphQL is a core artifact kind, not a plugin). +// GraphQLAPISchemaResolveFailed is the generic 422 for both "introspection +// against upstream.main.url failed" and +// "the supplied SDL could not be parsed" — the message never echoes the resolved +// IP, the parser's internal error text, or which specific reason applied +// (error-handling.md / ssrf-prevention.md). +var ( + GraphQLAPINotFound = def(CodeGraphQLAPINotFound, http.StatusNotFound, "The specified GraphQL API could not be found.") + GraphQLAPIExists = def(CodeGraphQLAPIExists, http.StatusConflict, "A GraphQL API with this ID already exists.") + GraphQLAPISchemaResolveFailed = def(CodeGraphQLAPISchemaResolveFailed, http.StatusUnprocessableEntity, "The provided endpoint could not be used to derive a GraphQL schema, or the supplied SDL could not be parsed.") + GraphQLAPIDeploymentValidationFailed = def(CodeGraphQLAPIDeploymentValidationFailed, http.StatusBadRequest, "%s") +) + // HMAC secret entries. The 32-character minimum is a fixed, publicly // documented rule, so stating it in the client message reveals nothing the // API contract does not already. diff --git a/platform-api/internal/apperror/catalog_test.go b/platform-api/internal/apperror/catalog_test.go index 37ec91402e..600a3718b2 100644 --- a/platform-api/internal/apperror/catalog_test.go +++ b/platform-api/internal/apperror/catalog_test.go @@ -43,6 +43,7 @@ var messageArity = map[string]int{ CodeOf(LLMProviderDeploymentValidationFailed): 1, CodeOf(LLMProxyDeploymentValidationFailed): 1, CodeOf(MCPProxyDeploymentValidationFailed): 1, + CodeOf(GraphQLAPIDeploymentValidationFailed): 1, CodeOf(DeploymentNotActive): 1, CodeOf(BuildLimitReached): 1, CodeOf(ArtifactReadOnly): 1, diff --git a/platform-api/internal/apperror/codes.go b/platform-api/internal/apperror/codes.go index 9d191ff682..0fdbfbdbaa 100644 --- a/platform-api/internal/apperror/codes.go +++ b/platform-api/internal/apperror/codes.go @@ -202,6 +202,14 @@ const ( CodeWebBrokerAPIExists = "WEBBROKER_API_EXISTS" ) +// GraphQL API domain codes (GraphQL is a core artifact kind, not a plugin). +const ( + CodeGraphQLAPINotFound = "GRAPHQL_API_NOT_FOUND" + CodeGraphQLAPIExists = "GRAPHQL_API_EXISTS" + CodeGraphQLAPISchemaResolveFailed = "GRAPHQL_API_SCHEMA_RESOLVE_FAILED" + CodeGraphQLAPIDeploymentValidationFailed = "GRAPHQL_API_DEPLOYMENT_VALIDATION_FAILED" +) + // HMAC secret domain codes (WebSub subscriber callback signing secrets). // HMAC_SECRET_NOT_CONFIGURED is a 503 rather than a 500: the encryption key is // a deployment-time setting, so the condition is transient from the client's diff --git a/platform-api/internal/constants/constants.go b/platform-api/internal/constants/constants.go index bfe74ec171..39c607baec 100644 --- a/platform-api/internal/constants/constants.go +++ b/platform-api/internal/constants/constants.go @@ -81,6 +81,11 @@ const ( LLMProviderTemplate = "LlmProviderTemplate" LLMProxy = "LlmProxy" MCPProxy = "Mcp" + // GraphQLApi is a core artifact kind (like RestApi/LlmProvider/LlmProxy/Mcp) — + // always compiled in, pre-seeded in NewArtifactTableRegistry(), no build tag. + // This is a different axis from the APITypeGraphQL/APISubTypeGraphQL dev-portal + // content-type constants below — do not conflate the two. + GraphQLApi = "GraphQLApi" ) // Artifact origin values. Origin distinguishes control-plane created artifacts @@ -232,6 +237,7 @@ var ValidArtifactKinds = map[string]bool{ LLMProvider: true, LLMProxy: true, MCPProxy: true, + GraphQLApi: true, } // Throttle limit unit constants diff --git a/platform-api/internal/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..f0e8ec0152 --- /dev/null +++ b/platform-api/internal/handler/graphql_api.go @@ -0,0 +1,323 @@ +/* + * 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.Get(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 +} + +// 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("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..4ae5960736 --- /dev/null +++ b/platform-api/internal/handler/graphql_apikey.go @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package handler + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/middleware" + "github.com/wso2/api-platform/platform-api/internal/router" + "github.com/wso2/api-platform/platform-api/internal/service" + "github.com/wso2/api-platform/platform-api/internal/utils" + + "github.com/wso2/api-platform/httpkit/httputil" +) + +// GraphQLAPIKeyHandler handles API key operations for GraphQL APIs. +// +// Unlike LLM Provider/Proxy (which each get a dedicated APIKeyService — +// see llm_apikey.go/llm_proxy_apikey.go), GraphQL API keys reuse the existing +// *service.APIKeyService unmodified. That service already resolves the target +// artifact via the kind-agnostic ArtifactRepository.GetAPIMetadataByHandleAndKind +// and is exercised in production with multiple kinds beyond RestApi today (the +// eventgateway plugin's WebSub/WebBroker API key handlers call the very same +// instance with constants.WebSubApi/constants.WebBrokerApi — see +// plugins/eventgateway/handler/{websub,webbroker}_apikey.go). Its only +// REST-typed dependency (apiRepo repository.APIRepository) is used solely for +// GetAPIGatewaysWithDetails, which reads the kind-agnostic +// artifact_gateway_mappings table and works correctly for any artifact kind. +// So this handler is the only new code needed here — introducing a +// GraphQLAPIKeyService would duplicate ~300 lines of hashing/broadcast logic +// that is already proven kind-agnostic. +type GraphQLAPIKeyHandler struct { + apiKeyService *service.APIKeyService + identity *service.IdentityService + authzMode string + slogger *slog.Logger +} + +// NewGraphQLAPIKeyHandler creates a new GraphQL API key handler. +func NewGraphQLAPIKeyHandler(apiKeyService *service.APIKeyService, identity *service.IdentityService, authzMode string, slogger *slog.Logger) *GraphQLAPIKeyHandler { + return &GraphQLAPIKeyHandler{ + apiKeyService: apiKeyService, + identity: identity, + authzMode: authzMode, + slogger: slogger, + } +} + +// isKeyAdmin reports whether the caller holds constants.ScopeAPIKeyAllManage and may +// therefore act on API keys created by other users, not only their own. +func (h *GraphQLAPIKeyHandler) isKeyAdmin(r *http.Request) bool { + return middleware.HasEffectiveScope(r, h.authzMode, constants.ScopeAPIKeyAllManage) +} + +// CreateAPIKey handles POST /api/v0.9/graphql-apis/{graphqlApiId}/api-keys +func (h *GraphQLAPIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + userId, err := resolveActorErr(r, h.identity, "create GraphQL API key") + if err != nil { + return err + } + + apiHandle := r.PathValue("graphqlApiId") + if apiHandle == "" { + return apperror.ValidationFailed.New("API handle is required") + } + + var req api.CreateAPIKeyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid API key creation request for user %s", userId)) + } + + if req.DisplayName == "" { + return apperror.ValidationFailed.New("Display name is required"). + WithLogMessage(fmt.Sprintf("missing display name in API key creation request for user %s", userId)) + } + + var name string + if req.Id != nil && *req.Id != "" { + name = *req.Id + } else { + generatedName, err := utils.GenerateHandle(req.DisplayName, nil) + if err != nil { + return apperror.ValidationFailed.Wrap(err, "Failed to generate API key name") + } + name = generatedName + req.Id = &name + } + + resp, err := h.apiKeyService.CreateAPIKey(r.Context(), apiHandle, constants.GraphQLApi, orgId, userId, &req) + if err != nil { + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err + } + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to create API key %q for GraphQL API %s in org %s by user %s", name, apiHandle, orgId, userId)) + } + + keyName := "" + if req.Id != nil { + keyName = *req.Id + } + h.slogger.Info("Successfully created GraphQL API key", "userId", userId, "apiHandle", apiHandle, "orgId", orgId, "keyName", keyName) + + setLocation(w, "graphql-apis", apiHandle, "api-keys", keyName) + httputil.WriteJSON(w, http.StatusCreated, resp) + return nil +} + +// UpdateAPIKey handles PUT /api/v0.9/graphql-apis/{graphqlApiId}/api-keys/{apiKeyId} +func (h *GraphQLAPIKeyHandler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + userId, err := resolveActorErr(r, h.identity, "update GraphQL API key") + if err != nil { + return err + } + + apiHandle := r.PathValue("graphqlApiId") + if apiHandle == "" { + return apperror.ValidationFailed.New("API handle is required") + } + + keyName := r.PathValue("apiKeyId") + if keyName == "" { + return apperror.ValidationFailed.New("API key name is required") + } + + var req api.UpdateAPIKeyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid API key update request for key %s of GraphQL API %s in org %s by user %s", keyName, apiHandle, orgId, userId)) + } + + if req.ApiKey == "" { + return apperror.ValidationFailed.New("API key value is required") + } + + if err := utils.ValidateHandleImmutable(keyName, req.Name); err != nil { + h.slogger.Warn("API key name mismatch", "userId", userId, "orgId", orgId, "apiHandle", apiHandle, "urlKeyName", keyName, "bodyKeyName", *req.Name) + return apperror.ValidationFailed.New(fmt.Sprintf("API key name mismatch: name in request body '%s' must match the key name in URL '%s'", *req.Name, keyName)). + WithLogMessage(fmt.Sprintf("API key name mismatch for GraphQL API %s in org %s by user %s", apiHandle, orgId, userId)) + } + + if err := h.apiKeyService.UpdateAPIKey(r.Context(), apiHandle, constants.GraphQLApi, orgId, keyName, userId, h.isKeyAdmin(r), false, &req); err != nil { + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err + } + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to update API key %s for GraphQL API %s in org %s by user %s", keyName, apiHandle, orgId, userId)) + } + + h.slogger.Info("Successfully updated GraphQL API key", "userId", userId, "apiHandle", apiHandle, "orgId", orgId, "keyName", keyName) + + httputil.WriteJSON(w, http.StatusOK, api.UpdateAPIKeyResponse{ + Status: api.UpdateAPIKeyResponseStatusSuccess, + Message: "API key updated and broadcasted to gateways successfully", + KeyId: &keyName, + }) + return nil +} + +// RevokeAPIKey handles DELETE /api/v0.9/graphql-apis/{graphqlApiId}/api-keys/{apiKeyId} +func (h *GraphQLAPIKeyHandler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiHandle := r.PathValue("graphqlApiId") + if apiHandle == "" { + return apperror.ValidationFailed.New("API handle is required") + } + + keyName := r.PathValue("apiKeyId") + if keyName == "" { + return apperror.ValidationFailed.New("API key name is required") + } + + userId, err := resolveActorErr(r, h.identity, "revoke GraphQL API key") + if err != nil { + return err + } + + if err := h.apiKeyService.RevokeAPIKey(r.Context(), apiHandle, constants.GraphQLApi, orgId, keyName, userId, h.isKeyAdmin(r), false); err != nil { + var appErr *apperror.Error + if errors.As(err, &appErr) { + return err + } + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to revoke API key %s for GraphQL API %s in org %s by user %s", keyName, apiHandle, orgId, userId)) + } + + h.slogger.Info("Successfully revoked GraphQL API key", "userId", userId, "apiHandle", apiHandle, "orgId", orgId, "keyName", keyName) + + w.WriteHeader(http.StatusNoContent) + return nil +} + +// RegisterRoutes registers GraphQL API key routes with the router. +func (h *GraphQLAPIKeyHandler) RegisterRoutes(mux router.Router) { + h.slogger.Debug("Registering GraphQL API key routes") + base := constants.APIBasePath + "/graphql-apis/{graphqlApiId}/api-keys" + mux.HandleFunc("POST "+base, middleware.MapErrors(h.slogger, h.CreateAPIKey)) + mux.HandleFunc("PUT "+base+"/{apiKeyId}", middleware.MapErrors(h.slogger, h.UpdateAPIKey)) + mux.HandleFunc("DELETE "+base+"/{apiKeyId}", middleware.MapErrors(h.slogger, h.RevokeAPIKey)) +} diff --git a/platform-api/internal/handler/graphql_deployment.go b/platform-api/internal/handler/graphql_deployment.go new file mode 100644 index 0000000000..b23b8a03d9 --- /dev/null +++ b/platform-api/internal/handler/graphql_deployment.go @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package handler + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/middleware" + "github.com/wso2/api-platform/platform-api/internal/router" + "github.com/wso2/api-platform/platform-api/internal/service" + + "github.com/wso2/api-platform/httpkit/httputil" +) + +// GraphQLAPIDeploymentHandler handles GraphQL API deployment endpoints using the +// shared deployment model. Mirrors LLMProviderDeploymentHandler +// (internal/handler/llm_deployment.go) — see GraphQLAPIDeploymentService's doc +// comment for why GraphQL gets its own dedicated deployment service/handler +// pair rather than reusing DeploymentHandler/DeploymentService. +type GraphQLAPIDeploymentHandler struct { + deploymentService *service.GraphQLAPIDeploymentService + identity *service.IdentityService + slogger *slog.Logger +} + +// NewGraphQLAPIDeploymentHandler creates a new GraphQL API deployment handler. +func NewGraphQLAPIDeploymentHandler(deploymentService *service.GraphQLAPIDeploymentService, identity *service.IdentityService, slogger *slog.Logger) *GraphQLAPIDeploymentHandler { + return &GraphQLAPIDeploymentHandler{deploymentService: deploymentService, identity: identity, slogger: slogger} +} + +// DeployGraphQLAPI handles POST /api/v0.9/graphql-apis/{graphqlApiId}/deployments +func (h *GraphQLAPIDeploymentHandler) DeployGraphQLAPI(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("graphqlApiId") + if apiId == "" { + return apperror.ValidationFailed.New("GraphQL API ID is required") + } + + var req api.DeployRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return apperror.ValidationFailed.Wrap(err, "Invalid request body"). + WithLogMessage(fmt.Sprintf("invalid GraphQL API deployment request body for API %s", apiId)) + } + + if req.Name == "" { + return apperror.GraphQLAPIDeploymentValidationFailed.New("name is required") + } + if req.Base == "" { + return apperror.GraphQLAPIDeploymentValidationFailed.New("base is required (use 'current' or a deploymentId)") + } + if strings.TrimSpace(req.GatewayId) == "" { + return apperror.GraphQLAPIDeploymentValidationFailed.New("gatewayId is required") + } + + createdBy, err := resolveActorErr(r, h.identity, "deploy GraphQL API") + if err != nil { + return err + } + + deployment, err := h.deploymentService.DeployGraphQLAPI(apiId, &req, orgId, createdBy) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to deploy GraphQL API %s", apiId)) + } + + setLocation(w, "graphql-apis", apiId, "deployments", deployment.DeploymentId.String()) + httputil.WriteJSON(w, http.StatusCreated, deployment) + return nil +} + +// UndeployGraphQLAPIDeployment handles POST /api/v0.9/graphql-apis/{graphqlApiId}/deployments/{deploymentId}/undeploy +func (h *GraphQLAPIDeploymentHandler) UndeployGraphQLAPIDeployment(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("graphqlApiId") + deploymentId := r.PathValue("deploymentId") + gatewayId := r.URL.Query().Get("gatewayId") + + if apiId == "" { + return apperror.ValidationFailed.New("GraphQL API ID is required") + } + deployment, err := h.deploymentService.UndeployGraphQLAPIDeployment(apiId, deploymentId, gatewayId, orgId) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to undeploy GraphQL API %s deployment %s on gateway %q", apiId, deploymentId, gatewayId)) + } + + httputil.WriteJSON(w, http.StatusOK, deployment) + return nil +} + +// RestoreGraphQLAPIDeployment handles POST /api/v0.9/graphql-apis/{graphqlApiId}/deployments/{deploymentId}/restore +func (h *GraphQLAPIDeploymentHandler) RestoreGraphQLAPIDeployment(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("graphqlApiId") + deploymentId := r.PathValue("deploymentId") + gatewayId := r.URL.Query().Get("gatewayId") + + if apiId == "" { + return apperror.ValidationFailed.New("GraphQL API ID is required") + } + deployment, err := h.deploymentService.RestoreGraphQLAPIDeployment(apiId, deploymentId, gatewayId, orgId) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to restore GraphQL API %s deployment %s on gateway %q", apiId, deploymentId, gatewayId)) + } + + httputil.WriteJSON(w, http.StatusOK, deployment) + return nil +} + +// DeleteGraphQLAPIDeployment handles DELETE /api/v0.9/graphql-apis/{graphqlApiId}/deployments/{deploymentId} +func (h *GraphQLAPIDeploymentHandler) DeleteGraphQLAPIDeployment(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("graphqlApiId") + deploymentId := r.PathValue("deploymentId") + + if apiId == "" { + return apperror.ValidationFailed.New("GraphQL API ID is required") + } + if deploymentId == "" { + return apperror.ValidationFailed.New("Deployment ID is required") + } + + if err := h.deploymentService.DeleteGraphQLAPIDeployment(apiId, deploymentId, orgId); err != nil { + return serviceError(err, fmt.Sprintf("failed to delete GraphQL API %s deployment %s", apiId, deploymentId)) + } + + w.WriteHeader(http.StatusNoContent) + return nil +} + +// GetGraphQLAPIDeployment handles GET /api/v0.9/graphql-apis/{graphqlApiId}/deployments/{deploymentId} +func (h *GraphQLAPIDeploymentHandler) GetGraphQLAPIDeployment(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("graphqlApiId") + deploymentId := r.PathValue("deploymentId") + + if apiId == "" { + return apperror.ValidationFailed.New("GraphQL API ID is required") + } + if deploymentId == "" { + return apperror.ValidationFailed.New("Deployment ID is required") + } + + deployment, err := h.deploymentService.GetGraphQLAPIDeployment(apiId, deploymentId, orgId) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to get GraphQL API %s deployment %s", apiId, deploymentId)) + } + + httputil.WriteJSON(w, http.StatusOK, deployment) + return nil +} + +// GetGraphQLAPIDeployments handles GET /api/v0.9/graphql-apis/{graphqlApiId}/deployments +func (h *GraphQLAPIDeploymentHandler) GetGraphQLAPIDeployments(w http.ResponseWriter, r *http.Request) error { + orgId, exists := middleware.GetOrganizationFromRequest(r) + if !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + apiId := r.PathValue("graphqlApiId") + if apiId == "" { + return apperror.ValidationFailed.New("GraphQL API ID is required") + } + + q := r.URL.Query() + var gatewayId, status *string + if v := q.Get("gatewayId"); v != "" { + gatewayId = &v + } + if v := q.Get("status"); v != "" { + status = &v + } + + limit, offset := parsePagination(r) + + deployments, err := h.deploymentService.GetGraphQLAPIDeployments(apiId, orgId, gatewayId, status) + if err != nil { + return serviceError(err, fmt.Sprintf("failed to get GraphQL API %s deployments", apiId)) + } + + paginateDeploymentList(deployments, limit, offset) + httputil.WriteJSON(w, http.StatusOK, deployments) + return nil +} + +// RegisterRoutes registers all GraphQL API deployment-related routes. +func (h *GraphQLAPIDeploymentHandler) RegisterRoutes(mux router.Router) { + base := constants.APIBasePath + "/graphql-apis/{graphqlApiId}" + mux.HandleFunc("POST "+base+"/deployments", middleware.MapErrors(h.slogger, h.DeployGraphQLAPI)) + mux.HandleFunc("POST "+base+"/deployments/{deploymentId}/undeploy", middleware.MapErrors(h.slogger, h.UndeployGraphQLAPIDeployment)) + mux.HandleFunc("POST "+base+"/deployments/{deploymentId}/restore", middleware.MapErrors(h.slogger, h.RestoreGraphQLAPIDeployment)) + mux.HandleFunc("GET "+base+"/deployments", middleware.MapErrors(h.slogger, h.GetGraphQLAPIDeployments)) + mux.HandleFunc("GET "+base+"/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.GetGraphQLAPIDeployment)) + mux.HandleFunc("DELETE "+base+"/deployments/{deploymentId}", middleware.MapErrors(h.slogger, h.DeleteGraphQLAPIDeployment)) +} diff --git a/platform-api/internal/handler/pagination_test.go b/platform-api/internal/handler/pagination_test.go new file mode 100644 index 0000000000..540c1e889d --- /dev/null +++ b/platform-api/internal/handler/pagination_test.go @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestParsePagination pins parsePagination's clamping contract — shared by +// every kind's list handler (including GraphQL's ListGraphQLAPIs), and +// previously untested anywhere in the repo despite being the one place that +// stands between a client-supplied limit/offset and an unbounded query. +func TestParsePagination(t *testing.T) { + tests := []struct { + name string + query string + wantLimit int + wantOffset int + }{ + {"defaults when absent", "", defaultPageLimit, defaultPageOffset}, + {"limit clamped at the upper bound", "limit=999", maxPageLimit, defaultPageOffset}, + {"limit clamped at the lower bound (zero)", "limit=0", minPageLimit, defaultPageOffset}, + {"limit clamped at the lower bound (negative)", "limit=-5", minPageLimit, defaultPageOffset}, + {"limit within range is respected", "limit=42", 42, defaultPageOffset}, + {"malformed limit falls back to default", "limit=not-a-number", defaultPageLimit, defaultPageOffset}, + {"offset respected when non-negative", "offset=15", defaultPageLimit, 15}, + {"negative offset falls back to default", "offset=-1", defaultPageLimit, defaultPageOffset}, + {"malformed offset falls back to default", "offset=not-a-number", defaultPageLimit, defaultPageOffset}, + {"limit and offset combined", "limit=999&offset=15", maxPageLimit, 15}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/graphql-apis?"+tt.query, nil) + limit, offset := parsePagination(r) + if limit != tt.wantLimit { + t.Errorf("limit = %d, want %d", limit, tt.wantLimit) + } + if offset != tt.wantOffset { + t.Errorf("offset = %d, want %d", offset, tt.wantOffset) + } + }) + } +} diff --git a/platform-api/internal/model/gateway_event.go b/platform-api/internal/model/gateway_event.go index 31c7b6b60a..469b649d20 100644 --- a/platform-api/internal/model/gateway_event.go +++ b/platform-api/internal/model/gateway_event.go @@ -174,6 +174,39 @@ type MCPProxyDeletionEvent struct { ProxyId string `json:"proxyId"` } +// GraphQLAPIDeploymentEvent contains payload data for "graphqlapi.deployed" event +// type. This event is sent when a GraphQL API is successfully deployed to a gateway. +type GraphQLAPIDeploymentEvent struct { + // ApiId identifies the deployed GraphQL API (handle) + ApiId string `json:"apiId"` + + // DeploymentID identifies the specific deployment artifact + DeploymentID string `json:"deploymentId"` + + // PerformedAt is the timestamp when the deployment was initiated (concurrency token) + PerformedAt time.Time `json:"performedAt"` +} + +// GraphQLAPIUndeploymentEvent contains payload data for "graphqlapi.undeployed" event +// type. This event is sent when a GraphQL API is undeployed from a gateway. +type GraphQLAPIUndeploymentEvent struct { + // ApiId identifies the undeployed GraphQL API (handle) + ApiId string `json:"apiId"` + + // DeploymentID identifies the specific deployment being undeployed + DeploymentID string `json:"deploymentId"` + + // PerformedAt is the timestamp when the undeployment was initiated (concurrency token) + PerformedAt time.Time `json:"performedAt"` +} + +// GraphQLAPIDeletionEvent contains payload data for "graphqlapi.deleted" event +// type. This event is sent when a GraphQL API is permanently deleted from the platform. +type GraphQLAPIDeletionEvent struct { + // ApiId identifies the deleted GraphQL API (handle) + ApiId string `json:"apiId"` +} + // WebSubAPIDeploymentEvent contains payload data for "websub.deployed" event type. // This event is sent when a WebSub API is successfully deployed to a gateway. type WebSubAPIDeploymentEvent struct { diff --git a/platform-api/internal/model/graphql_api.go b/platform-api/internal/model/graphql_api.go new file mode 100644 index 0000000000..fcc13507f5 --- /dev/null +++ b/platform-api/internal/model/graphql_api.go @@ -0,0 +1,71 @@ +/* + * 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"` + LifeCycleStatus string `json:"lifeCycleStatus,omitempty" db:"lifecycle_status"` + Configuration GraphQLAPIConfig `json:"configuration" db:"-"` + Origin string `json:"origin,omitempty" db:"origin"` + DataVersion string `json:"dataVersion,omitempty" db:"data_version"` +} + +// GraphQLAPIConfig holds the GraphQL API configuration stored as JSON in the +// DB. Deliberately absent compared to RestAPIConfig: Transport and +// Operations — a GraphQL API has exactly one logical endpoint, not a +// per-resource/per-verb operation list. +type GraphQLAPIConfig struct { + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + Context *string `json:"context,omitempty"` // e.g. "/countries/$version" — same $version substitution as REST + + // SDL is the GraphQL schema, always stored resolved — never a + // document-supplied schemaLocation (xxe-xml-processing.md §3 applies by + // analogy: the server never auto-dereferences a secondary location). + SDL string `json:"sdl"` + + // IntrospectionMode records how SDL was obtained: "SDL" (supplied + // directly) or "ENDPOINT" (derived by introspecting upstream.main.url at + // creation/update time). Informational only; storage is identical either way. + IntrospectionMode string `json:"introspectionMode,omitempty"` + + // Upstream is reused as-is from model/upstream.go — a GraphQL API has a + // single endpoint (no per-operation paths), so upstream.main is the one + // GraphQL endpoint. + Upstream UpstreamConfig `json:"upstream,omitempty"` + Policies []Policy `json:"policies,omitempty"` + SubscriptionPlans []string `json:"subscriptionPlans,omitempty"` +} diff --git a/platform-api/internal/repository/api.go b/platform-api/internal/repository/api.go index f715f4f537..4aea3c60a9 100644 --- a/platform-api/internal/repository/api.go +++ b/platform-api/internal/repository/api.go @@ -572,9 +572,42 @@ func (r *APIRepo) CheckAPIExistsByNameAndVersionInOrganization(name, version, or } // CreateAPIAssociation creates a gateway-API association in artifact_gateway_mappings. -// created_by/updated_by are seeded from association.CreatedBy (the acting user); on create -// updated_by mirrors created_by. Both are stored as NULL when the actor is unknown. +// Delegates to the kind-agnostic createArtifactGatewayAssociation helper — see that +// function's doc comment (this method exists only to satisfy APIRepository). func (r *APIRepo) CreateAPIAssociation(association *model.APIAssociation) error { + return createArtifactGatewayAssociation(r.db, association) +} + +// UpdateAPIAssociation updates the updated_at timestamp and updated_by actor for a +// gateway-API association. Delegates to updateArtifactGatewayAssociation. +func (r *APIRepo) UpdateAPIAssociation(apiUUID, resourceId, associationType, orgUUID, updatedBy string) error { + return updateArtifactGatewayAssociation(r.db, apiUUID, resourceId, orgUUID, updatedBy) +} + +// GetAPIAssociations retrieves all gateway associations for an API. +// associationType is accepted for interface compatibility but only 'gateway' associations are stored. +// Delegates to getArtifactGatewayAssociations. +func (r *APIRepo) GetAPIAssociations(apiUUID, associationType, orgUUID string) ([]*model.APIAssociation, error) { + return getArtifactGatewayAssociations(r.db, apiUUID, orgUUID) +} + +// GetAPIGatewaysWithDetails retrieves all gateways associated with an API including +// deployment details. Delegates to getArtifactGatewaysWithDetails. +func (r *APIRepo) GetAPIGatewaysWithDetails(apiUUID, orgUUID string) ([]*model.APIGatewayWithDetails, error) { + return getArtifactGatewaysWithDetails(r.db, apiUUID, orgUUID) +} + +// createArtifactGatewayAssociation creates a gateway-artifact association in +// artifact_gateway_mappings. created_by/updated_by are seeded from +// association.CreatedBy (the acting user); on create updated_by mirrors created_by. +// Both are stored as NULL when the actor is unknown. +// +// This helper (and its update/get/getWithDetails siblings below) is kind-agnostic — +// artifact_gateway_mappings is keyed solely on artifact_uuid, with no REST-specific +// columns — so both *APIRepo and *GraphQLAPIRepo delegate to the exact same SQL +// rather than each maintaining their own copy. Any future kind's gateway-association +// repo methods should do the same. +func createArtifactGatewayAssociation(db *database.DB, association *model.APIAssociation) error { association.CreatedAt = time.Now().UTC() association.UpdatedAt = association.CreatedAt if association.UpdatedBy == "" { @@ -585,34 +618,35 @@ func (r *APIRepo) CreateAPIAssociation(association *model.APIAssociation) error INSERT INTO artifact_gateway_mappings (artifact_uuid, organization_uuid, gateway_uuid, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ` - _, err := r.db.Exec(r.db.Rebind(query), + _, err := db.Exec(db.Rebind(query), association.ArtifactID, association.OrganizationID, association.GatewayID, association.CreatedBy, association.UpdatedBy, association.CreatedAt, association.UpdatedAt) return err } -// UpdateAPIAssociation updates the updated_at timestamp and updated_by actor for a -// gateway-API association. -func (r *APIRepo) UpdateAPIAssociation(apiUUID, resourceId, associationType, orgUUID, updatedBy string) error { +// updateArtifactGatewayAssociation updates the updated_at timestamp and updated_by +// actor for a gateway-artifact association. See createArtifactGatewayAssociation for +// why this is a shared, kind-agnostic helper. +func updateArtifactGatewayAssociation(db *database.DB, artifactUUID, gatewayUUID, orgUUID, updatedBy string) error { query := ` UPDATE artifact_gateway_mappings SET updated_at = ?, updated_by = ? WHERE artifact_uuid = ? AND gateway_uuid = ? AND organization_uuid = ? ` - _, err := r.db.Exec(r.db.Rebind(query), time.Now().UTC(), updatedBy, apiUUID, resourceId, orgUUID) + _, err := db.Exec(db.Rebind(query), time.Now().UTC(), updatedBy, artifactUUID, gatewayUUID, orgUUID) return err } -// GetAPIAssociations retrieves all gateway associations for an API. -// associationType is accepted for interface compatibility but only 'gateway' associations are stored. -func (r *APIRepo) GetAPIAssociations(apiUUID, associationType, orgUUID string) ([]*model.APIAssociation, error) { +// getArtifactGatewayAssociations retrieves all gateway associations for an artifact. +// See createArtifactGatewayAssociation for why this is a shared, kind-agnostic helper. +func getArtifactGatewayAssociations(db *database.DB, artifactUUID, orgUUID string) ([]*model.APIAssociation, error) { query := ` SELECT artifact_uuid, organization_uuid, gateway_uuid, created_by, updated_by, created_at, updated_at FROM artifact_gateway_mappings WHERE artifact_uuid = ? AND organization_uuid = ? ` - rows, err := r.db.Query(r.db.Rebind(query), apiUUID, orgUUID) + rows, err := db.Query(db.Rebind(query), artifactUUID, orgUUID) if err != nil { return nil, err } @@ -635,8 +669,10 @@ func (r *APIRepo) GetAPIAssociations(apiUUID, associationType, orgUUID string) ( return associations, rows.Err() } -// GetAPIGatewaysWithDetails retrieves all gateways associated with an API including deployment details. -func (r *APIRepo) GetAPIGatewaysWithDetails(apiUUID, orgUUID string) ([]*model.APIGatewayWithDetails, error) { +// getArtifactGatewaysWithDetails retrieves all gateways associated with an artifact, +// including deployment details. See createArtifactGatewayAssociation for why this is +// a shared, kind-agnostic helper. +func getArtifactGatewaysWithDetails(db *database.DB, artifactUUID, orgUUID string) ([]*model.APIGatewayWithDetails, error) { query := ` SELECT g.uuid as id, @@ -664,7 +700,7 @@ func (r *APIRepo) GetAPIGatewaysWithDetails(apiUUID, orgUUID string) ([]*model.A ORDER BY aa.created_at DESC, ge.id ASC ` - rows, err := r.db.Query(r.db.Rebind(query), apiUUID, string(model.DeploymentStatusDeployed), apiUUID, orgUUID) + rows, err := db.Query(db.Rebind(query), artifactUUID, string(model.DeploymentStatusDeployed), artifactUUID, orgUUID) if err != nil { return nil, err } diff --git a/platform-api/internal/repository/artifact_tables.go b/platform-api/internal/repository/artifact_tables.go index 914c59e854..8d6956a40b 100644 --- a/platform-api/internal/repository/artifact_tables.go +++ b/platform-api/internal/repository/artifact_tables.go @@ -22,6 +22,8 @@ import ( "fmt" "strings" "sync" + + "github.com/wso2/api-platform/platform-api/internal/constants" ) // ArtifactTableEntry describes a kind-specific child table that backs artifact rows. @@ -67,6 +69,11 @@ func NewArtifactTableRegistry() *ArtifactTableRegistry { KindAlias: "Mcp", KindKeys: []string{"mcp-proxy", "MCPProxy", "Mcp"}, }) + r.Register(ArtifactTableEntry{ + Table: "graphql_apis", + KindAlias: constants.GraphQLApi, + KindKeys: []string{"graphql-api", constants.GraphQLApi}, + }) return r } diff --git a/platform-api/internal/repository/artifact_tables_test.go b/platform-api/internal/repository/artifact_tables_test.go new file mode 100644 index 0000000000..133679a8e8 --- /dev/null +++ b/platform-api/internal/repository/artifact_tables_test.go @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + */ + +package repository + +import "testing" + +// TestNewArtifactTableRegistry_AllCoreKindsRegistered guards GraphQL's status +// as a core kind (like RestApi/LlmProvider/LlmProxy/Mcp): NewArtifactTableRegistry +// must register all five unconditionally, with no build tag or plugin Init() +// step able to skip any of them. A future kind silently dropped from this +// constructor would otherwise only surface as a runtime 404 on that kind's +// API-key/deployment/gateway-association endpoints — this test catches it at +// build time instead. +func TestNewArtifactTableRegistry_AllCoreKindsRegistered(t *testing.T) { + reg := NewArtifactTableRegistry() + + wantKindAliases := []string{"RestApi", "LlmProvider", "LlmProxy", "Mcp", "GraphQLApi"} + for _, alias := range wantKindAliases { + if !reg.IsValidKindAlias(alias) { + t.Errorf("expected core kind %q to be registered, but it wasn't", alias) + } + } + + entries := reg.Entries() + if len(entries) != len(wantKindAliases) { + t.Errorf("expected exactly %d core tables registered, got %d: %+v", len(wantKindAliases), len(entries), entries) + } + + // GraphQLApi specifically: confirm both the handle form ("graphql-api") + // and the Go-constant form ("GraphQLApi") resolve to the graphql_apis + // table, matching every other core kind's dual-key convention. + for _, key := range []string{"graphql-api", "GraphQLApi"} { + entry, ok := reg.TableByKindKey(key) + if !ok { + t.Fatalf("expected kind key %q to resolve to a table entry", key) + } + if entry.Table != "graphql_apis" { + t.Errorf("expected kind key %q to resolve to table \"graphql_apis\", got %q", key, entry.Table) + } + } +} diff --git a/platform-api/internal/repository/graphql_api.go b/platform-api/internal/repository/graphql_api.go new file mode 100644 index 0000000000..1c47d07cca --- /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, lifecycle_status, 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, a.LifeCycleStatus, + 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, lifecycle_status, 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, lifecycle_status, 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, lifecycle_status, 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, lifecycle_status, 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 = ?, lifecycle_status = ?, configuration = ?, updated_by = ?, data_version = ?, updated_at = ? + WHERE uuid = ?` + result, err := tx.Exec(r.db.Rebind(query), + a.Name, a.Version, a.Description, a.LifeCycleStatus, 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, &a.LifeCycleStatus, &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, &a.LifeCycleStatus, &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..550e418daa --- /dev/null +++ b/platform-api/internal/repository/graphql_api_test.go @@ -0,0 +1,612 @@ +/* + * 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, + LifeCycleStatus: "CREATED", + 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 || created.LifeCycleStatus != api.LifeCycleStatus { + t.Fatalf("GetByUUID returned unexpected lifecycle details: %+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.LifeCycleStatus = "PUBLISHED" + 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 || updated.LifeCycleStatus != api.LifeCycleStatus { + t.Fatalf("Update changes not persisted: %+v", updated) + } + if updated.Configuration.SDL != api.Configuration.SDL || updated.Configuration.IntrospectionMode != api.Configuration.IntrospectionMode { + t.Fatalf("Update did not persist configuration changes: %+v", updated.Configuration) + } +} + +func TestGraphQLAPIRepo_Update_NotFound(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-update-404" + projectUUID := "project-graphql-update-404" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + ghost := newTestGraphQLAPI("does-not-exist", orgUUID, projectUUID) + err := repo.Update(ghost) + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("Update on a non-existent handle = %v, want sql.ErrNoRows", err) + } +} + +func TestGraphQLAPIRepo_Delete(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-delete-001" + projectUUID := "project-graphql-delete-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + api := newTestGraphQLAPI("delete-graphql", orgUUID, projectUUID) + if err := repo.Create(api); err != nil { + t.Fatalf("Create failed: %v", err) + } + + if err := repo.Delete(api.Handle, orgUUID); err != nil { + t.Fatalf("Delete failed: %v", err) + } + + deleted, err := repo.GetByUUID(api.ID, orgUUID) + if err != nil { + t.Fatalf("GetByUUID failed: %v", err) + } + if deleted != nil { + t.Fatalf("expected GraphQL API to be deleted, got: %+v", deleted) + } + + var count int + err = db.QueryRow("SELECT COUNT(*) FROM artifacts WHERE uuid = ?", api.ID).Scan(&count) + if err != nil && err != sql.ErrNoRows { + t.Fatalf("failed to verify artifact cleanup: %v", err) + } + if count != 0 { + t.Fatalf("expected artifact row to be removed, found %d", count) + } + + exists, err := repo.Exists(api.Handle, orgUUID) + if err != nil { + t.Fatalf("Exists failed: %v", err) + } + if exists { + t.Fatal("expected handle to no longer exist after delete") + } +} + +// TestGraphQLAPIRepo_Delete_CascadesRelatedRows is the real cascade test +// TestGraphQLAPIRepo_Delete couldn't be: that test never creates any +// deployment or gateway-association rows, so its own "0 rows remain" check +// is trivially true whether or not ON DELETE CASCADE actually fires. This +// test seeds a deployment and an artifact_gateway_mappings row first, so the +// post-delete zero-count genuinely exercises the FK chain +// (deployments/artifact_gateway_mappings -> artifacts(uuid) ON DELETE CASCADE) +// rather than asserting over an empty table. This is the first cascade-delete +// test in the repo for any artifact kind. +func TestGraphQLAPIRepo_Delete_CascadesRelatedRows(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-cascade-001" + projectUUID := "project-graphql-cascade-001" + gatewayUUID := "gateway-graphql-cascade-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + createTestGateway(t, db, gatewayUUID, orgUUID) + + api := newTestGraphQLAPI("cascade-graphql", orgUUID, projectUUID) + if err := repo.Create(api); err != nil { + t.Fatalf("Create failed: %v", err) + } + + insertDeployment(t, db, "deployment-graphql-cascade-001", "cascade-deployment", api.ID, orgUUID, gatewayUUID, time.Now()) + + mappingQuery := ` + INSERT INTO artifact_gateway_mappings (artifact_uuid, organization_uuid, gateway_uuid, created_at, updated_at) + VALUES (?, ?, ?, datetime('now'), datetime('now')) + ` + if _, err := db.Exec(mappingQuery, api.ID, orgUUID, gatewayUUID); err != nil { + t.Fatalf("failed to seed artifact_gateway_mappings: %v", err) + } + + if err := repo.Delete(api.Handle, orgUUID); err != nil { + t.Fatalf("Delete failed: %v", err) + } + + for _, tbl := range []string{"artifacts", "graphql_apis", "deployments", "artifact_gateway_mappings"} { + var count int + if err := db.QueryRow("SELECT COUNT(*) FROM "+tbl+" WHERE "+cascadeFKColumn(tbl)+" = ?", api.ID).Scan(&count); err != nil { + t.Fatalf("failed to verify %s cleanup: %v", tbl, err) + } + if count != 0 { + t.Errorf("expected all %s rows for this artifact to be gone after delete, found %d", tbl, count) + } + } +} + +// cascadeFKColumn returns the column each table keys its artifact reference +// by — "uuid" for the artifact's own primary-key tables, "artifact_uuid" for +// the generic child tables that reference it. +func cascadeFKColumn(table string) string { + if table == "artifacts" || table == "graphql_apis" { + return "uuid" + } + return "artifact_uuid" +} + +func TestGraphQLAPIRepo_Delete_NotFound(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-delete-404" + projectUUID := "project-graphql-delete-404" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + err := repo.Delete("does-not-exist", orgUUID) + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("Delete on a non-existent handle = %v, want sql.ErrNoRows", err) + } +} + +func TestGraphQLAPIRepo_Exists(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-exists-001" + projectUUID := "project-graphql-exists-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + api := newTestGraphQLAPI("exists-graphql", orgUUID, projectUUID) + if err := repo.Create(api); err != nil { + t.Fatalf("Create failed: %v", err) + } + + exists, err := repo.Exists(api.Handle, orgUUID) + if err != nil { + t.Fatalf("Exists failed: %v", err) + } + if !exists { + t.Fatal("expected handle to exist") + } + + exists, err = repo.Exists("unknown-handle", orgUUID) + if err != nil { + t.Fatalf("Exists for unknown handle failed: %v", err) + } + if exists { + t.Fatal("expected unknown handle to not exist") + } +} + +// TestGraphQLAPIRepo_CreateRecordsArtifactSecretRefs guards the {{ secret "..." }} +// reference-tracking path shared with REST (upsertArtifactSecretRefs) — a +// GraphQL upstream auth value referencing a secret must be recorded the same +// way a REST API's would be, so the secret's "in use" delete-protection sees it. +func TestGraphQLAPIRepo_CreateRecordsArtifactSecretRefs(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-secretref-001" + projectUUID := "project-graphql-secretref-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + api := newTestGraphQLAPI("secretref-graphql", orgUUID, projectUUID) + api.Configuration.Upstream.Main.Auth = &model.UpstreamAuth{ + Type: "header", + Header: "Authorization", + Value: `{{ secret "upstream-token" }}`, + } + if err := repo.Create(api); err != nil { + t.Fatalf("Create failed: %v", err) + } + + var refCount int + if err := db.QueryRow("SELECT COUNT(*) FROM artifact_secret_refs WHERE artifact_uuid = ? AND secret_handle = ?", api.ID, "upstream-token").Scan(&refCount); err != nil { + t.Fatalf("failed to count artifact_secret_refs: %v", err) + } + if refCount == 0 { + t.Fatal("expected an artifact_secret_refs row recording the {{ secret \"upstream-token\" }} reference") + } +} diff --git a/platform-api/internal/repository/interfaces.go b/platform-api/internal/repository/interfaces.go index f53a5ed278..9fb353b6d5 100644 --- a/platform-api/internal/repository/interfaces.go +++ b/platform-api/internal/repository/interfaces.go @@ -412,6 +412,39 @@ type MCPProxyRepository interface { EnsureGatewayAssociation(proxyUUID, gatewayUUID, orgUUID, createdBy, deployMetadata string, metadataProvided bool) (string, error) } +// GraphQLAPIRepository defines the interface for GraphQL API persistence. +// GraphQL is a core artifact kind (like RestApi/LlmProvider/LlmProxy/Mcp). No +// cross-service hooks are wired for it yet (unlike APIRepository, which +// plugin.Deps exposes for +// eventgateway to reference), so this interface is declared for the same +// service/repo decoupling and testability every other core kind gets, without +// also adding a plugin.Deps field until a real consumer needs one. +type GraphQLAPIRepository interface { + Create(a *model.GraphQLAPI) error + GetByHandle(handle, orgUUID string) (*model.GraphQLAPI, error) + GetByUUID(uuid, orgUUID string) (*model.GraphQLAPI, error) + List(orgUUID, projectUUID string, 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 adccf27aa9..e0487697ae 100644 --- a/platform-api/internal/server/scope_route_coverage_test.go +++ b/platform-api/internal/server/scope_route_coverage_test.go @@ -58,6 +58,9 @@ func registerAllRoutes(mux *http.ServeMux) { handler.NewAPIKeyUserHandler(nil, nil, "scope", logger).RegisterRoutes(mux) handler.NewMCPProxyHandler(nil, nil, logger).RegisterRoutes(mux) handler.NewMCPProxyDeploymentHandler(nil, nil, logger).RegisterRoutes(mux) + handler.NewGraphQLAPIHandler(nil, nil, logger).RegisterRoutes(mux) + handler.NewGraphQLAPIDeploymentHandler(nil, nil, logger).RegisterRoutes(mux) + handler.NewGraphQLAPIKeyHandler(nil, nil, "scope", logger).RegisterRoutes(mux) handler.NewSecretHandler(nil, nil, logger).RegisterRoutes(mux) // Plugin routes are registered on the same mux and their specs merged into diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index fa65546ab8..43d6941af8 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -139,6 +139,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, llmProviderRepo := repository.NewLLMProviderRepo(db) llmProxyRepo := repository.NewLLMProxyRepo(db) mcpProxyRepo := repository.NewMCPProxyRepo(db) + graphqlAPIRepo := repository.NewGraphQLAPIRepo(db, artifactTableRegistry) apiKeyRepo := repository.NewAPIKeyRepo(db, artifactTableRegistry) auditRepo := repository.NewAuditRepo(db) secretRepo := repository.NewSecretRepo(db) @@ -275,6 +276,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, llmProviderService.SetCustomPolicyRepository(customPolicyRepo) llmProxyService := service.NewLLMProxyService(llmProxyRepo, llmProviderRepo, projectRepo, deploymentRepo, gatewayRepo, gatewayEventsService, slogger, auditRepo, cfg, identityService) mcpProxyService := service.NewMCPProxyService(mcpProxyRepo, projectRepo, deploymentRepo, gatewayRepo, gatewayEventsService, slogger, auditRepo, cfg, identityService) + graphqlAPIService := service.NewGraphQLAPIService(graphqlAPIRepo, projectRepo, auditRepo, deploymentRepo, gatewayRepo, orgRepo, gatewayEventsService, identityService, slogger) // The single configured encryption key (APIP_CP_ENCRYPTION_KEY) is used for all encrypted DB // columns (secrets, subscription tokens, WebSub HMAC secrets) @@ -319,6 +321,16 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, cfg, slogger, ) + graphqlAPIDeploymentService := service.NewGraphQLAPIDeploymentService( + graphqlAPIRepo, + deploymentRepo, + gatewayRepo, + orgRepo, + apiKeyRepo, + gatewayEventsService, + cfg, + slogger, + ) // One place that knows which service serves which artifact kind, so plugins and // the per-kind paths reach the same code. deploymentsByKind := service.NewDeploymentsByKind( @@ -382,12 +394,17 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, llmProxyDeploymentHandler := handler.NewLLMProxyDeploymentHandler(llmProxyDeploymentService, identityService, slogger) mcpProxyHandler := handler.NewMCPProxyHandler(mcpProxyService, identityService, slogger) mcpProxyDeploymentHandler := handler.NewMCPProxyDeploymentHandler(mcpDeploymentService, identityService, slogger) + graphqlAPIHandler := handler.NewGraphQLAPIHandler(graphqlAPIService, identityService, slogger) + graphqlAPIKeyHandler := handler.NewGraphQLAPIKeyHandler(apiKeyService, identityService, cfg.Auth.Authorization.Mode, slogger) + graphqlAPIDeploymentHandler := handler.NewGraphQLAPIDeploymentHandler(graphqlAPIDeploymentService, identityService, slogger) // Wire secret placeholder validation into dependent services llmProviderService.SetSecretService(secretService) llmProviderDeploymentService.SetSecretService(secretService) llmProxyService.SetSecretService(secretService) mcpProxyService.WithSecretService(secretService) apiService.SetSecretService(secretService) + graphqlAPIService.SetSecretService(secretService) + graphqlAPIService.SetMaxSDLFetchBytes(cfg.OpenAPISpecMaxFetchBytes) secretHandler := handler.NewSecretHandler(secretService, identityService, slogger) // Start deployment timeout background job timeoutConfig := service.DeploymentTimeoutConfig{ @@ -444,6 +461,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 603e25c00a..c06b202a29 100644 --- a/platform-api/internal/service/deployment_test.go +++ b/platform-api/internal/service/deployment_test.go @@ -280,6 +280,7 @@ type mockDeploymentRepo struct { setCurrentStatus model.DeploymentStatus setCurrentPerformedAt *time.Time deleteCalled bool + createdDeployment *model.Deployment } func (m *mockDeploymentRepo) GetWithContent(deploymentID, artifactUUID, orgUUID string) (*model.Deployment, error) { @@ -344,6 +345,7 @@ func (m *mockDeploymentRepo) CreateFromBuildWithLimitEnforcement(deployment *mod } 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..89a4fe33a7 --- /dev/null +++ b/platform-api/internal/service/graphql_api.go @@ -0,0 +1,677 @@ +/* + * 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 +} + +// 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 + } + + lifeCycleStatus := "CREATED" + if req.LifeCycleStatus != nil && *req.LifeCycleStatus != "" { + lifeCycleStatus = string(*req.LifeCycleStatus) + } + + 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, + LifeCycleStatus: lifeCycleStatus, + 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) +} + +// 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 + } + + lifeCycleStatus := existing.LifeCycleStatus + if req.LifeCycleStatus != nil && *req.LifeCycleStatus != "" { + lifeCycleStatus = string(*req.LifeCycleStatus) + } + + 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.LifeCycleStatus = lifeCycleStatus + 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..c1849f6cae --- /dev/null +++ b/platform-api/internal/service/graphql_api_test.go @@ -0,0 +1,1416 @@ +/* + * 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) + } +} + +// 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", + LifeCycleStatus: "CREATED", + // 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..a3a31a6594 --- /dev/null +++ b/platform-api/internal/service/graphql_apikey_test.go @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +// GraphQL API keys reuse the existing generic APIKeyService (see +// internal/handler/graphql_apikey.go's doc comment for why a dedicated +// GraphQLAPIKeyService was NOT introduced) — these tests pin that the shared +// service works correctly end-to-end when called with constants.GraphQLApi, +// the same way the eventgateway plugin already calls it with +// constants.WebSubApi/constants.WebBrokerApi. + +import ( + "context" + "testing" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" +) + +// gqlKeyArtifactRepo is a minimal ArtifactRepository resolving one GraphQL API +// handle to a fixed UUID via GetAPIMetadataByHandleAndKind — the only method +// APIKeyService.CreateAPIKey/RevokeAPIKey actually call on it. The interface +// is embedded (mirroring guardStubArtifactRepo's approach in +// deployment_undeploy_guard_test.go) so every other method panics if +// accidentally invoked, rather than silently returning a zero value. +type gqlKeyArtifactRepo struct { + repository.ArtifactRepository + metadata *model.APIMetadata +} + +func (g *gqlKeyArtifactRepo) GetAPIMetadataByHandleAndKind(handle, kind, orgUUID string) (*model.APIMetadata, error) { + if handle == g.metadata.Handle && kind == constants.GraphQLApi { + return g.metadata, nil + } + return nil, nil +} + +// TestGraphQLAPIKey_CreateAndRevoke_Success exercises the shared APIKeyService +// with kind=constants.GraphQLApi end-to-end: create persists and broadcasts to +// every associated gateway, then revoke looks the key back up (ownership +// check passes since the same caller created it) and broadcasts a revocation. +func TestGraphQLAPIKey_CreateAndRevoke_Success(t *testing.T) { + apiUUID := "gql-uuid-1" + artifactRepo := &gqlKeyArtifactRepo{metadata: &model.APIMetadata{ID: apiUUID, Handle: "countries-graphql-api"}} + apiRepo := dpKeyAPIRepo{} // GetAPIGatewaysWithDetails returns one gateway — see artifact_dp_apikey_test.go + keyRepo := &dpCapturingAPIKeyRepo{} + events := NewGatewayEventsService(dpNoopEventHub{}, newTestIdentityService(), newTestLogger()) + + svc := NewAPIKeyService(apiRepo, artifactRepo, keyRepo, events, &noopAuditRepo{}, nil, newTestLogger()) + + plaintextKey := "test-plaintext-key" + createReq := &api.CreateAPIKeyRequest{ + ApiKey: &plaintextKey, + DisplayName: "My GraphQL Key", + } + if _, err := svc.CreateAPIKey(context.Background(), "countries-graphql-api", constants.GraphQLApi, "org-1", "creator-uuid", createReq); err != nil { + t.Fatalf("CreateAPIKey for GraphQL API = %v, want success", err) + } + if keyRepo.created == nil { + t.Fatal("expected the API key to be persisted") + } + if keyRepo.created.ArtifactUUID != apiUUID { + t.Errorf("persisted key ArtifactUUID = %q, want %q", keyRepo.created.ArtifactUUID, apiUUID) + } + keyName := keyRepo.created.Name + + if err := svc.RevokeAPIKey(context.Background(), "countries-graphql-api", constants.GraphQLApi, "org-1", keyName, "creator-uuid", false, false); err != nil { + t.Fatalf("RevokeAPIKey for GraphQL API = %v, want success", err) + } +} + +// TestGraphQLAPIKey_Revoke_NotCreator_Forbidden verifies the shared ownership +// predicate (canManageAPIKey) is enforced for GraphQL API keys exactly as it +// is for REST/WebSub/WebBroker: a caller who isn't the key's creator, and +// doesn't hold ap:api_key:all:manage, is denied. +func TestGraphQLAPIKey_Revoke_NotCreator_Forbidden(t *testing.T) { + apiUUID := "gql-uuid-1" + artifactRepo := &gqlKeyArtifactRepo{metadata: &model.APIMetadata{ID: apiUUID, Handle: "countries-graphql-api"}} + apiRepo := dpKeyAPIRepo{} + keyRepo := &dpCapturingAPIKeyRepo{} + events := NewGatewayEventsService(dpNoopEventHub{}, newTestIdentityService(), newTestLogger()) + + svc := NewAPIKeyService(apiRepo, artifactRepo, keyRepo, events, &noopAuditRepo{}, nil, newTestLogger()) + + plaintextKey := "test-plaintext-key" + createReq := &api.CreateAPIKeyRequest{ApiKey: &plaintextKey, DisplayName: "My GraphQL Key"} + if _, err := svc.CreateAPIKey(context.Background(), "countries-graphql-api", constants.GraphQLApi, "org-1", "creator-uuid", createReq); err != nil { + t.Fatalf("CreateAPIKey for GraphQL API = %v, want success", err) + } + keyName := keyRepo.created.Name + + err := svc.RevokeAPIKey(context.Background(), "countries-graphql-api", constants.GraphQLApi, "org-1", keyName, "someone-else", false, false) + if err == nil { + t.Fatal("expected an error revoking another user's GraphQL API key without ap:api_key:all:manage") + } + if code := graphQLCatalogCode(t, err); code != "REST_API_API_KEY_FORBIDDEN" { + t.Errorf("expected REST_API_API_KEY_FORBIDDEN (the shared ownership-forbidden code every kind currently returns), got %s", code) + } +} diff --git a/platform-api/internal/service/graphql_deployment.go b/platform-api/internal/service/graphql_deployment.go new file mode 100644 index 0000000000..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..7d6d80dc2f --- /dev/null +++ b/platform-api/internal/service/graphql_introspection.go @@ -0,0 +1,420 @@ +/* + * 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() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 5<<20)) // 5 MiB ceiling on the introspection response + if err != nil { + return "", fmt.Errorf("failed to read introspection response: %w", err) + } + 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..322982ab8a --- /dev/null +++ b/platform-api/internal/service/graphql_mapping.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 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 + lifeCycleStatus := api.RESTAPILifeCycleStatus(m.LifeCycleStatus) + 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 := mapUpstreamModelToAPI(&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, + LifeCycleStatus: &lifeCycleStatus, + 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), + } +} + +// 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 + } + + lifeCycleStatus := api.RESTAPILifeCycleStatus(m.LifeCycleStatus) + + var introspectionMode *api.GraphQLIntrospectionMode + if m.Configuration.IntrospectionMode != "" { + im := api.GraphQLIntrospectionMode(m.Configuration.IntrospectionMode) + introspectionMode = &im + } + + upstream := mapUpstreamModelToAPI(&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, + LifeCycleStatus: &lifeCycleStatus, + 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/utils/graphql_multipart.go b/platform-api/internal/utils/graphql_multipart.go new file mode 100644 index 0000000000..28900916a8 --- /dev/null +++ b/platform-api/internal/utils/graphql_multipart.go @@ -0,0 +1,94 @@ +/* + * 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 ( + "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 + + // 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 file part is read entirely in memory through a size-limited reader — +// never written to a temp file — and bounded independently of the reported +// Content-Length, per file-access.md directives 3/5. +func ParseGraphQLAPIMultipartRequest(r *http.Request) (metadataJSON []byte, sdl string, err error) { + if err := r.ParseMultipartForm(maxGraphQLSDLUploadBytes); err != nil { + return nil, "", fmt.Errorf("failed to parse multipart form: %w", err) + } + + 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 { + // 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/role-to-scope-mapping.yaml b/platform-api/resources/role-to-scope-mapping.yaml index d8616d8977..0b23d0c251 100644 --- a/platform-api/resources/role-to-scope-mapping.yaml +++ b/platform-api/resources/role-to-scope-mapping.yaml @@ -86,6 +86,7 @@ roles: - ap:api_publication:read # - ap:websub_api:manage # event-gateway build only # - ap:webbroker_api:manage # event-gateway build only + - ap:graphql_api:manage # API Portal & MCP Hub - dp:organization:manage - dp:organization_content:manage @@ -141,6 +142,8 @@ roles: # - ap:websub_api:deployment:read # event-gateway build only # - ap:webbroker_api:read # event-gateway build only # - ap:webbroker_api:deployment:read # event-gateway build only + - ap:graphql_api:read + - ap:graphql_api:deployment:manage # API Portal & MCP Hub - dp:key_manager:manage - dp:key_manager:read @@ -191,6 +194,7 @@ roles: - ap:api_publication:read # - ap:websub_api:manage # event-gateway build only # - ap:webbroker_api:manage # event-gateway build only + - ap:graphql_api:manage # API Portal & MCP Hub - dp:api:manage - dp:api_content:manage @@ -277,6 +281,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 From 405507a7163fb98bad7f4385949d3304773837eb Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Fri, 28 Aug 2026 13:52:49 +0530 Subject: [PATCH 07/38] Remove GraphQL lifecycleStatus and harden GraphQL API support --- .../cmd/gateway/graphqlapi/apikey/update.go | 13 +++- cli/src/cmd/gateway/graphqlapi/list.go | 4 ++ gateway/examples/blog-graphql-api.yaml | 12 +++- .../api/management-openapi.yaml | 9 ++- .../pkg/api/handlers/graphql_api_handler.go | 65 ++++++++++++----- .../pkg/utils/graphql_deployment.go | 4 +- .../tests/integration/schema_test.go | 2 +- gateway/it/docker-compose.test.yaml | 22 ++++++ gateway/it/features/graphql_deploy.feature | 2 +- gateway/it/steps_graphql.go | 7 +- gateway/it/suite_test.go | 2 + platform-api/api/generated.go | 24 +------ .../internal/database/schema.postgres.sql | 2 - platform-api/internal/database/schema.sql | 2 - .../internal/database/schema.sqlite.sql | 2 - .../internal/database/schema.sqlserver.sql | 3 - platform-api/internal/model/graphql_api.go | 1 - .../internal/repository/graphql_api.go | 22 +++--- .../internal/repository/graphql_api_test.go | 24 +++---- platform-api/internal/service/graphql_api.go | 12 ---- .../internal/service/graphql_api_test.go | 11 ++- .../internal/service/graphql_introspection.go | 10 ++- .../internal/service/graphql_mapping.go | 5 -- .../internal/utils/graphql_multipart.go | 25 ++++++- platform-api/resources/openapi.yaml | 30 ++------ .../resources/role-to-scope-mapping.yaml | 1 + .../mock-graphql-backend/Dockerfile | 38 ++++++++++ .../mock-servers/mock-graphql-backend/go.mod | 3 + .../mock-servers/mock-graphql-backend/main.go | 71 +++++++++++++++++++ 29 files changed, 289 insertions(+), 139 deletions(-) create mode 100644 tests/mock-servers/mock-graphql-backend/Dockerfile create mode 100644 tests/mock-servers/mock-graphql-backend/go.mod create mode 100644 tests/mock-servers/mock-graphql-backend/main.go diff --git a/cli/src/cmd/gateway/graphqlapi/apikey/update.go b/cli/src/cmd/gateway/graphqlapi/apikey/update.go index 4d67f330eb..a3e4d32c0b 100644 --- a/cli/src/cmd/gateway/graphqlapi/apikey/update.go +++ b/cli/src/cmd/gateway/graphqlapi/apikey/update.go @@ -60,10 +60,9 @@ 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 (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) - updateCmd.MarkFlagRequired(utils.FlagAPIKey) } func runUpdateCommand(cmd *cobra.Command) error { @@ -73,6 +72,16 @@ func runUpdateCommand(cmd *cobra.Command) error { 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) } diff --git a/cli/src/cmd/gateway/graphqlapi/list.go b/cli/src/cmd/gateway/graphqlapi/list.go index bec333c1be..766d2185b5 100644 --- a/cli/src/cmd/gateway/graphqlapi/list.go +++ b/cli/src/cmd/gateway/graphqlapi/list.go @@ -147,6 +147,10 @@ func runListCommand(cmd *cobra.Command) error { 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 { diff --git a/gateway/examples/blog-graphql-api.yaml b/gateway/examples/blog-graphql-api.yaml index f5b3240cbd..0659e0f843 100644 --- a/gateway/examples/blog-graphql-api.yaml +++ b/gateway/examples/blog-graphql-api.yaml @@ -1,11 +1,19 @@ # -------------------------------------------------------------------- # Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). # -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. +# 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. diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index 9b97a1c8df..1042876298 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -4038,8 +4038,8 @@ components: example: Countries GraphQL API version: type: string - description: Semantic version of the API - pattern: '^v\d+\.\d+$' + 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 @@ -4059,7 +4059,10 @@ components: 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. + 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" diff --git a/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go b/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go index 78b7b2c0ad..a70f0d5bd7 100644 --- a/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go +++ b/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go @@ -85,9 +85,9 @@ func (s *APIServer) CreateGraphQLAPI(w http.ResponseWriter, r *http.Request) { }) return } - httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{ + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{ Status: "error", - Message: err.Error(), + Message: "Failed to create configuration", }) return } @@ -148,10 +148,18 @@ func (s *APIServer) GetGraphQLAPIById(w http.ResponseWriter, r *http.Request, id cfg, err := s.db.GetConfigByKindAndHandle(string(api.GraphQLAPIKindGraphQLApi), id) if err != nil { - log.Warn("GraphQL API configuration not found", slog.String("handle", id)) - httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{ + 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: fmt.Sprintf("GraphQLApi with handle '%s' not found", id), + Message: "Failed to retrieve configuration", }) return } @@ -176,10 +184,18 @@ func (s *APIServer) UpdateGraphQLAPI(w http.ResponseWriter, r *http.Request, id existing, err := s.db.GetConfigByKindAndHandle(string(api.GraphQLAPIKindGraphQLApi), id) if err != nil { - log.Warn("GraphQL API configuration not found", slog.String("handle", id)) - httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{ + 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: fmt.Sprintf("GraphQLApi with handle '%s' not found", id), + Message: "Failed to retrieve configuration", }) return } @@ -236,9 +252,9 @@ func (s *APIServer) UpdateGraphQLAPI(w http.ResponseWriter, r *http.Request, id }) return } - httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{ + httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{ Status: "error", - Message: err.Error(), + Message: "Failed to update configuration", }) return } @@ -255,10 +271,18 @@ func (s *APIServer) DeleteGraphQLAPI(w http.ResponseWriter, r *http.Request, id cfg, err := s.db.GetConfigByKindAndHandle(string(api.GraphQLAPIKindGraphQLApi), id) if err != nil { - log.Warn("GraphQL API configuration not found", slog.String("handle", id)) - httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{ + 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: fmt.Sprintf("GraphQLApi with handle '%s' not found", id), + Message: "Failed to retrieve configuration", }) return } @@ -366,7 +390,8 @@ func (s *APIServer) CreateGraphQLAPIKey(w http.ResponseWriter, r *http.Request, } else if storage.IsConflictError(err) || strings.Contains(err.Error(), "already exists") { httputil.WriteJSON(w, http.StatusConflict, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { - httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: err.Error()}) + 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 } @@ -400,7 +425,8 @@ func (s *APIServer) RevokeGraphQLAPIKey(w http.ResponseWriter, r *http.Request, if strings.Contains(err.Error(), "not found") { httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { - httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: err.Error()}) + 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 } @@ -450,7 +476,8 @@ func (s *APIServer) UpdateGraphQLAPIKey(w http.ResponseWriter, r *http.Request, } else if storage.IsConflictError(err) || strings.Contains(err.Error(), "already exists") { httputil.WriteJSON(w, http.StatusConflict, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { - httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: err.Error()}) + 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 } @@ -491,7 +518,8 @@ func (s *APIServer) RegenerateGraphQLAPIKey(w http.ResponseWriter, r *http.Reque if strings.Contains(err.Error(), "not found") { httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { - httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: err.Error()}) + 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 } @@ -524,7 +552,8 @@ func (s *APIServer) ListGraphQLAPIKeys(w http.ResponseWriter, r *http.Request, i if strings.Contains(err.Error(), "not found") { httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { - httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: err.Error()}) + 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 } diff --git a/gateway/gateway-controller/pkg/utils/graphql_deployment.go b/gateway/gateway-controller/pkg/utils/graphql_deployment.go index 0a033eb9ce..c5a100c1da 100644 --- a/gateway/gateway-controller/pkg/utils/graphql_deployment.go +++ b/gateway/gateway-controller/pkg/utils/graphql_deployment.go @@ -40,7 +40,7 @@ func init() { RegisterKindConfigValidator(graphQLApiKind, validateGraphQLAPIConfig) } -const graphQLApiKind = "GraphQLApi" +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 @@ -95,6 +95,8 @@ func validateGraphQLAPIConfig(cfg any) (apiName, apiVersion string, validationEr 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)...) diff --git a/gateway/gateway-controller/tests/integration/schema_test.go b/gateway/gateway-controller/tests/integration/schema_test.go index 75122b4a01..c3908ec985 100644 --- a/gateway/gateway-controller/tests/integration/schema_test.go +++ b/gateway/gateway-controller/tests/integration/schema_test.go @@ -163,7 +163,7 @@ func TestSchemaInitialization(t *testing.T) { // Verify per-resource-type tables exist t.Run("ResourceTypeTablesExist", func(t *testing.T) { - tables := []string{"rest_apis", "llm_providers", "llm_proxies", "mcp_proxies", "agents"} + tables := []string{"rest_apis", "llm_providers", "llm_proxies", "mcp_proxies", "agents", "graphql_apis"} for _, table := range tables { var tableName string err := rawDB.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&tableName) diff --git a/gateway/it/docker-compose.test.yaml b/gateway/it/docker-compose.test.yaml index 0bfd3096a6..3bf9182027 100644 --- a/gateway/it/docker-compose.test.yaml +++ b/gateway/it/docker-compose.test.yaml @@ -120,6 +120,8 @@ services: # condition: service_healthy mock-openapi: condition: service_healthy + mock-graphql-backend: + condition: service_healthy healthcheck: test: ["CMD", "health-check.sh"] interval: 5s @@ -329,6 +331,26 @@ services: networks: - it-gateway-runtime-network + # Mock GraphQL Backend: echoes the request body back verbatim, so a test can produce + # an arbitrary upstream response shape (e.g. a GraphQL {"errors":[...]} body) that the + # generic sample-service fixture's fixed envelope can never produce. + mock-graphql-backend: + container_name: it-mock-graphql-backend + image: ghcr.io/wso2/api-platform/mock-graphql-backend:latest + build: + context: ../../tests/mock-servers/mock-graphql-backend + dockerfile: Dockerfile + ports: + - "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_deploy.feature b/gateway/it/features/graphql_deploy.feature index ce658e41d9..b752f7ea9e 100644 --- a/gateway/it/features/graphql_deploy.feature +++ b/gateway/it/features/graphql_deploy.feature @@ -215,7 +215,7 @@ Feature: Test GraphQL API CRUD and connectivity (gateway-only path) Scenario: List GraphQL APIs when none exist Given I authenticate using basic auth as "admin" - When I list all GraphQL APIs + 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" diff --git a/gateway/it/steps_graphql.go b/gateway/it/steps_graphql.go index cbc9a2c0d0..0f01a8ef2a 100644 --- a/gateway/it/steps_graphql.go +++ b/gateway/it/steps_graphql.go @@ -20,6 +20,7 @@ package it import ( "fmt" + "net/url" "time" "github.com/cucumber/godog" @@ -43,7 +44,7 @@ func RegisterGraphQLSteps(ctx *godog.ScenarioContext, state *TestState, httpStep } deleteGraphQLAPI := func(name string) error { - err := httpSteps.SendDELETEToService("gateway-controller", "/graphql-apis/"+name) + err := httpSteps.SendDELETEToService("gateway-controller", "/graphql-apis/"+url.PathEscape(name)) if err != nil { return err } @@ -58,12 +59,12 @@ func RegisterGraphQLSteps(ctx *godog.ScenarioContext, state *TestState, httpStep }) ctx.Step(`^I get the GraphQL API "([^"]*)"$`, func(name string) error { - return httpSteps.SendGETToService("gateway-controller", "/graphql-apis/"+name) + 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/"+name, body) + err := httpSteps.SendPUTToService("gateway-controller", "/graphql-apis/"+url.PathEscape(name), body) if err != nil { return err } diff --git a/gateway/it/suite_test.go b/gateway/it/suite_test.go index aa88572c9e..8fbed1f9aa 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -135,6 +135,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", diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index d227e030fd..049f75d17f 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -993,12 +993,6 @@ type CreateGraphQLAPIRequest struct { // Kind Kind of the API based on its communication protocol or architectural style Kind *string `json:"kind,omitempty" yaml:"kind,omitempty"` - // LifeCycleStatus Current lifecycle status of the API. Reuses REST APIs' lifecycle enum - // unmodified (STAGED, CREATED, PUBLISHED, DEPRECATED, RETIRED, BLOCKED) — - // declaring a second identically-valued enum schema here would collide - // with it at Go-constant generation time. - LifeCycleStatus *RESTAPILifeCycleStatus `json:"lifeCycleStatus,omitempty" yaml:"lifeCycleStatus,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"` @@ -1575,12 +1569,6 @@ type GraphQLAPI struct { // Kind Kind of the API based on its communication protocol or architectural style Kind *string `json:"kind,omitempty" yaml:"kind,omitempty"` - // LifeCycleStatus Current lifecycle status of the API. Reuses REST APIs' lifecycle enum - // unmodified (STAGED, CREATED, PUBLISHED, DEPRECATED, RETIRED, BLOCKED) — - // declaring a second identically-valued enum schema here would collide - // with it at Go-constant generation time. - LifeCycleStatus *RESTAPILifeCycleStatus `json:"lifeCycleStatus,omitempty" yaml:"lifeCycleStatus,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"` @@ -1633,15 +1621,9 @@ type GraphQLAPIListItem struct { Id *string `json:"id,omitempty" yaml:"id,omitempty"` IntrospectionMode *GraphQLIntrospectionMode `json:"introspectionMode,omitempty" yaml:"introspectionMode,omitempty"` Kind *string `json:"kind,omitempty" yaml:"kind,omitempty"` - - // LifeCycleStatus Current lifecycle status of the API. Reuses REST APIs' lifecycle enum - // unmodified (STAGED, CREATED, PUBLISHED, DEPRECATED, RETIRED, BLOCKED) — - // declaring a second identically-valued enum schema here would collide - // with it at Go-constant generation time. - LifeCycleStatus *RESTAPILifeCycleStatus `json:"lifeCycleStatus,omitempty" yaml:"lifeCycleStatus,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"` + 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"` diff --git a/platform-api/internal/database/schema.postgres.sql b/platform-api/internal/database/schema.postgres.sql index 6cb71c1d23..dee0f053d1 100644 --- a/platform-api/internal/database/schema.postgres.sql +++ b/platform-api/internal/database/schema.postgres.sql @@ -606,7 +606,6 @@ CREATE TABLE IF NOT EXISTS graphql_apis ( version VARCHAR(30) NOT NULL DEFAULT 'v1.0', project_uuid VARCHAR(40) NOT NULL, description VARCHAR(1023), - lifecycle_status VARCHAR(20) NOT NULL DEFAULT 'CREATED', configuration BYTEA NOT NULL, data_version VARCHAR(20) NOT NULL DEFAULT '1.0', origin VARCHAR(20) NOT NULL DEFAULT 'control_plane', @@ -696,7 +695,6 @@ CREATE INDEX IF NOT EXISTS idx_mcp_proxies_org ON mcp_proxies(organization_uuid) CREATE INDEX IF NOT EXISTS idx_api_portals_org ON api_portals(organization_uuid); CREATE INDEX IF NOT EXISTS idx_graphql_apis_project ON graphql_apis(project_uuid); CREATE INDEX IF NOT EXISTS idx_graphql_apis_org ON graphql_apis(organization_uuid); -CREATE INDEX IF NOT EXISTS idx_graphql_apis_lifecycle_status ON graphql_apis(lifecycle_status); 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 e8f8ed6764..55a3c64066 100644 --- a/platform-api/internal/database/schema.sql +++ b/platform-api/internal/database/schema.sql @@ -419,7 +419,6 @@ CREATE TABLE IF NOT EXISTS graphql_apis ( version VARCHAR(30) NOT NULL DEFAULT 'v1.0', project_uuid VARCHAR(40) NOT NULL, description VARCHAR(1023), - lifecycle_status VARCHAR(20) NOT NULL DEFAULT 'CREATED', 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', @@ -511,7 +510,6 @@ 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_graphql_apis_lifecycle_status ON graphql_apis(lifecycle_status); 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 e6469f2e72..87e679a9d4 100644 --- a/platform-api/internal/database/schema.sqlite.sql +++ b/platform-api/internal/database/schema.sqlite.sql @@ -608,7 +608,6 @@ CREATE TABLE IF NOT EXISTS graphql_apis ( version VARCHAR(30) NOT NULL DEFAULT 'v1.0', project_uuid VARCHAR(40) NOT NULL, description VARCHAR(1023), - lifecycle_status VARCHAR(20) NOT NULL DEFAULT 'CREATED', 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', @@ -698,7 +697,6 @@ CREATE INDEX IF NOT EXISTS idx_mcp_proxies_org ON mcp_proxies(organization_uuid) CREATE INDEX IF NOT EXISTS idx_api_portals_org ON api_portals(organization_uuid); CREATE INDEX IF NOT EXISTS idx_graphql_apis_project ON graphql_apis(project_uuid); CREATE INDEX IF NOT EXISTS idx_graphql_apis_org ON graphql_apis(organization_uuid); -CREATE INDEX IF NOT EXISTS idx_graphql_apis_lifecycle_status ON graphql_apis(lifecycle_status); 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 b24738c86b..da273e1da8 100644 --- a/platform-api/internal/database/schema.sqlserver.sql +++ b/platform-api/internal/database/schema.sqlserver.sql @@ -693,7 +693,6 @@ CREATE TABLE dbo.graphql_apis ( version VARCHAR(30) NOT NULL DEFAULT 'v1.0', project_uuid VARCHAR(40) NOT NULL, description VARCHAR(1023), - lifecycle_status VARCHAR(20) NOT NULL DEFAULT 'CREATED', configuration VARBINARY(MAX) NOT NULL, data_version VARCHAR(20) NOT NULL DEFAULT '1.0', origin VARCHAR(20) NOT NULL DEFAULT 'control_plane', @@ -818,8 +817,6 @@ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_graphql_apis_project 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_graphql_apis_lifecycle_status' AND object_id = OBJECT_ID(N'dbo.graphql_apis')) -CREATE INDEX idx_graphql_apis_lifecycle_status ON dbo.graphql_apis(lifecycle_status); 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/model/graphql_api.go b/platform-api/internal/model/graphql_api.go index fcc13507f5..54c075218c 100644 --- a/platform-api/internal/model/graphql_api.go +++ b/platform-api/internal/model/graphql_api.go @@ -37,7 +37,6 @@ type GraphQLAPI struct { 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"` - LifeCycleStatus string `json:"lifeCycleStatus,omitempty" db:"lifecycle_status"` Configuration GraphQLAPIConfig `json:"configuration" db:"-"` Origin string `json:"origin,omitempty" db:"origin"` DataVersion string `json:"dataVersion,omitempty" db:"data_version"` diff --git a/platform-api/internal/repository/graphql_api.go b/platform-api/internal/repository/graphql_api.go index 1c47d07cca..3c2bc79d91 100644 --- a/platform-api/internal/repository/graphql_api.go +++ b/platform-api/internal/repository/graphql_api.go @@ -86,11 +86,11 @@ func (r *GraphQLAPIRepo) Create(a *model.GraphQLAPI) error { query := ` INSERT INTO graphql_apis ( - uuid, organization_uuid, handle, display_name, version, project_uuid, description, created_by, updated_by, lifecycle_status, configuration, origin, data_version, created_at, updated_at + uuid, organization_uuid, handle, display_name, version, project_uuid, description, created_by, updated_by, configuration, origin, data_version, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + 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, a.LifeCycleStatus, + 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 { @@ -109,7 +109,7 @@ func (r *GraphQLAPIRepo) GetByHandle(handle, orgUUID string) (*model.GraphQLAPI, query := ` SELECT uuid, handle, display_name, version, organization_uuid, origin, created_at, updated_at, - project_uuid, description, created_by, updated_by, lifecycle_status, configuration, data_version + 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) @@ -121,7 +121,7 @@ func (r *GraphQLAPIRepo) GetByUUID(uuid, orgUUID string) (*model.GraphQLAPI, err query := ` SELECT uuid, handle, display_name, version, organization_uuid, origin, created_at, updated_at, - project_uuid, description, created_by, updated_by, lifecycle_status, configuration, data_version + 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) @@ -138,7 +138,7 @@ func (r *GraphQLAPIRepo) List(orgUUID, projectUUID string, limit, offset int) ([ query = ` SELECT uuid, handle, display_name, version, organization_uuid, origin, created_at, updated_at, - project_uuid, description, created_by, updated_by, lifecycle_status, configuration, data_version + project_uuid, description, created_by, updated_by, configuration, data_version FROM graphql_apis WHERE organization_uuid = ? AND project_uuid = ? ORDER BY created_at DESC @@ -148,7 +148,7 @@ func (r *GraphQLAPIRepo) List(orgUUID, projectUUID string, limit, offset int) ([ query = ` SELECT uuid, handle, display_name, version, organization_uuid, origin, created_at, updated_at, - project_uuid, description, created_by, updated_by, lifecycle_status, configuration, data_version + project_uuid, description, created_by, updated_by, configuration, data_version FROM graphql_apis WHERE organization_uuid = ? ORDER BY created_at DESC @@ -224,10 +224,10 @@ func (r *GraphQLAPIRepo) Update(a *model.GraphQLAPI) error { query = ` UPDATE graphql_apis - SET display_name = ?, version = ?, description = ?, lifecycle_status = ?, configuration = ?, updated_by = ?, data_version = ?, updated_at = ? + 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, a.LifeCycleStatus, configurationJSON, a.UpdatedBy, a.DataVersion, now, + a.Name, a.Version, a.Description, configurationJSON, a.UpdatedBy, a.DataVersion, now, apiUUID, ) if err != nil { @@ -292,7 +292,7 @@ func (r *GraphQLAPIRepo) scanGraphQLAPI(row *sql.Row) (*model.GraphQLAPI, error) 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, &a.LifeCycleStatus, &configurationJSON, &a.DataVersion, + &a.ProjectID, &a.Description, &createdBy, &updatedBy, &configurationJSON, &a.DataVersion, ); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -319,7 +319,7 @@ func (r *GraphQLAPIRepo) scanGraphQLAPIFromRows(rows *sql.Rows) (*model.GraphQLA 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, &a.LifeCycleStatus, &configurationJSON, &a.DataVersion, + &a.ProjectID, &a.Description, &createdBy, &updatedBy, &configurationJSON, &a.DataVersion, ); err != nil { return nil, err } diff --git a/platform-api/internal/repository/graphql_api_test.go b/platform-api/internal/repository/graphql_api_test.go index 550e418daa..4160d0002a 100644 --- a/platform-api/internal/repository/graphql_api_test.go +++ b/platform-api/internal/repository/graphql_api_test.go @@ -39,15 +39,14 @@ import ( 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, - LifeCycleStatus: "CREATED", + 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", @@ -100,8 +99,8 @@ func TestGraphQLAPIRepo_CreateAndRead(t *testing.T) { 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 || created.LifeCycleStatus != api.LifeCycleStatus { - t.Fatalf("GetByUUID returned unexpected lifecycle 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") @@ -384,7 +383,6 @@ func TestGraphQLAPIRepo_Update(t *testing.T) { api.Name = "Updated Countries API" api.Description = "Updated description" - api.LifeCycleStatus = "PUBLISHED" api.Configuration.SDL = "type Query { countries: [Country] country(code: ID!): Country }\ntype Country { code: String }" api.Configuration.IntrospectionMode = "ENDPOINT" @@ -399,7 +397,7 @@ func TestGraphQLAPIRepo_Update(t *testing.T) { if updated == nil { t.Fatal("GetByUUID returned nil") } - if updated.Name != api.Name || updated.Description != api.Description || updated.LifeCycleStatus != api.LifeCycleStatus { + 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 { diff --git a/platform-api/internal/service/graphql_api.go b/platform-api/internal/service/graphql_api.go index 89a4fe33a7..eb72839851 100644 --- a/platform-api/internal/service/graphql_api.go +++ b/platform-api/internal/service/graphql_api.go @@ -192,11 +192,6 @@ func (s *GraphQLAPIService) Create(orgUUID, createdBy string, req *api.CreateGra return nil, err } - lifeCycleStatus := "CREATED" - if req.LifeCycleStatus != nil && *req.LifeCycleStatus != "" { - lifeCycleStatus = string(*req.LifeCycleStatus) - } - var subscriptionPlans []string if req.SubscriptionPlans != nil { subscriptionPlans = *req.SubscriptionPlans @@ -212,7 +207,6 @@ func (s *GraphQLAPIService) Create(orgUUID, createdBy string, req *api.CreateGra CreatedBy: createdBy, UpdatedBy: createdBy, Version: req.Version, - LifeCycleStatus: lifeCycleStatus, Configuration: model.GraphQLAPIConfig{ Name: req.DisplayName, Version: req.Version, @@ -450,11 +444,6 @@ func (s *GraphQLAPIService) Update(orgUUID, handle, updatedBy string, req *api.G return nil, err } - lifeCycleStatus := existing.LifeCycleStatus - if req.LifeCycleStatus != nil && *req.LifeCycleStatus != "" { - lifeCycleStatus = string(*req.LifeCycleStatus) - } - var subscriptionPlans []string if req.SubscriptionPlans != nil { subscriptionPlans = *req.SubscriptionPlans @@ -465,7 +454,6 @@ func (s *GraphQLAPIService) Update(orgUUID, handle, updatedBy string, req *api.G existing.Version = req.Version existing.Description = utils.ValueOrEmpty(req.Description) existing.UpdatedBy = updatedBy - existing.LifeCycleStatus = lifeCycleStatus existing.Configuration = model.GraphQLAPIConfig{ Name: req.DisplayName, Version: req.Version, diff --git a/platform-api/internal/service/graphql_api_test.go b/platform-api/internal/service/graphql_api_test.go index c1849f6cae..e6775029b8 100644 --- a/platform-api/internal/service/graphql_api_test.go +++ b/platform-api/internal/service/graphql_api_test.go @@ -845,12 +845,11 @@ func TestGraphQLCreate_ProjectRefNotFound_CrossOrgProject(t *testing.T) { // 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", - LifeCycleStatus: "CREATED", + 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"}, diff --git a/platform-api/internal/service/graphql_introspection.go b/platform-api/internal/service/graphql_introspection.go index 7d6d80dc2f..26f87fd417 100644 --- a/platform-api/internal/service/graphql_introspection.go +++ b/platform-api/internal/service/graphql_introspection.go @@ -229,10 +229,18 @@ func fetchAndConvertGraphQLSchema(upstreamURL string) (string, error) { } defer resp.Body.Close() - respBody, err := io.ReadAll(io.LimitReader(resp.Body, 5<<20)) // 5 MiB ceiling on the introspection response + 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) } diff --git a/platform-api/internal/service/graphql_mapping.go b/platform-api/internal/service/graphql_mapping.go index 322982ab8a..749868b40b 100644 --- a/platform-api/internal/service/graphql_mapping.go +++ b/platform-api/internal/service/graphql_mapping.go @@ -39,7 +39,6 @@ func mapGraphQLAPIModelToAPI(m *model.GraphQLAPI) *api.GraphQLAPI { desc := m.Description createdBy := m.CreatedBy kind := constants.GraphQLApi - lifeCycleStatus := api.RESTAPILifeCycleStatus(m.LifeCycleStatus) sdl := m.Configuration.SDL var introspectionMode *api.GraphQLIntrospectionMode @@ -64,7 +63,6 @@ func mapGraphQLAPIModelToAPI(m *model.GraphQLAPI) *api.GraphQLAPI { Description: &desc, CreatedBy: &createdBy, Kind: &kind, - LifeCycleStatus: &lifeCycleStatus, Sdl: &sdl, IntrospectionMode: introspectionMode, Upstream: upstream, @@ -85,8 +83,6 @@ func mapGraphQLAPIModelToListItem(m *model.GraphQLAPI) *api.GraphQLAPIListItem { return nil } - lifeCycleStatus := api.RESTAPILifeCycleStatus(m.LifeCycleStatus) - var introspectionMode *api.GraphQLIntrospectionMode if m.Configuration.IntrospectionMode != "" { im := api.GraphQLIntrospectionMode(m.Configuration.IntrospectionMode) @@ -104,7 +100,6 @@ func mapGraphQLAPIModelToListItem(m *model.GraphQLAPI) *api.GraphQLAPIListItem { Description: utils.StringPtrIfNotEmpty(m.Description), IntrospectionMode: introspectionMode, Upstream: &upstream, - LifeCycleStatus: &lifeCycleStatus, ReadOnly: utils.BoolPtr(m.Origin == constants.OriginDP), CreatedBy: utils.StringPtrIfNotEmpty(m.CreatedBy), CreatedAt: utils.TimePtr(m.CreatedAt), diff --git a/platform-api/internal/utils/graphql_multipart.go b/platform-api/internal/utils/graphql_multipart.go index 28900916a8..fe3e493711 100644 --- a/platform-api/internal/utils/graphql_multipart.go +++ b/platform-api/internal/utils/graphql_multipart.go @@ -18,6 +18,7 @@ package utils import ( + "errors" "fmt" "io" "net/http" @@ -31,6 +32,13 @@ const ( // 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). @@ -44,13 +52,21 @@ const ( // 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 file part is read entirely in memory through a size-limited reader — -// never written to a temp file — and bounded independently of the reported -// Content-Length, per file-access.md directives 3/5. +// 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) == "" { @@ -59,6 +75,9 @@ func ParseGraphQLAPIMultipartRequest(r *http.Request) (metadataJSON []byte, sdl 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. diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index d1a62970d0..87b469c5d5 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -8879,15 +8879,6 @@ components: 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). - lifeCycleStatus: - type: string - x-go-type: RESTAPILifeCycleStatus - description: | - Current lifecycle status of the API. Reuses REST APIs' lifecycle enum - unmodified (STAGED, CREATED, PUBLISHED, DEPRECATED, RETIRED, BLOCKED) — - declaring a second identically-valued enum schema here would collide - with it at Go-constant generation time. - example: "CREATED" kind: type: string description: Kind of the API based on its communication protocol or architectural style @@ -8974,8 +8965,10 @@ components: type: string description: | JSON-encoded request body — CreateGraphQLAPIRequest fields for create, - GraphQLAPI fields for update. Any `sdl`/`sdlUrl` included here is - ignored; the uploaded `sdlFile` part is always the source of `sdl`. + 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: @@ -9018,15 +9011,6 @@ components: $ref: "#/components/schemas/Upstream" introspectionMode: $ref: '#/components/schemas/GraphQLIntrospectionMode' - lifeCycleStatus: - type: string - x-go-type: RESTAPILifeCycleStatus - description: | - Current lifecycle status of the API. Reuses REST APIs' lifecycle enum - unmodified (STAGED, CREATED, PUBLISHED, DEPRECATED, RETIRED, BLOCKED) — - declaring a second identically-valued enum schema here would collide - with it at Go-constant generation time. - example: "CREATED" kind: type: string example: GraphQLApi @@ -9046,12 +9030,6 @@ components: type: string format: date-time readOnly: true - # sdl is deliberately omitted from list responses — unlike a REST API's - # operations[] (a handful of entries), a GraphQL schema's SDL can run to - # many KB; embedding it in every list-page row would bloat GET - # /graphql-apis for no listing-view benefit. Fetch GET - # /graphql-apis/{graphqlApiId} for the full schema. - GraphQLAPIListResponse: type: object required: diff --git a/platform-api/resources/role-to-scope-mapping.yaml b/platform-api/resources/role-to-scope-mapping.yaml index 0b23d0c251..a0b76425b0 100644 --- a/platform-api/resources/role-to-scope-mapping.yaml +++ b/platform-api/resources/role-to-scope-mapping.yaml @@ -226,6 +226,7 @@ roles: - ap:mcp_proxy:read - ap:llm_proxy:read - ap:llm_provider:read + - ap:graphql_api:read - ap:api_key:read # API Portal & MCP Hub - dp:application:manage 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) + } +} From b641fcf33f9859fb43469b10dea4ee98cf992455 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Fri, 28 Aug 2026 15:06:17 +0530 Subject: [PATCH 08/38] Split GraphQL API sdl out of the GET-by-id response --- platform-api/api/generated.go | 53 +++++- platform-api/internal/handler/graphql_api.go | 28 ++- platform-api/internal/service/graphql_api.go | 64 +++++++ .../internal/service/graphql_api_test.go | 102 ++++++++++ .../internal/service/graphql_mapping.go | 47 +++++ platform-api/resources/openapi.yaml | 175 +++++++++++++++++- 6 files changed, 465 insertions(+), 4 deletions(-) diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index 049f75d17f..49cd1a7d82 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -1611,6 +1611,45 @@ type GraphQLAPI struct { Version string `binding:"required" json:"version" yaml:"version"` } +// GraphQLAPIDetail defines model for GraphQLAPIDetail. +type GraphQLAPIDetail 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 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"` @@ -1640,14 +1679,24 @@ type GraphQLAPIListResponse struct { // GraphQLAPIMultipartRequest defines model for GraphQLAPIMultipartRequest. type GraphQLAPIMultipartRequest struct { // Metadata JSON-encoded request body — CreateGraphQLAPIRequest fields for create, - // GraphQLAPI fields for update. Any `sdl`/`sdlUrl` included here is - // ignored; the uploaded `sdlFile` part is always the source of `sdl`. + // 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 diff --git a/platform-api/internal/handler/graphql_api.go b/platform-api/internal/handler/graphql_api.go index f0e8ec0152..0e96882a53 100644 --- a/platform-api/internal/handler/graphql_api.go +++ b/platform-api/internal/handler/graphql_api.go @@ -107,7 +107,7 @@ func (h *GraphQLAPIHandler) GetGraphQLAPI(w http.ResponseWriter, r *http.Request return apperror.ValidationFailed.New("API ID is required") } - apiResponse, err := h.graphqlAPIService.Get(orgId, apiId) + 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)) } @@ -116,6 +116,31 @@ func (h *GraphQLAPIHandler) GetGraphQLAPI(w http.ResponseWriter, r *http.Request 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) @@ -316,6 +341,7 @@ func (h *GraphQLAPIHandler) RegisterRoutes(mux router.Router) { 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)) diff --git a/platform-api/internal/service/graphql_api.go b/platform-api/internal/service/graphql_api.go index eb72839851..db842da7b8 100644 --- a/platform-api/internal/service/graphql_api.go +++ b/platform-api/internal/service/graphql_api.go @@ -120,6 +120,32 @@ func (s *GraphQLAPIService) toGraphQLAPI(m *model.GraphQLAPI) (*api.GraphQLAPI, 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) { @@ -310,6 +336,44 @@ func (s *GraphQLAPIService) Get(orgUUID, handle string) (*api.GraphQLAPI, error) 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 := "" diff --git a/platform-api/internal/service/graphql_api_test.go b/platform-api/internal/service/graphql_api_test.go index e6775029b8..03e97207a2 100644 --- a/platform-api/internal/service/graphql_api_test.go +++ b/platform-api/internal/service/graphql_api_test.go @@ -492,6 +492,108 @@ func TestGraphQLGet_NotFound(t *testing.T) { } } +// 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) + } +} + // TestGraphQLList_NoProjectFilter_ReturnsAllAndResolvesHandles guards the // no-project-filter path (Count, not CountByProject) and the per-item // project-UUID -> handle resolution (mirrors REST's modelToRESTAPIUnresolved, diff --git a/platform-api/internal/service/graphql_mapping.go b/platform-api/internal/service/graphql_mapping.go index 749868b40b..56edd3103d 100644 --- a/platform-api/internal/service/graphql_mapping.go +++ b/platform-api/internal/service/graphql_mapping.go @@ -75,6 +75,53 @@ func mapGraphQLAPIModelToAPI(m *model.GraphQLAPI) *api.GraphQLAPI { } } +// 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 := mapUpstreamModelToAPI(&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). diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 87b469c5d5..63c6ff7c3c 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -2016,6 +2016,11 @@ paths: /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: @@ -2038,7 +2043,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/GraphQLAPI' + $ref: '#/components/schemas/GraphQLAPIDetail' '400': $ref: '#/components/responses/BadRequest' '401': @@ -2134,6 +2139,44 @@ paths: '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 @@ -8944,6 +8987,136 @@ components: 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 + example: /countries + 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' From 1cae920a13f2e7ad4ca96c5b5df978be716de8ba Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Fri, 28 Aug 2026 17:03:02 +0530 Subject: [PATCH 09/38] Suggest a /graphql suffix convention for GraphQL API context paths --- gateway/examples/blog-graphql-api.yaml | 2 +- gateway/examples/countries-graphql-api.yaml | 2 +- .../api/management-openapi.yaml | 13 +- .../pkg/api/management/generated.go | 789 +++++++++--------- platform-api/api/generated.go | 9 + platform-api/resources/openapi.yaml | 14 +- 6 files changed, 426 insertions(+), 403 deletions(-) diff --git a/gateway/examples/blog-graphql-api.yaml b/gateway/examples/blog-graphql-api.yaml index 0659e0f843..ad420284e5 100644 --- a/gateway/examples/blog-graphql-api.yaml +++ b/gateway/examples/blog-graphql-api.yaml @@ -39,7 +39,7 @@ metadata: spec: displayName: Blog version: v1 - context: /blog + context: /blog/graphql upstream: main: url: http://sample-backend:9080/graphql diff --git a/gateway/examples/countries-graphql-api.yaml b/gateway/examples/countries-graphql-api.yaml index ed5128ba83..711e2f4763 100644 --- a/gateway/examples/countries-graphql-api.yaml +++ b/gateway/examples/countries-graphql-api.yaml @@ -23,7 +23,7 @@ metadata: spec: displayName: Countries version: v1 - context: /countries/$version + context: /countries/$version/graphql upstream: main: url: https://countries.trevorblades.com/graphql diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index 1042876298..185cc6e95a 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -329,7 +329,7 @@ paths: description: Filter by API context/path schema: type: string - example: /countries + example: /countries/graphql - name: status in: query required: false @@ -4047,11 +4047,14 @@ components: 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. + 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 + example: /countries/$version/graphql upstream: type: object required: @@ -4119,7 +4122,7 @@ components: spec: displayName: Countries-GraphQL-API version: v1.0 - context: /countries/$version + context: /countries/$version/graphql upstream: main: url: https://countries.trevorblades.com/graphql @@ -4146,7 +4149,7 @@ components: spec: displayName: Countries-GraphQL-API version: v1.0 - context: /countries/$version + context: /countries/$version/graphql upstream: main: url: https://countries.trevorblades.com/graphql diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index a35510d685..7530784af5 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -1097,7 +1097,7 @@ 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. + // 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. @@ -1112,7 +1112,7 @@ type GraphQLAPIConfigData struct { // 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. + // 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"` @@ -1121,7 +1121,7 @@ type GraphQLAPIConfigData struct { Sandbox *Upstream `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` } `json:"upstream" yaml:"upstream"` - // Version Semantic version of the API + // Version Semantic version of the API. Both major-only (v1) and major.minor (v1.0) forms are accepted. Version string `json:"version" yaml:"version"` } @@ -6122,396 +6122,399 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "XjWWJyzk6vPV/x8AAP//LO9kPd23AgA=", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index 49cd1a7d82..d8cf689ad0 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -973,6 +973,9 @@ 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"` @@ -1549,6 +1552,9 @@ type GatewayTokenListResponse struct { // 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"` @@ -1613,6 +1619,9 @@ type GraphQLAPI struct { // 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"` diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 63c6ff7c3c..4d30e8ac1d 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -8876,7 +8876,11 @@ components: maxLength: 232 minLength: 1 type: string - example: /countries + 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 @@ -9022,7 +9026,11 @@ components: maxLength: 232 minLength: 1 type: string - example: /countries + 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 @@ -9173,7 +9181,7 @@ components: type: string context: type: string - example: /countries + example: /countries/graphql version: type: string example: v1.0 From 0f88f72e7f75dd50b7fed965659b3386677d30c7 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Fri, 28 Aug 2026 17:20:29 +0530 Subject: [PATCH 10/38] Fix go-httpkit import path in new GraphQL files after upstream rename --- .../gateway-controller/pkg/api/handlers/graphql_api_handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go b/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go index a70f0d5bd7..e94d24a9f4 100644 --- a/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go +++ b/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go @@ -33,7 +33,7 @@ import ( "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/go-httpkit/httputil" + "github.com/wso2/api-platform/httpkit/httputil" ) // GraphQLAPI CRUD handlers, implemented directly on *APIServer (mirroring From 974e0eed34bea65c7a9038a8d8f15233112681c8 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Fri, 28 Aug 2026 17:33:40 +0530 Subject: [PATCH 11/38] Add GraphQL API deploy support to event-gateway-controller --- .../gateway-controller/cmd/controller/main.go | 10 +++++++++- .../pkg/eventlistener/agent_processor_test.go | 1 + .../pkg/transform/agent_routing_test.go | 1 + gateway/gateway-controller/pkg/transform/agent_test.go | 2 ++ gateway/gateway-controller/pkg/transform/registry.go | 3 +++ 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/event-gateway/gateway-controller/cmd/controller/main.go b/event-gateway/gateway-controller/cmd/controller/main.go index eac8694c2f..f557928de0 100644 --- a/event-gateway/gateway-controller/cmd/controller/main.go +++ b/event-gateway/gateway-controller/cmd/controller/main.go @@ -407,7 +407,15 @@ func main() { // listener (which dispatches EventTypeAgent), so Agents do reach both the // policy manager and the xDS translator below. agentTransformer := transform.NewAgentTransformer(&cfg.Router, cfg, policyDefinitions) - transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer, agentTransformer) + // GraphQLApi's config validator/deploy parser (pkg/utils/graphql_deployment.go) + // self-register via init() and are therefore already active in this binary too + // (transitively imported via the shared transform/handlers packages) — the + // /graphql-apis CRUD and api-key routes are 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, agentTransformer, graphqlTransformer) // Derived from the registry rather than hand-listed, for the same reason the // gateway controller derives it: a hand-written map beside the registry's own diff --git a/gateway/gateway-controller/pkg/eventlistener/agent_processor_test.go b/gateway/gateway-controller/pkg/eventlistener/agent_processor_test.go index b6019ec7dd..1f599dd2d0 100644 --- a/gateway/gateway-controller/pkg/eventlistener/agent_processor_test.go +++ b/gateway/gateway-controller/pkg/eventlistener/agent_processor_test.go @@ -223,6 +223,7 @@ func newAgentReplica(t *testing.T, db storage.Storage) *agentReplica { registry := transform.NewRegistry( nil, nil, transform.NewAgentTransformer(routerConfig, systemConfig, policyDefinitions), + nil, ) policySnapshotManager := policyxds.NewSnapshotManager(logger) diff --git a/gateway/gateway-controller/pkg/transform/agent_routing_test.go b/gateway/gateway-controller/pkg/transform/agent_routing_test.go index 9892a3033b..6e4d28384a 100644 --- a/gateway/gateway-controller/pkg/transform/agent_routing_test.go +++ b/gateway/gateway-controller/pkg/transform/agent_routing_test.go @@ -141,6 +141,7 @@ func agentEnvoyRoutes(t *testing.T, stored *models.StoredConfig) []*route.Route transform.NewRestAPITransformer(routerCfg, systemCfg, map[string]models.PolicyDefinition{}), nil, transform.NewAgentTransformer(routerCfg, systemCfg, routingTestPolicyDefinitions()), + nil, ) translator.SetTransformers(map[string]models.ConfigTransformer{models.KindAgent: registry}) diff --git a/gateway/gateway-controller/pkg/transform/agent_test.go b/gateway/gateway-controller/pkg/transform/agent_test.go index 1f4b74da69..8d070342ff 100644 --- a/gateway/gateway-controller/pkg/transform/agent_test.go +++ b/gateway/gateway-controller/pkg/transform/agent_test.go @@ -1703,6 +1703,7 @@ func TestRegistryKindsMatchDispatch(t *testing.T) { NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}), nil, agentTransformer(), + NewGraphQLAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}), ) for _, kind := range Kinds() { @@ -1722,6 +1723,7 @@ func TestAgentRegistryDispatch(t *testing.T) { NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{}), nil, agentTransformer(), + nil, ) viaRegistry, err := registry.Transform(testAgent()) diff --git a/gateway/gateway-controller/pkg/transform/registry.go b/gateway/gateway-controller/pkg/transform/registry.go index 4158039b5d..d2ea4fb959 100644 --- a/gateway/gateway-controller/pkg/transform/registry.go +++ b/gateway/gateway-controller/pkg/transform/registry.go @@ -95,6 +95,9 @@ func (r *Registry) Transform(cfg *models.StoredConfig) (*models.RuntimeDeployCon } return r.agentT.Transform(cfg) case models.KindGraphQLApi: + if r.graphqlT == nil { + return nil, fmt.Errorf("%w: %s", ErrUnsupportedKind, cfg.Kind) + } return r.graphqlT.Transform(cfg) default: return nil, fmt.Errorf("%w: %s", ErrUnsupportedKind, cfg.Kind) From 53757e1e4eb8af79652ab2d799ed30f47f0e51e0 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Fri, 28 Aug 2026 17:40:09 +0530 Subject: [PATCH 12/38] Redact upstream auth credentials in GraphQL API responses --- .../internal/service/graphql_api_test.go | 91 +++++++++++++++++++ .../internal/service/graphql_mapping.go | 6 +- platform-api/internal/service/llm.go | 50 ---------- 3 files changed, 94 insertions(+), 53 deletions(-) diff --git a/platform-api/internal/service/graphql_api_test.go b/platform-api/internal/service/graphql_api_test.go index 03e97207a2..29ef024517 100644 --- a/platform-api/internal/service/graphql_api_test.go +++ b/platform-api/internal/service/graphql_api_test.go @@ -594,6 +594,97 @@ func TestGraphQLGetSDL_NotFound(t *testing.T) { } } +// 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, diff --git a/platform-api/internal/service/graphql_mapping.go b/platform-api/internal/service/graphql_mapping.go index 56edd3103d..66f352f4fd 100644 --- a/platform-api/internal/service/graphql_mapping.go +++ b/platform-api/internal/service/graphql_mapping.go @@ -52,7 +52,7 @@ func mapGraphQLAPIModelToAPI(m *model.GraphQLAPI) *api.GraphQLAPI { subscriptionPlans = &m.Configuration.SubscriptionPlans } - upstream := mapUpstreamModelToAPI(&m.Configuration.Upstream) + upstream := mapUpstreamConfigToDTO(&m.Configuration.Upstream) return &api.GraphQLAPI{ Id: utils.StringPtrIfNotEmpty(m.Handle), @@ -100,7 +100,7 @@ func mapGraphQLAPIModelToDetail(m *model.GraphQLAPI) *api.GraphQLAPIDetail { subscriptionPlans = &m.Configuration.SubscriptionPlans } - upstream := mapUpstreamModelToAPI(&m.Configuration.Upstream) + upstream := mapUpstreamConfigToDTO(&m.Configuration.Upstream) return &api.GraphQLAPIDetail{ Id: utils.StringPtrIfNotEmpty(m.Handle), @@ -136,7 +136,7 @@ func mapGraphQLAPIModelToListItem(m *model.GraphQLAPI) *api.GraphQLAPIListItem { introspectionMode = &im } - upstream := mapUpstreamModelToAPI(&m.Configuration.Upstream) + upstream := mapUpstreamConfigToDTO(&m.Configuration.Upstream) return &api.GraphQLAPIListItem{ Id: utils.StringPtrIfNotEmpty(m.Handle), diff --git a/platform-api/internal/service/llm.go b/platform-api/internal/service/llm.go index 2a3714d83a..b0f7e76d2c 100644 --- a/platform-api/internal/service/llm.go +++ b/platform-api/internal/service/llm.go @@ -2438,40 +2438,6 @@ func mapUpstreamAPIToModel(in api.Upstream) *model.UpstreamConfig { return out } -func mapUpstreamModelToAPI(in *model.UpstreamConfig) api.Upstream { - main := api.UpstreamDefinition{} - if in != nil && in.Main != nil { - if strings.TrimSpace(in.Main.URL) != "" { - u := in.Main.URL - main.Url = &u - } - if strings.TrimSpace(in.Main.Ref) != "" { - r := in.Main.Ref - main.Ref = &r - } - if in.Main.Auth != nil { - main.Auth = mapUpstreamAuthModelToAPI(in.Main.Auth) - } - } - var sandbox *api.UpstreamDefinition - if in != nil && in.Sandbox != nil { - s := api.UpstreamDefinition{} - if strings.TrimSpace(in.Sandbox.URL) != "" { - u := in.Sandbox.URL - s.Url = &u - } - if strings.TrimSpace(in.Sandbox.Ref) != "" { - r := in.Sandbox.Ref - s.Ref = &r - } - if in.Sandbox.Auth != nil { - s.Auth = mapUpstreamAuthModelToAPI(in.Sandbox.Auth) - } - sandbox = &s - } - return api.Upstream{Main: main, Sandbox: sandbox} -} - // mapUpstreamConfigToDTO maps upstream config to API type with auth values redacted for security func mapUpstreamConfigToDTO(in *model.UpstreamConfig) api.Upstream { main := api.UpstreamDefinition{} @@ -2527,22 +2493,6 @@ func mapUpstreamConfigToDTO(in *model.UpstreamConfig) api.Upstream { return api.Upstream{Main: main, Sandbox: sandbox} } -func mapUpstreamAuthModelToAPI(in *model.UpstreamAuth) *api.UpstreamAuth { - if in == nil { - return nil - } - var authType *api.UpstreamAuthType - if normalized := normalizeUpstreamAuthType(in.Type); normalized != "" { - t := api.UpstreamAuthType(normalized) - authType = &t - } - return &api.UpstreamAuth{ - Type: authType, - Header: utils.StringPtrIfNotEmpty(in.Header), - Value: utils.StringPtrIfNotEmpty(in.Value), - } -} - func mapRateLimitingAPIToModel(in *api.LLMRateLimitingConfig) *model.LLMRateLimitingConfig { if in == nil { return nil From a3463c1235447931e8f0b37a7eb3ea7ea484d341 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Mon, 31 Aug 2026 11:44:25 +0530 Subject: [PATCH 13/38] Wire up CP<->DP GraphQL API deployment: sync, live events, artifact push, deployment-YAML sandbox upstream, sdlUrl SSRF doc fix, and introspection timeout enforcement --- .../pkg/controlplane/client.go | 286 ++++++++++++++++++ .../pkg/controlplane/events.go | 43 +++ .../pkg/controlplane/sync.go | 30 +- .../gateway-controller/pkg/utils/api_utils.go | 10 + platform-api/config/default_config.go | 1 + platform-api/internal/dto/graphql_api.go | 9 +- .../internal/handler/gateway_internal.go | 58 ++++ .../gateway_secret_integration_test.go | 2 +- platform-api/internal/server/server.go | 3 +- .../internal/service/artifact_import.go | 2 + .../service/artifact_import_graphql.go | 124 ++++++++ .../internal/service/artifact_import_test.go | 101 ++++++- .../internal/service/gateway_internal.go | 29 +- .../internal/service/graphql_api_test.go | 2 +- .../internal/service/graphql_deployment.go | 7 + .../service/graphql_deployment_test.go | 102 +++++++ .../internal/service/graphql_introspection.go | 17 +- platform-api/internal/utils/common.go | 19 ++ .../internal/utils/import_artifacts.go | 1 + platform-api/resources/openapi.yaml | 10 +- 20 files changed, 825 insertions(+), 31 deletions(-) create mode 100644 platform-api/internal/service/artifact_import_graphql.go diff --git a/gateway/gateway-controller/pkg/controlplane/client.go b/gateway/gateway-controller/pkg/controlplane/client.go index d16c5864c9..80a1bd51fa 100644 --- a/gateway/gateway-controller/pkg/controlplane/client.go +++ b/gateway/gateway-controller/pkg/controlplane/client.go @@ -1484,6 +1484,12 @@ func (c *Client) handleMessage(messageType int, message []byte) { c.handleAgentUndeployedEvent(event) case "agent.deleted": c.handleAgentDeletedEvent(event) + case "graphqlapi.deployed": + c.handleGraphQLAPIDeployedEvent(event) + case "graphqlapi.undeployed": + c.handleGraphQLAPIUndeployedEvent(event) + case "graphqlapi.deleted": + c.handleGraphQLAPIDeletedEvent(event) case "websub.deployed": c.dispatchEventGatewayHook(event["type"], func(h ControlPlaneEventGatewayHooks) { h.HandleWebSubAPIDeployed(c, event) }) case "websub.undeployed": @@ -1559,6 +1565,286 @@ func (c *Client) fetchAndDeployAPI(apiID, deploymentID string, deployedAt *time. return result, nil } +// fetchAndDeployGraphQLAPI fetches a GraphQL API definition and deploys it. +// GraphQLApi has no dedicated deployment service — it self-registers into +// APIDeploymentService's generic kindDeployParsers extension point (see +// pkg/utils/graphql_deployment.go's init()), so it is deployed through the +// same generic deploymentService RestApi uses via fetchAndDeployAPI above, +// keyed off the YAML's own "kind: GraphQLApi" field. +func (c *Client) fetchAndDeployGraphQLAPI(apiID, deploymentID string, deployedAt *time.Time, correlationID string) (*utils.APIDeploymentResult, error) { + zipData, err := c.apiUtilsService.FetchGraphQLAPIDefinition(apiID) + if err != nil { + c.logger.Error("Failed to fetch GraphQL API definition", + slog.String("api_id", apiID), + slog.Any("error", err), + ) + return nil, fmt.Errorf("failed to fetch GraphQL API definition: %w", err) + } + + yamlData, err := c.apiUtilsService.ExtractYAMLFromZip(zipData) + if err != nil { + c.logger.Error("Failed to extract YAML from zip", + slog.String("api_id", apiID), + slog.Any("error", err), + ) + return nil, fmt.Errorf("failed to extract YAML from zip: %w", err) + } + + c.syncSecretRefsFromYAML(yamlData, correlationID) + + result, err := c.apiUtilsService.CreateAPIFromYAML(yamlData, c.resolveLocalArtifactID(apiID), deploymentID, deployedAt, correlationID, c.deploymentService) + if err != nil { + c.logger.Error("Failed to create GraphQL API from YAML", + slog.String("api_id", apiID), + slog.Any("error", err), + ) + return nil, fmt.Errorf("failed to create GraphQL API from YAML: %w", err) + } + + return result, nil +} + +// handleGraphQLAPIDeployedEvent handles GraphQL API deployment events +func (c *Client) handleGraphQLAPIDeployedEvent(event map[string]interface{}) { + c.logger.Info("GraphQL API Deployment Event", + slog.Any("payload", event["payload"]), + slog.Any("timestamp", event["timestamp"]), + slog.Any("correlationId", event["correlationId"]), + ) + + eventBytes, err := json.Marshal(event) + if err != nil { + c.logger.Error("Failed to marshal event for parsing", + slog.Any("error", err), + ) + return + } + + var deployedEvent GraphQLAPIDeployedEvent + if err := json.Unmarshal(eventBytes, &deployedEvent); err != nil { + c.logger.Error("Failed to parse GraphQL API deployment event", + slog.Any("error", err), + ) + return + } + + apiID := deployedEvent.Payload.ApiId + if apiID == "" { + c.logger.Error("GraphQL API ID is empty in deployment event") + return + } + + c.logger.Info("Processing GraphQL API deployment", + slog.String("api_id", apiID), + slog.String("deployment_id", deployedEvent.Payload.DeploymentID), + slog.String("correlation_id", deployedEvent.CorrelationID), + ) + + performedAt := deployedEvent.Payload.PerformedAt.Truncate(time.Millisecond) + if performedAt.IsZero() { + performedAt = time.Now().Truncate(time.Millisecond) + } + result, err := c.fetchAndDeployGraphQLAPI(apiID, deployedEvent.Payload.DeploymentID, &performedAt, deployedEvent.CorrelationID) + if err != nil { + c.sendDeploymentAck(deployedEvent.Payload.DeploymentID, apiID, "graphqlapi", "deploy", "failed", + deployedEvent.Payload.PerformedAt, "GATEWAY_PROCESSING_ERROR") + return + } + + if result.IsStale { + // Stale event — DB was not modified. Do not send ack; in HA mode the + // controller that actually processed the event will ack. If all controllers + // see stale, platform-API will timeout and handle accordingly. + c.logger.Debug("Skipped stale GraphQL API deploy event (newer version exists in DB)", + slog.String("api_id", apiID), + slog.String("deployment_id", deployedEvent.Payload.DeploymentID), + ) + return + } + + c.sendDeploymentAck(deployedEvent.Payload.DeploymentID, apiID, "graphqlapi", "deploy", "success", + deployedEvent.Payload.PerformedAt, "") + + c.logger.Info("Successfully processed GraphQL API deployment event", + slog.String("api_id", apiID), + slog.String("correlation_id", deployedEvent.CorrelationID), + ) +} + +// handleGraphQLAPIUndeployedEvent handles GraphQL API undeployment events +func (c *Client) handleGraphQLAPIUndeployedEvent(event map[string]interface{}) { + c.logger.Info("GraphQL API Undeployment Event", + slog.Any("payload", event["payload"]), + slog.Any("timestamp", event["timestamp"]), + slog.Any("correlationId", event["correlationId"]), + ) + + eventBytes, err := json.Marshal(event) + if err != nil { + c.logger.Error("Failed to marshal event for parsing", + slog.Any("error", err), + ) + return + } + + var undeployedEvent GraphQLAPIUndeployedEvent + if err := json.Unmarshal(eventBytes, &undeployedEvent); err != nil { + c.logger.Error("Failed to parse GraphQL API undeployment event", + slog.Any("error", err), + ) + return + } + + apiID := undeployedEvent.Payload.ApiId + if apiID == "" { + c.logger.Error("GraphQL API ID is empty in undeployment event") + return + } + + c.logger.Info("Processing GraphQL API undeployment", + slog.String("api_id", apiID), + slog.String("correlation_id", undeployedEvent.CorrelationID), + ) + + apiConfig, err := c.findAPIConfig(apiID) + if err != nil { + if storage.IsNotFoundError(err) { + c.logger.Warn("GraphQL API configuration not found for undeployment", + slog.String("api_id", apiID), + ) + // Still send success ack - the API is already undeployed + c.sendDeploymentAck(undeployedEvent.Payload.DeploymentID, apiID, "graphqlapi", "undeploy", "success", + undeployedEvent.Payload.PerformedAt, "") + return + } + c.logger.Error("Failed to fetch GraphQL API configuration for undeployment", + slog.String("api_id", apiID), + slog.String("correlation_id", undeployedEvent.CorrelationID), + slog.Any("error", err), + ) + c.sendDeploymentAck(undeployedEvent.Payload.DeploymentID, apiID, "graphqlapi", "undeploy", "failed", + undeployedEvent.Payload.PerformedAt, "GATEWAY_PROCESSING_ERROR") + return + } + + // Only process undeploy if the event's DeploymentID matches the current one. + // This prevents stale undeploy events from affecting a newer deployment. + if apiConfig.DeploymentID != "" && undeployedEvent.Payload.DeploymentID != "" && + apiConfig.DeploymentID != undeployedEvent.Payload.DeploymentID { + c.logger.Warn("Ignoring stale GraphQL API undeploy event: deployment ID mismatch", + slog.String("api_id", apiID), + slog.String("event_deployment_id", undeployedEvent.Payload.DeploymentID), + slog.String("current_deployment_id", apiConfig.DeploymentID), + ) + c.sendDeploymentAck(undeployedEvent.Payload.DeploymentID, apiID, "graphqlapi", "undeploy", "failed", + undeployedEvent.Payload.PerformedAt, "DEPLOYMENT_ID_MISMATCH") + return + } + + graphqlUndeployPerformedAt := undeployedEvent.Payload.PerformedAt.Truncate(time.Millisecond) + if graphqlUndeployPerformedAt.IsZero() { + graphqlUndeployPerformedAt = time.Now().Truncate(time.Millisecond) + } + apiConfig.DesiredState = models.StateUndeployed + apiConfig.DeploymentID = undeployedEvent.Payload.DeploymentID + apiConfig.DeployedAt = &graphqlUndeployPerformedAt + apiConfig.UpdatedAt = time.Now() + + // Timestamp-guarded upsert: only writes if deployed_at is newer than what's in DB. + // This prevents stale undeploy events from overwriting newer state. + affected, err := c.db.UpsertConfig(apiConfig) + if err != nil { + c.logger.Error("Failed to upsert config for undeployment", + slog.String("api_id", apiID), + slog.Any("error", err), + ) + c.sendDeploymentAck(undeployedEvent.Payload.DeploymentID, apiID, "graphqlapi", "undeploy", "failed", + undeployedEvent.Payload.PerformedAt, "GATEWAY_PROCESSING_ERROR") + return + } + if !affected { + c.logger.Debug("Skipped stale GraphQL API undeploy event (newer version exists in DB)", + slog.String("api_id", apiID), + slog.String("deployment_id", undeployedEvent.Payload.DeploymentID), + ) + return + } + + evt := eventhub.Event{ + EventType: eventhub.EventTypeAPI, + Action: "UPDATE", + EntityID: apiID, + EventID: undeployedEvent.CorrelationID, + } + if err := c.eventHub.PublishEvent(c.gatewayID, evt); err != nil { + c.logger.Error("Failed to publish GraphQL API undeployment event", slog.Any("error", err)) + } + + c.sendDeploymentAck(undeployedEvent.Payload.DeploymentID, apiID, "graphqlapi", "undeploy", "success", + undeployedEvent.Payload.PerformedAt, "") + + c.logger.Info("Successfully processed GraphQL API undeployment event", + slog.String("api_id", apiID), + slog.String("correlation_id", undeployedEvent.CorrelationID), + ) +} + +// handleGraphQLAPIDeletedEvent handles GraphQL API deletion events +func (c *Client) handleGraphQLAPIDeletedEvent(event map[string]interface{}) { + c.logger.Info("GraphQL API Deletion Event", + slog.Any("payload", event["payload"]), + slog.Any("timestamp", event["timestamp"]), + slog.Any("correlationId", event["correlationId"]), + ) + + eventBytes, err := json.Marshal(event) + if err != nil { + c.logger.Error("Failed to marshal event for parsing", + slog.Any("error", err), + ) + return + } + + var deletedEvent GraphQLAPIDeletedEvent + if err := json.Unmarshal(eventBytes, &deletedEvent); err != nil { + c.logger.Error("Failed to parse GraphQL API deletion event", + slog.Any("error", err), + ) + return + } + + apiID := deletedEvent.Payload.ApiId + if apiID == "" { + c.logger.Error("GraphQL API ID is empty in deletion event") + return + } + + c.logger.Info("Processing GraphQL API deletion", + slog.String("api_id", apiID), + slog.String("correlation_id", deletedEvent.CorrelationID), + ) + + apiConfig, err := c.findAPIConfig(apiID) + if err != nil { + if storage.IsNotFoundError(err) { + // Config not found - proceed with orphan cleanup + c.cleanupOrphanedResources(apiID, deletedEvent.CorrelationID) + return + } + // Real storage error (DB failure, etc.) - log and abort + // Do NOT proceed with orphan cleanup as the config might actually exist + c.logger.Error("Failed to fetch GraphQL API configuration for deletion, aborting", + slog.String("api_id", apiID), + slog.String("correlation_id", deletedEvent.CorrelationID), + slog.Any("error", err), + ) + return + } + + // Config found - perform full deletion + c.performFullAPIDeletion(apiID, apiConfig, deletedEvent.CorrelationID) +} + // updatePolicyForDeployment updates policy engine for API deployment func (c *Client) updatePolicyForDeployment(apiID, correlationID string, result *utils.APIDeploymentResult) error { if c.policyManager == nil { diff --git a/gateway/gateway-controller/pkg/controlplane/events.go b/gateway/gateway-controller/pkg/controlplane/events.go index 908c83c5a4..1a48d05f2b 100644 --- a/gateway/gateway-controller/pkg/controlplane/events.go +++ b/gateway/gateway-controller/pkg/controlplane/events.go @@ -328,6 +328,49 @@ type AgentDeletedEvent struct { CorrelationID string `json:"correlationId"` } +// GraphQLAPIDeployedEventPayload represents the payload of a GraphQL API deployment event +type GraphQLAPIDeployedEventPayload struct { + ApiId string `json:"apiId"` + DeploymentID string `json:"deploymentId"` + PerformedAt time.Time `json:"performedAt"` +} + +// GraphQLAPIDeployedEvent represents the complete GraphQL API deployment event +type GraphQLAPIDeployedEvent struct { + Type string `json:"type"` + Payload GraphQLAPIDeployedEventPayload `json:"payload"` + Timestamp string `json:"timestamp"` + CorrelationID string `json:"correlationId"` +} + +// GraphQLAPIUndeployedEventPayload represents the payload of a GraphQL API undeployment event +type GraphQLAPIUndeployedEventPayload struct { + ApiId string `json:"apiId"` + DeploymentID string `json:"deploymentId"` + PerformedAt time.Time `json:"performedAt"` +} + +// GraphQLAPIUndeployedEvent represents the complete GraphQL API undeployment event +type GraphQLAPIUndeployedEvent struct { + Type string `json:"type"` + Payload GraphQLAPIUndeployedEventPayload `json:"payload"` + Timestamp string `json:"timestamp"` + CorrelationID string `json:"correlationId"` +} + +// GraphQLAPIDeletedEventPayload represents the payload of a GraphQL API deletion event +type GraphQLAPIDeletedEventPayload struct { + ApiId string `json:"apiId"` +} + +// GraphQLAPIDeletedEvent represents the complete GraphQL API deletion event +type GraphQLAPIDeletedEvent struct { + Type string `json:"type"` + Payload GraphQLAPIDeletedEventPayload `json:"payload"` + Timestamp string `json:"timestamp"` + CorrelationID string `json:"correlationId"` +} + // Note: WebSub/WebBroker deploy/undeploy/delete event payload types // (WebSubAPIDeployedEvent, WebBrokerAPIDeployedEvent, etc.) are NOT defined // here. They are event-gateway-specific and owned by the diff --git a/gateway/gateway-controller/pkg/controlplane/sync.go b/gateway/gateway-controller/pkg/controlplane/sync.go index 9b83cc137d..f76f49af28 100644 --- a/gateway/gateway-controller/pkg/controlplane/sync.go +++ b/gateway/gateway-controller/pkg/controlplane/sync.go @@ -224,8 +224,8 @@ func computeSyncDiff(remote []models.ControlPlaneDeployment, local []*models.Sto // processSyncFetches fetches deployment artifacts in chunked batches, ordered by // dependency: LLM Providers first, then LLM Proxies, then REST APIs. func (c *Client) processSyncFetches(deployments []models.ControlPlaneDeployment, gatewayID string) { - // Sort by dependency order: providers → proxies → REST APIs/MCP proxies/agents - var providers, proxies, restAPIs, mcpProxies, agents []models.ControlPlaneDeployment + // Sort by dependency order: providers → proxies → REST APIs/MCP proxies/agents/GraphQL APIs + var providers, proxies, restAPIs, mcpProxies, agents, graphqlAPIs []models.ControlPlaneDeployment for _, dep := range deployments { switch dep.Kind { case models.KindLlmProvider: @@ -238,6 +238,8 @@ func (c *Client) processSyncFetches(deployments []models.ControlPlaneDeployment, mcpProxies = append(mcpProxies, dep) case models.KindAgent: agents = append(agents, dep) + case models.KindGraphQLApi: + graphqlAPIs = append(graphqlAPIs, dep) } } @@ -248,6 +250,7 @@ func (c *Client) processSyncFetches(deployments []models.ControlPlaneDeployment, ordered = append(ordered, restAPIs...) ordered = append(ordered, mcpProxies...) ordered = append(ordered, agents...) + ordered = append(ordered, graphqlAPIs...) batchSize := c.config.SyncBatchSize if batchSize <= 0 { @@ -358,6 +361,14 @@ func (c *Client) processSyncFetchBatch(batch []models.ControlPlaneDeployment, ga // pkg/utils. Same call shape either way. _, err = c.agentService.CreateFromYAML(yamlData, dep.ArtifactID, dep.DeploymentID, &deployedAt, correlationID, c.logger) + case models.KindGraphQLApi: + // GraphQLApi has no dedicated deployment service — it self-registers into + // APIDeploymentService's generic kindDeployParsers extension point (see + // pkg/utils/graphql_deployment.go's init()), so it is deployed through the + // same generic deploymentService RestApi uses, keyed off the YAML's own + // "kind: GraphQLApi" field rather than a per-kind service reference. + _, err = c.apiUtilsService.CreateAPIFromYAML(yamlData, dep.ArtifactID, + dep.DeploymentID, &deployedAt, correlationID, c.deploymentService) } if err != nil { @@ -468,7 +479,7 @@ func (c *Client) processSyncDeletions(artifactIDs []string, gatewayID string) { kind string } - var restAPIs, proxies, providers, mcpProxies, agents, unknown []deletionEntry + var restAPIs, proxies, providers, mcpProxies, agents, graphqlAPIs, unknown []deletionEntry for _, id := range artifactIDs { cfg, err := c.db.GetConfig(id) @@ -495,14 +506,17 @@ func (c *Client) processSyncDeletions(artifactIDs []string, gatewayID string) { mcpProxies = append(mcpProxies, entry) case models.KindAgent: agents = append(agents, entry) + case models.KindGraphQLApi: + graphqlAPIs = append(graphqlAPIs, entry) } } - // Reverse dependency order: agents/MCP proxies/REST APIs → proxies → providers + // Reverse dependency order: agents/MCP proxies/REST APIs/GraphQL APIs → proxies → providers ordered := make([]deletionEntry, 0, len(artifactIDs)) ordered = append(ordered, agents...) ordered = append(ordered, mcpProxies...) ordered = append(ordered, restAPIs...) + ordered = append(ordered, graphqlAPIs...) ordered = append(ordered, unknown...) ordered = append(ordered, proxies...) ordered = append(ordered, providers...) @@ -562,8 +576,12 @@ func (c *Client) processSyncDeletion(artifactID, kind, gatewayID string) { } } - case models.KindRestApi: - // REST API / WebSub — follow the performFullAPIDeletion pattern + case models.KindRestApi, models.KindGraphQLApi: + // REST API / WebSub / GraphQL API — follow the performFullAPIDeletion pattern. + // GraphQLApi is stored as a generic StoredConfig artifact (no dedicated + // deployment/deletion service — see the KindGraphQLApi case in + // processSyncFetchBatch), so the same generic deletion path REST/WebSub use + // applies unmodified. apiConfig, err := c.findAPIConfig(artifactID) if err != nil { if storage.IsNotFoundError(err) { diff --git a/gateway/gateway-controller/pkg/utils/api_utils.go b/gateway/gateway-controller/pkg/utils/api_utils.go index 698a9555b1..1444029c35 100644 --- a/gateway/gateway-controller/pkg/utils/api_utils.go +++ b/gateway/gateway-controller/pkg/utils/api_utils.go @@ -629,6 +629,16 @@ func (s *APIUtilsService) FetchMCPProxyDefinition(proxyID string) ([]byte, error return bodyBytes, nil } +// FetchGraphQLAPIDefinition downloads the GraphQL API definition as a zip file +// from the control plane. GraphQLApi is compiled directly into this binary +// (not a separate module), so unlike WebSub/WebBroker it doesn't need +// FetchResourceZip's cross-module reuse — the wrapper is kept anyway to avoid +// duplicating the HTTP/auth/size-limit boilerplate FetchMCPProxyDefinition +// above still carries inline. +func (s *APIUtilsService) FetchGraphQLAPIDefinition(apiID string) ([]byte, error) { + return s.FetchResourceZip("/graphql-apis/"+apiID, "GraphQL API definition") +} + // FetchResourceZip performs a generic authenticated GET against // {baseURL}{resourcePath}, expecting a zip response, and returns the raw // bytes. resourceLabel is used only for log/error messages (e.g. "WebSub API diff --git a/platform-api/config/default_config.go b/platform-api/config/default_config.go index 5e7d439d51..447256c16d 100644 --- a/platform-api/config/default_config.go +++ b/platform-api/config/default_config.go @@ -65,6 +65,7 @@ func defaultConfig() *Server { "/api/internal/v1/llm-proxies", "/api/internal/v1/subscription-plans", "/api/internal/v1/mcp-proxies", + "/api/internal/v1/graphql-apis", "/api/internal/v1/gateways", "/api/internal/v1/deployments", "/api/internal/v1/artifacts", diff --git a/platform-api/internal/dto/graphql_api.go b/platform-api/internal/dto/graphql_api.go index d04e312b63..e2fafee176 100644 --- a/platform-api/internal/dto/graphql_api.go +++ b/platform-api/internal/dto/graphql_api.go @@ -53,9 +53,14 @@ type GraphQLAPIYAMLData struct { } // GraphQLUpstream represents the upstream configuration for the GraphQL API -// deployment YAML — a single logical endpoint (no sandbox split, unlike REST). +// deployment YAML. Unlike RestAPI's per-operation upstream shape, a GraphQL +// API has exactly one logical endpoint per environment, but — like REST — it +// still supports an optional sandbox split alongside the main upstream (see +// GraphQLAPIConfigData.Upstream.Sandbox in the gateway's OpenAPI spec and +// GraphQLAPITransformer's sandbox route handling). type GraphQLUpstream struct { - Main *GraphQLUpstreamTarget `yaml:"main,omitempty"` + Main *GraphQLUpstreamTarget `yaml:"main,omitempty"` + Sandbox *GraphQLUpstreamTarget `yaml:"sandbox,omitempty"` } // GraphQLUpstreamTarget represents the GraphQL upstream endpoint (url or ref), diff --git a/platform-api/internal/handler/gateway_internal.go b/platform-api/internal/handler/gateway_internal.go index 98e7caa2af..61dc6625fb 100644 --- a/platform-api/internal/handler/gateway_internal.go +++ b/platform-api/internal/handler/gateway_internal.go @@ -577,6 +577,63 @@ func (h *GatewayInternalAPIHandler) GetMCPProxy(w http.ResponseWriter, r *http.R _, _ = w.Write(zipData) } +// GetGraphQLAPI handles GET /api/internal/v1/graphql-apis/:apiId +func (h *GatewayInternalAPIHandler) GetGraphQLAPI(w http.ResponseWriter, r *http.Request) { + orgID, gatewayID, ok := h.authenticateRequest(w, r) + if !ok { + return + } + + apiID := r.PathValue("apiId") + if apiID == "" { + httputil.WriteJSON(w, http.StatusBadRequest, dto.NewInternalErrorResponse(400, "Bad Request", + "API ID is required")) + return + } + + api, err := h.gatewayInternalService.GetActiveGraphQLAPIDeploymentByGateway(apiID, orgID, gatewayID) + if err != nil { + clientIP := r.RemoteAddr + if i := strings.LastIndex(clientIP, ":"); i != -1 { + clientIP = clientIP[:i] + } + if apperror.DeploymentNotActive.Is(err) { + h.slogger.Error("No active deployment found for GraphQL API", "clientIP", clientIP, "apiID", apiID, "orgID", orgID, "gatewayID", gatewayID, "error", err) + httputil.WriteJSON(w, http.StatusNotFound, dto.NewInternalErrorResponse(404, "Not Found", + "No active deployment found for this GraphQL API on this gateway")) + return + } + if apperror.GraphQLAPINotFound.Is(err) { + h.slogger.Error("GraphQL API not found", "clientIP", clientIP, "apiID", apiID, "orgID", orgID, "gatewayID", gatewayID, "error", err) + httputil.WriteJSON(w, http.StatusNotFound, dto.NewInternalErrorResponse(404, "Not Found", + "GraphQL API not found")) + return + } + h.slogger.Error("Failed to get GraphQL API", "clientIP", clientIP, "apiID", apiID, "orgID", orgID, "gatewayID", gatewayID, "error", err) + httputil.WriteJSON(w, http.StatusInternalServerError, dto.NewInternalErrorResponse(500, "Internal Server Error", + "Failed to get GraphQL API")) + return + } + + // Create ZIP file from GraphQL API YAML file + zipData, err := utils.CreateGraphQLAPIYamlZip(api) + if err != nil { + h.slogger.Error("Failed to create ZIP file", "apiID", apiID, "error", err) + httputil.WriteJSON(w, http.StatusInternalServerError, dto.NewInternalErrorResponse(500, "Internal Server Error", + "Failed to create GraphQL API package")) + return + } + + // Set headers for ZIP file download + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"graphql-api-%s.zip\"", apiID)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(zipData))) + + // Return ZIP file + w.WriteHeader(http.StatusOK) + _, _ = w.Write(zipData) +} + // GetWebSubAPI handles GET /api/internal/v1/websub-apis/:apiId func (h *GatewayInternalAPIHandler) GetWebSubAPI(w http.ResponseWriter, r *http.Request) { orgID, gatewayID, ok := h.authenticateRequest(w, r) @@ -1018,6 +1075,7 @@ func (h *GatewayInternalAPIHandler) RegisterRoutes(mux router.Router) { mux.HandleFunc("GET /api/internal/v1/deployments", h.GetGatewayDeployments) mux.HandleFunc("POST /api/internal/v1/deployments/fetch-batch", h.BatchFetchDeployments) mux.HandleFunc("GET /api/internal/v1/mcp-proxies/{proxyId}", h.GetMCPProxy) + mux.HandleFunc("GET /api/internal/v1/graphql-apis/{apiId}", h.GetGraphQLAPI) mux.HandleFunc("GET /api/internal/v1/websub-apis/api-keys", h.GetWebSubAPIAPIKeys) mux.HandleFunc("GET /api/internal/v1/websub-apis/{apiId}", h.GetWebSubAPI) mux.HandleFunc("GET /api/internal/v1/websub-apis/{apiId}/secrets", h.GetWebSubAPIHmacSecrets) diff --git a/platform-api/internal/handler/gateway_secret_integration_test.go b/platform-api/internal/handler/gateway_secret_integration_test.go index a7ce9abedf..79f7bf5e18 100644 --- a/platform-api/internal/handler/gateway_secret_integration_test.go +++ b/platform-api/internal/handler/gateway_secret_integration_test.go @@ -117,7 +117,7 @@ func setupGatewaySecretTestEnv(t *testing.T) (*gatewaySecretTestEnv, func()) { cfg := &config.Server{} gwInternalSvc := service.NewGatewayInternalAPIService( - nil, nil, nil, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, deploymentRepo, gatewayRepo, nil, nil, nil, nil, secretRepo, diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index 43d6941af8..b9e34c1f05 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -259,7 +259,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, gatewayService := service.NewGatewayService(gatewayRepo, orgRepo, apiRepo, customPolicyRepo, gatewayEventsService, slogger, cfg.Gateway.EnableVersionVerification, cfg.Gateway.EnableFunctionalityTypeVerification, auditRepo, identityService) subscriptionService := service.NewSubscriptionService(apiRepo, artifactRepo, subscriptionRepo, subscriptionPlanRepo, orgRepo, gatewayEventsService, auditRepo, slogger) subscriptionPlanService := service.NewSubscriptionPlanService(subscriptionPlanRepo, gatewayRepo, orgRepo, gatewayEventsService, auditRepo, slogger) - internalGatewayService := service.NewGatewayInternalAPIService(apiRepo, subscriptionRepo, subscriptionPlanRepo, llmProviderRepo, llmProxyRepo, mcpProxyRepo, deploymentRepo, gatewayRepo, orgRepo, projectRepo, apiKeyRepo, artifactRepo, secretRepo, cfg, slogger) + internalGatewayService := service.NewGatewayInternalAPIService(apiRepo, subscriptionRepo, subscriptionPlanRepo, llmProviderRepo, llmProxyRepo, mcpProxyRepo, graphqlAPIRepo, deploymentRepo, gatewayRepo, orgRepo, projectRepo, apiKeyRepo, artifactRepo, secretRepo, cfg, slogger) apiKeyService := service.NewAPIKeyService(apiRepo, artifactRepo, apiKeyRepo, gatewayEventsService, auditRepo, cfg.Security.APIKey.HashingAlgorithms, slogger) // One definition per artifact kind, indexed by the kind the artifact row // carries. Builds and deployments are shared across kinds; rendering is the @@ -345,6 +345,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, llmTemplateRepo, llmProxyRepo, mcpProxyRepo, + graphqlAPIRepo, artifactRepo, deploymentRepo, gatewayRepo, diff --git a/platform-api/internal/service/artifact_import.go b/platform-api/internal/service/artifact_import.go index a45b249236..155191ed72 100644 --- a/platform-api/internal/service/artifact_import.go +++ b/platform-api/internal/service/artifact_import.go @@ -117,6 +117,7 @@ func NewArtifactImportService( templateRepo repository.LLMProviderTemplateRepository, proxyRepo repository.LLMProxyRepository, mcpProxyRepo repository.MCPProxyRepository, + graphqlAPIRepo repository.GraphQLAPIRepository, artifactRepo repository.ArtifactRepository, deploymentRepo repository.DeploymentRepository, gatewayRepo repository.GatewayRepository, @@ -140,6 +141,7 @@ func NewArtifactImportService( constants.LLMProviderTemplate: newLLMProviderTemplateImporter(templateRepo), constants.LLMProxy: newLLMProxyImporter(proxyRepo, providerRepo, artifactRepo), constants.MCPProxy: newMCPProxyImporter(mcpProxyRepo, artifactRepo, mcpServerInfo), + constants.GraphQLApi: newGraphQLAPIImporter(graphqlAPIRepo, artifactRepo), } return s } diff --git a/platform-api/internal/service/artifact_import_graphql.go b/platform-api/internal/service/artifact_import_graphql.go new file mode 100644 index 0000000000..0789991e6a --- /dev/null +++ b/platform-api/internal/service/artifact_import_graphql.go @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package service + +import ( + "fmt" + + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/utils" +) + +// graphqlAPIImporter imports GraphQL API artifacts (project-scoped). +type graphqlAPIImporter struct { + graphqlAPIRepo repository.GraphQLAPIRepository + artifactRepo repository.ArtifactRepository +} + +func newGraphQLAPIImporter(graphqlAPIRepo repository.GraphQLAPIRepository, artifactRepo repository.ArtifactRepository) *graphqlAPIImporter { + return &graphqlAPIImporter{graphqlAPIRepo: graphqlAPIRepo, artifactRepo: artifactRepo} +} + +func (i *graphqlAPIImporter) Kind() string { return constants.GraphQLApi } +func (i *graphqlAPIImporter) RequiresProject() bool { return true } + +func (i *graphqlAPIImporter) Import(ctx *ImportContext) (*ImportResult, error) { + version := utils.ImportVersion(ctx.Configuration) + + // The gateway pushes the artifact spec in the same shape the control plane emits + // when generating a deployment (context + upstream only — see + // generateGraphQLAPIDeploymentYAML in graphql_deployment.go). It never carries the + // schema, so SDL/introspectionMode come back empty from the decode and are + // resolved separately below, mirroring mcpProxyImporter's out-of-band capability + // fetch. + var cfg model.GraphQLAPIConfig + if err := utils.DecodeSpec(ctx.Configuration.Spec, &cfg); err != nil { + return nil, err + } + + if ctx.Existing == nil { + i.resolveSchema(&cfg) + projectID := ctx.ProjectID + graphqlAPI := &model.GraphQLAPI{ + ID: ctx.ID, + Handle: utils.ImportHandle(ctx.Configuration), + Name: utils.ImportDisplayName(ctx.Configuration), + Kind: constants.GraphQLApi, + Version: version, + ProjectID: projectID, + OrganizationID: ctx.OrgID, + Origin: constants.OriginDP, + Configuration: cfg, + } + if err := i.graphqlAPIRepo.Create(graphqlAPI); err != nil { + return nil, fmt.Errorf("failed to create GraphQL API from gateway import: %w", err) + } + return &ImportResult{ID: graphqlAPI.ID, DeployedVersion: version, Deployable: true}, nil + } + + existing, err := i.graphqlAPIRepo.GetByUUID(ctx.ID, ctx.OrgID) + if err != nil { + return nil, fmt.Errorf("failed to load existing GraphQL API: %w", err) + } + if existing == nil { + return &ImportResult{ID: ctx.ID, DeployedVersion: version, Deployable: true}, nil + } + + switch ctx.MetadataMode { + case utils.SkipWorkingCopy: + // Stale, out-of-order push: a newer deployment already defines the working copy. + return &ImportResult{ID: ctx.ID, DeployedVersion: version, Deployable: true}, nil + case utils.WriteFullMetadata: + existing.Name = utils.ImportDisplayName(ctx.Configuration) + existing.Version = version + existing.ProjectID = ctx.ProjectID + // Refresh the schema from the (possibly new) upstream alongside the rest of + // the configuration, the same as at create time. + i.resolveSchema(&cfg) + existing.Configuration = cfg + case utils.WriteGatewaySpecificOnly: + // CP-owned: only the upstream is gateway-specific data; SDL/name/etc. are not + // touched. + existing.Configuration.Upstream = cfg.Upstream + } + if err := i.graphqlAPIRepo.Update(existing); err != nil { + return nil, fmt.Errorf("failed to update GraphQL API from gateway import: %w", err) + } + return &ImportResult{ID: ctx.ID, DeployedVersion: version, Deployable: true}, nil +} + +// resolveSchema derives cfg.SDL/IntrospectionMode via the same introspection path +// CP-native create uses (fetchAndConvertGraphQLSchema, graphql_introspection.go), +// since the gateway-pushed spec never carries the schema. Best-effort, mirroring +// mcpProxyImporter.fetchCapabilities: an unreachable or misbehaving upstream must +// not fail the whole import, so a failure just leaves SDL empty rather than +// surfacing the specific reason (matches GraphQLAPIService.resolveSchema's own +// sterile-failure posture). +func (i *graphqlAPIImporter) resolveSchema(cfg *model.GraphQLAPIConfig) { + if cfg.Upstream.Main == nil || cfg.Upstream.Main.URL == "" { + return + } + derived, err := fetchAndConvertGraphQLSchema(cfg.Upstream.Main.URL) + if err != nil { + return + } + cfg.SDL = derived + cfg.IntrospectionMode = "ENDPOINT" +} diff --git a/platform-api/internal/service/artifact_import_test.go b/platform-api/internal/service/artifact_import_test.go index 9089049297..c0c18ca3b4 100644 --- a/platform-api/internal/service/artifact_import_test.go +++ b/platform-api/internal/service/artifact_import_test.go @@ -69,12 +69,13 @@ func withDeployedAt(req dto.ImportGatewayArtifactRequest, t time.Time) dto.Impor // importTestDeps bundles the import service with the repos and db needed for assertions. type importTestDeps struct { - svc *ArtifactImportService - db *database.DB - artifactRepo repository.ArtifactRepository - apiRepo repository.APIRepository - templateRepo repository.LLMProviderTemplateRepository - deployment repository.DeploymentRepository + svc *ArtifactImportService + db *database.DB + artifactRepo repository.ArtifactRepository + apiRepo repository.APIRepository + templateRepo repository.LLMProviderTemplateRepository + deployment repository.DeploymentRepository + graphqlAPIRepo repository.GraphQLAPIRepository } func setupImportTest(t *testing.T) *importTestDeps { @@ -126,21 +127,23 @@ func setupImportTest(t *testing.T) *importTestDeps { deploymentRepo := repository.NewDeploymentRepo(db, reg) gatewayRepo := repository.NewGatewayRepo(db) projectRepo := repository.NewProjectRepo(db) + graphqlAPIRepo := repository.NewGraphQLAPIRepo(db, reg) cfg := &config.Server{} cfg.Deployments.MaxPerAPIGateway = 10 logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - svc := NewArtifactImportService(apiRepo, providerRepo, templateRepo, proxyRepo, mcpProxyRepo, + svc := NewArtifactImportService(apiRepo, providerRepo, templateRepo, proxyRepo, mcpProxyRepo, graphqlAPIRepo, artifactRepo, deploymentRepo, gatewayRepo, projectRepo, cfg, logger, fakeMCPServerInfoFetcher{}) return &importTestDeps{ - svc: svc, - db: db, - artifactRepo: artifactRepo, - apiRepo: apiRepo, - templateRepo: templateRepo, - deployment: deploymentRepo, + svc: svc, + db: db, + artifactRepo: artifactRepo, + apiRepo: apiRepo, + templateRepo: templateRepo, + deployment: deploymentRepo, + graphqlAPIRepo: graphqlAPIRepo, } } @@ -206,6 +209,77 @@ func TestArtifactImport_CreateRestAPI(t *testing.T) { } } +func graphqlImportRequest(id, name, displayName string) dto.ImportGatewayArtifactRequest { + return dto.ImportGatewayArtifactRequest{ + DPID: id, + Status: "deployed", + Configuration: dto.ArtifactImportConfig{ + APIVersion: "gateway.api-platform.wso2.com/v1", + Kind: constants.GraphQLApi, + Metadata: dto.ArtifactImportMetadata{Name: name, Annotations: projectAnnotations("default")}, + Spec: map[string]interface{}{ + "displayName": displayName, + "version": "v1.0", + "context": "/countries", + // upstream is deliberately omitted so resolveSchema's introspection + // fetch short-circuits without a real network call — this test only + // asserts the artifact lands, not schema resolution. + }, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} + +func TestArtifactImport_CreateGraphQLAPI(t *testing.T) { + d := setupImportTest(t) + + const id = "55555555-5555-5555-5555-555555555555" + resp, err := d.svc.Import(importTestOrgID, importTestGatewayID, graphqlImportRequest(id, "countries-graphql", "Countries GraphQL API")) + if err != nil { + t.Fatalf("Import() error = %v", err) + } + + // The control plane mints its own UUID; it must NOT reuse the data-plane UUID. + if resp.ID == "" || resp.ID == id { + t.Errorf("response ID = %q, want a freshly generated CP UUID (not the DP UUID %q)", resp.ID, id) + } + cpID := resp.ID + if resp.Origin != constants.OriginDP { + t.Errorf("response Origin = %q, want DP", resp.Origin) + } + + // Artifact row should exist with origin DP and kind GraphQLApi under the + // CP-generated UUID. + art, err := d.artifactRepo.GetByUUID(cpID, importTestOrgID) + if err != nil || art == nil { + t.Fatalf("GetByUUID returned (%v, %v)", art, err) + } + if art.Origin != constants.OriginDP { + t.Errorf("artifact origin = %q, want DP", art.Origin) + } + if art.Type != constants.GraphQLApi { + t.Errorf("artifact kind = %q, want GraphQLApi", art.Type) + } + + graphqlAPI, err := d.graphqlAPIRepo.GetByUUID(cpID, importTestOrgID) + if err != nil || graphqlAPI == nil { + t.Fatalf("GetByUUID returned (%v, %v)", graphqlAPI, err) + } + if graphqlAPI.Kind != constants.GraphQLApi { + t.Errorf("GraphQLAPI.Kind = %q, want GraphQLApi", graphqlAPI.Kind) + } + + // Deployment status should be DEPLOYED on the gateway. + depID, status, _, err := d.deployment.GetStatus(cpID, importTestOrgID, importTestGatewayID) + if err != nil { + t.Fatalf("GetStatus error = %v", err) + } + if depID == "" || status != model.DeploymentStatusDeployed { + t.Errorf("deployment status = (%q,%q), want non-empty DEPLOYED", depID, status) + } +} + func TestArtifactImport_UnsupportedKind(t *testing.T) { d := setupImportTest(t) req := restImportRequest("22222222-2222-2222-2222-222222222222", "x", "X") @@ -726,6 +800,7 @@ func TestArtifactImport_AllSupportedKindsRegistered(t *testing.T) { constants.LLMProviderTemplate, constants.LLMProxy, constants.MCPProxy, + constants.GraphQLApi, } { importer, ok := d.svc.importers[kind] if !ok { diff --git a/platform-api/internal/service/gateway_internal.go b/platform-api/internal/service/gateway_internal.go index 11b7cec3d4..3b073b6fef 100644 --- a/platform-api/internal/service/gateway_internal.go +++ b/platform-api/internal/service/gateway_internal.go @@ -39,6 +39,7 @@ type GatewayInternalAPIService struct { providerRepo repository.LLMProviderRepository proxyRepo repository.LLMProxyRepository mcpProxyRepo repository.MCPProxyRepository + graphqlAPIRepo repository.GraphQLAPIRepository websubAPIRepo repository.WebSubAPIRepository webbrokerAPIRepo repository.WebBrokerAPIRepository deploymentRepo repository.DeploymentRepository @@ -58,7 +59,7 @@ type GatewayInternalAPIService struct { // event-gateway plugin in experimental builds via SetEventArtifactRepos. func NewGatewayInternalAPIService(apiRepo repository.APIRepository, subscriptionRepo repository.SubscriptionRepository, subscriptionPlanRepo repository.SubscriptionPlanRepository, providerRepo repository.LLMProviderRepository, - proxyRepo repository.LLMProxyRepository, mcpProxyRepo repository.MCPProxyRepository, + proxyRepo repository.LLMProxyRepository, mcpProxyRepo repository.MCPProxyRepository, graphqlAPIRepo repository.GraphQLAPIRepository, deploymentRepo repository.DeploymentRepository, gatewayRepo repository.GatewayRepository, orgRepo repository.OrganizationRepository, projectRepo repository.ProjectRepository, apiKeyRepo repository.APIKeyRepository, artifactRepo repository.ArtifactRepository, secretRepo repository.SecretRepository, cfg *config.Server, slogger *slog.Logger) *GatewayInternalAPIService { @@ -69,6 +70,7 @@ func NewGatewayInternalAPIService(apiRepo repository.APIRepository, subscription providerRepo: providerRepo, proxyRepo: proxyRepo, mcpProxyRepo: mcpProxyRepo, + graphqlAPIRepo: graphqlAPIRepo, deploymentRepo: deploymentRepo, gatewayRepo: gatewayRepo, orgRepo: orgRepo, @@ -354,6 +356,31 @@ func (s *GatewayInternalAPIService) GetActiveMCPProxyDeploymentByGateway(proxyID return proxyYamlMap, nil } +// GetActiveGraphQLAPIDeploymentByGateway retrieves the currently deployed GraphQL API artifact for a specific gateway +func (s *GatewayInternalAPIService) GetActiveGraphQLAPIDeploymentByGateway(apiID, orgID, gatewayID string) (map[string]string, error) { + graphqlAPI, err := s.graphqlAPIRepo.GetByUUID(apiID, orgID) + if err != nil { + return nil, fmt.Errorf("failed to get GraphQL API: %w", err) + } + if graphqlAPI == nil { + return nil, apperror.GraphQLAPINotFound.New() + } + + deployment, err := s.deploymentRepo.GetCurrentByGateway(graphqlAPI.ID, gatewayID, orgID) + if err != nil { + return nil, fmt.Errorf("failed to get deployment: %w", err) + } + if deployment == nil { + return nil, apperror.DeploymentNotActive.New("GraphQL API") + } + + apiYaml := string(deployment.Content) + apiYamlMap := map[string]string{ + apiID: apiYaml, + } + return apiYamlMap, nil +} + // GetActiveWebSubAPIDeploymentByGateway retrieves the currently deployed WebSub API artifact for a specific gateway func (s *GatewayInternalAPIService) GetActiveWebSubAPIDeploymentByGateway(apiID, orgID, gatewayID string) (map[string]string, error) { if s.websubAPIRepo == nil { diff --git a/platform-api/internal/service/graphql_api_test.go b/platform-api/internal/service/graphql_api_test.go index 29ef024517..59eefdeb1f 100644 --- a/platform-api/internal/service/graphql_api_test.go +++ b/platform-api/internal/service/graphql_api_test.go @@ -323,7 +323,7 @@ func TestGraphQLCreate_WithIntrospection_Success(t *testing.T) { // 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 +// same shared-client policy as MCP and as sdlUrl's own fetcher), so a local // httptest.Server genuinely exercises this path rather than tripping an SSRF // block first. func TestGraphQLCreate_IntrospectionFailure_UnprocessableEntity(t *testing.T) { diff --git a/platform-api/internal/service/graphql_deployment.go b/platform-api/internal/service/graphql_deployment.go index 8e27ea987b..075f52829d 100644 --- a/platform-api/internal/service/graphql_deployment.go +++ b/platform-api/internal/service/graphql_deployment.go @@ -105,6 +105,13 @@ func generateGraphQLAPIDeploymentYAML(apiModel *model.GraphQLAPI) (dto.GraphQLAP Auth: main.Auth, // raw model.UpstreamAuth — the gateway needs the real credential, unlike API read responses }, } + if sandbox := apiModel.Configuration.Upstream.Sandbox; sandbox != nil { + upstream.Sandbox = &dto.GraphQLUpstreamTarget{ + URL: sandbox.URL, + Ref: sandbox.Ref, + Auth: sandbox.Auth, + } + } } contextValue := "" diff --git a/platform-api/internal/service/graphql_deployment_test.go b/platform-api/internal/service/graphql_deployment_test.go index 80db15e7eb..43aa8f6e10 100644 --- a/platform-api/internal/service/graphql_deployment_test.go +++ b/platform-api/internal/service/graphql_deployment_test.go @@ -217,6 +217,108 @@ func TestGenerateGraphQLAPIDeploymentYAML_CarriesUpstreamAuth(t *testing.T) { } } +// TestGenerateGraphQLAPIDeploymentYAML_CarriesSandboxAuth is the sandbox +// counterpart to TestGenerateGraphQLAPIDeploymentYAML_CarriesUpstreamAuth, +// asserting the generator itself (not just the full deploy flow) populates +// spec.upstream.sandbox from apiModel.Configuration.Upstream.Sandbox. +func TestGenerateGraphQLAPIDeploymentYAML_CarriesSandboxAuth(t *testing.T) { + ctx := "/countries" + apiModel := &model.GraphQLAPI{ + ID: "gql-uuid-1", + Handle: "countries-graphql-api", + Name: "Countries GraphQL API", + Version: "v1.0", + Configuration: model.GraphQLAPIConfig{ + Context: &ctx, + Upstream: model.UpstreamConfig{ + Main: &model.UpstreamEndpoint{URL: "https://countries.example.com/graphql"}, + Sandbox: &model.UpstreamEndpoint{ + URL: "https://sandbox.countries.example.com/graphql", + Auth: &model.UpstreamAuth{ + Type: "bearer", + Header: "Authorization", + Value: "sandbox-secret-value", + }, + }, + }, + }, + } + + yamlData, err := generateGraphQLAPIDeploymentYAML(apiModel) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if yamlData.Spec.Upstream == nil || yamlData.Spec.Upstream.Sandbox == nil { + t.Fatal("expected spec.upstream.sandbox to be populated") + } + sandbox := yamlData.Spec.Upstream.Sandbox + if sandbox.URL != "https://sandbox.countries.example.com/graphql" { + t.Errorf("sandbox.url = %q, want the configured sandbox URL", sandbox.URL) + } + if sandbox.Auth == nil { + t.Fatal("expected upstream.sandbox.auth to be carried through into the deployment YAML, got nil") + } + if sandbox.Auth.Type != "bearer" || sandbox.Auth.Header != "Authorization" || sandbox.Auth.Value != "sandbox-secret-value" { + t.Errorf("upstream.sandbox.auth was not carried through unmodified: %+v", sandbox.Auth) + } +} + +// TestGenerateGraphQLAPIDeploymentYAML_CarriesSandboxUpstream pins the fix for +// the gap where GraphQLUpstream had only a Main field: upstream.sandbox is a +// genuinely supported concept everywhere else (the gateway OpenAPI spec's +// GraphQLAPIConfigData.Upstream.Sandbox, GraphQLAPITransformer's sandbox +// route, and the CP's own read-response round-trip in +// TestGraphQLUpstreamAuth_RedactedAcrossAllResponseShapes), but the +// deployment YAML generator silently dropped it before it ever reached the +// gateway. This exercises the full DeployGraphQLAPI path (not just the +// generator) so it also proves gatewaytranslator.Translate still succeeds +// with a sandbox upstream present. +func TestGenerateGraphQLAPIDeploymentYAML_CarriesSandboxUpstream(t *testing.T) { + ctx := "/countries" + stored := &model.GraphQLAPI{ + ID: "gql-uuid-1", + Handle: "countries-graphql-api", + OrganizationID: "org-1", + Name: "Countries GraphQL API", + Version: "v1.0", + Configuration: model.GraphQLAPIConfig{ + SDL: validCountriesGraphQLSDL, + Context: &ctx, + Upstream: model.UpstreamConfig{ + Main: &model.UpstreamEndpoint{URL: "https://countries.example.com/graphql"}, + Sandbox: &model.UpstreamEndpoint{ + URL: "https://sandbox.countries.example.com/graphql", + Auth: &model.UpstreamAuth{ + Type: "bearer", + Header: "Authorization", + Value: "sandbox-secret", + }, + }, + }, + }, + } + repo := &mockGraphQLAPIRepo{ + getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, + } + gateway := graphQLDeploymentTestGateway() + gatewayRepo := &mockGatewayRepository{getByNameResult: gateway, getByUUIDResult: gateway} + deploymentRepo := &mockDeploymentRepo{setCurrentUpdatedAt: time.Now()} + + svc := newGraphQLDeploymentTestService(repo, deploymentRepo, gatewayRepo) + + req := &api.DeployRequest{Name: "prod-deployment", Base: "current", GatewayId: "prod-gateway"} + if _, err := svc.DeployGraphQLAPI("countries-graphql-api", req, "org-1", "creator-uuid"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if deploymentRepo.createdDeployment == nil { + t.Fatal("expected a deployment to be created") + } + content := string(deploymentRepo.createdDeployment.Content) + if !strings.Contains(content, "sandbox.countries.example.com") { + t.Errorf("expected deployment content to contain spec.upstream.sandbox.url, got:\n%s", content) + } +} + // TestGraphQLUndeployDeployment_Success verifies an active deployment // transitions to UNDEPLOYING when the bound gateway matches the request. func TestGraphQLUndeployDeployment_Success(t *testing.T) { diff --git a/platform-api/internal/service/graphql_introspection.go b/platform-api/internal/service/graphql_introspection.go index 26f87fd417..9cbdd2abd6 100644 --- a/platform-api/internal/service/graphql_introspection.go +++ b/platform-api/internal/service/graphql_introspection.go @@ -19,6 +19,7 @@ package service import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -205,7 +206,15 @@ func fetchAndConvertGraphQLSchema(upstreamURL string) (string, error) { return "", fmt.Errorf("failed to build introspection request: %w", err) } - httpReq, err := http.NewRequest(http.MethodPost, upstreamURL, bytes.NewReader(body)) + // The shared client's own Timeouts.Overall is a safety net only (see + // NewUpstreamFetchClient's doc comment) — this call's real budget is enforced + // via the request context here, the same self-contained + // context.WithTimeout(context.Background(), ...) pattern CheckURLReachability + // uses for its own one-shot outbound probe (common.go). + ctx, cancel := context.WithTimeout(context.Background(), graphQLIntrospectionTimeout) + defer cancel() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body)) if err != nil { return "", fmt.Errorf("failed to build introspection request: %w", err) } @@ -217,8 +226,10 @@ func fetchAndConvertGraphQLSchema(upstreamURL string) (string, error) { // 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. + // policy is the correct one here. FetchOpenAPISpecFromURL (used to + // resolve sdlUrl) goes through this same shared client and policy — it + // is not a stricter public-only fetch; only isPublicIP/ValidateExternalURL + // (used for LLM provider endpoint validation) enforce that bar. client, err := utils.NewUpstreamFetchClient(graphQLIntrospectionTimeout) if err != nil { return "", fmt.Errorf("failed to create HTTP client: %w", err) diff --git a/platform-api/internal/utils/common.go b/platform-api/internal/utils/common.go index 79967caa54..0699fbfa97 100644 --- a/platform-api/internal/utils/common.go +++ b/platform-api/internal/utils/common.go @@ -179,6 +179,25 @@ func CreateBatchDeploymentTarGz(deploymentContentMap map[string]*model.Deploymen return buf.Bytes(), nil } +// CreateGraphQLAPIYamlZip creates a ZIP file containing GraphQL API YAML files +func CreateGraphQLAPIYamlZip(apiYamlMap map[string]string) ([]byte, error) { + var buf bytes.Buffer + zipWriter := zip.NewWriter(&buf) + + for apiID, yamlContent := range apiYamlMap { + fileName := fmt.Sprintf("graphql-api-%s.yaml", apiID) + if err := addFileToZip(zipWriter, fileName, []byte(yamlContent)); err != nil { + return nil, err + } + } + + if err := zipWriter.Close(); err != nil { + return nil, fmt.Errorf("failed to close zip writer: %w", err) + } + + return buf.Bytes(), nil +} + // CreateWebSubAPIYamlZip creates a ZIP file containing WebSub API YAML files func CreateWebSubAPIYamlZip(apiYamlMap map[string]string) ([]byte, error) { var buf bytes.Buffer diff --git a/platform-api/internal/utils/import_artifacts.go b/platform-api/internal/utils/import_artifacts.go index bb71c01f10..a31bd2c550 100644 --- a/platform-api/internal/utils/import_artifacts.go +++ b/platform-api/internal/utils/import_artifacts.go @@ -134,6 +134,7 @@ var artifactImportOrder = map[string]int{ constants.RestApi: 4, constants.WebSubApi: 5, constants.WebBrokerApi: 6, + constants.GraphQLApi: 7, } // ArtifactImportRank returns the creation-order rank for a kind; unknown kinds sort last. diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 4d30e8ac1d..e5ebb53055 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -8963,9 +8963,13 @@ components: 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 + is fetched through the same shared SSRF-guarded HTTP client every other + operator/tenant-supplied fetch in this API uses, under the operator- + configured policy (default `netguard.PermitPrivateBlockMetadata()`): the + host is resolved and every candidate IP — including each redirect hop — + is checked at dial time, refusing link-local/metadata/unspecified/ + multicast addresses while private and in-cluster addresses (a Kubernetes + ClusterIP, a service-DNS name, localhost) remain reachable. 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 From 123c3c1dedda14bb7cd8fd9f2494f213f3db12a5 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Mon, 31 Aug 2026 11:49:29 +0530 Subject: [PATCH 14/38] Wire up upstream.ref rejection --- .../pkg/utils/graphql_deployment.go | 7 ++ .../pkg/utils/graphql_deployment_test.go | 87 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 gateway/gateway-controller/pkg/utils/graphql_deployment_test.go diff --git a/gateway/gateway-controller/pkg/utils/graphql_deployment.go b/gateway/gateway-controller/pkg/utils/graphql_deployment.go index c5a100c1da..29dd3c46d9 100644 --- a/gateway/gateway-controller/pkg/utils/graphql_deployment.go +++ b/gateway/gateway-controller/pkg/utils/graphql_deployment.go @@ -118,6 +118,13 @@ func validateGraphQLUpstream(label string, up *api.Upstream) []config.Validation return errors } + if up.Ref != nil && strings.TrimSpace(*up.Ref) != "" { + errors = append(errors, config.ValidationError{ + Field: "spec.upstream." + label + ".ref", + Message: "Upstream ref is not supported for GraphQLApi (no upstreamDefinitions list); use a direct url", + }) + } + if up.Url == nil || strings.TrimSpace(*up.Url) == "" { errors = append(errors, config.ValidationError{ Field: "spec.upstream." + label + ".url", diff --git a/gateway/gateway-controller/pkg/utils/graphql_deployment_test.go b/gateway/gateway-controller/pkg/utils/graphql_deployment_test.go new file mode 100644 index 0000000000..8602226a92 --- /dev/null +++ b/gateway/gateway-controller/pkg/utils/graphql_deployment_test.go @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package utils + +import ( + "testing" + + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/config" +) + +func graphqlUpstreamStrPtr(s string) *string { return &s } + +// containsFieldError reports whether errs has a ValidationError for the given field. +func containsFieldError(errs []config.ValidationError, field string) bool { + for _, e := range errs { + if e.Field == field { + return true + } + } + return false +} + +// TestValidateGraphQLUpstream_RefOnly pins the fix for the gap where a +// ref-only upstream produced only the misleading "Upstream URL is required" +// error, with nothing telling the caller that ref itself is unsupported for +// GraphQLApi (it has no upstreamDefinitions list to resolve it against). +func TestValidateGraphQLUpstream_RefOnly(t *testing.T) { + up := &api.Upstream{Ref: graphqlUpstreamStrPtr("some-def")} + + errs := validateGraphQLUpstream("main", up) + + if !containsFieldError(errs, "spec.upstream.main.ref") { + t.Errorf("expected a spec.upstream.main.ref error for a ref-only upstream, got: %+v", errs) + } + if !containsFieldError(errs, "spec.upstream.main.url") { + t.Errorf("expected a spec.upstream.main.url error (missing) for a ref-only upstream, got: %+v", errs) + } +} + +// TestValidateGraphQLUpstream_URLAndRef_RefRejected pins the other half of the +// gap: url+ref together used to be accepted outright, with ref silently +// ignored downstream by resolveUpstreamURL's early return on a non-empty Url +// (transform/restapi.go). A ref alongside a valid url must still be rejected. +func TestValidateGraphQLUpstream_URLAndRef_RefRejected(t *testing.T) { + up := &api.Upstream{ + Url: graphqlUpstreamStrPtr("http://backend.example.com:8080/graphql"), + Ref: graphqlUpstreamStrPtr("some-def"), + } + + errs := validateGraphQLUpstream("main", up) + + if !containsFieldError(errs, "spec.upstream.main.ref") { + t.Errorf("expected a spec.upstream.main.ref error when ref is set alongside a valid url, got: %+v", errs) + } + if containsFieldError(errs, "spec.upstream.main.url") { + t.Errorf("did not expect a url error when url is valid, got: %+v", errs) + } +} + +// TestValidateGraphQLUpstream_URLOnly_NoRefError is the control case: a +// direct url with no ref must produce no errors at all. +func TestValidateGraphQLUpstream_URLOnly_NoRefError(t *testing.T) { + up := &api.Upstream{Url: graphqlUpstreamStrPtr("https://backend.example.com/graphql")} + + errs := validateGraphQLUpstream("main", up) + + if len(errs) != 0 { + t.Errorf("expected no validation errors for a valid direct url, got: %+v", errs) + } +} From 83d53901225b48a9dce6f416465382b848526ffe Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Mon, 31 Aug 2026 11:55:11 +0530 Subject: [PATCH 15/38] Document GraphQL cors preflight limitation in both OpenAPI specs --- .../api/management-openapi.yaml | 8 +- .../pkg/api/management/generated.go | 793 +++++++++--------- platform-api/api/generated.go | 44 +- platform-api/resources/openapi.yaml | 18 +- 4 files changed, 458 insertions(+), 405 deletions(-) diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index 185cc6e95a..833198d6b6 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -4079,7 +4079,13 @@ components: example: ["Gold", "Silver"] policies: type: array - description: List of policies applied to the single GraphQL route + description: | + List of policies applied to the single GraphQL route. A `cors` + policy applies only to that route's `POST` method — a GraphQLApi + has no operations[] list to add an `OPTIONS` entry to, so a + browser preflight request is not routed at all and a `cors` + policy will not run for it; cross-origin browser clients that + trigger a preflight are not currently supported. items: $ref: "#/components/schemas/Policy" deploymentState: diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index 7530784af5..b3cbe183f9 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -1106,7 +1106,12 @@ type GraphQLAPIConfigData struct { // 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 List of policies applied to the single GraphQL route. A `cors` + // policy applies only to that route's `POST` method — a GraphQLApi + // has no operations[] list to add an `OPTIONS` entry to, so a + // browser preflight request is not routed at all and a `cors` + // policy will not run for it; cross-origin browser clients that + // trigger a preflight are not currently supported. Policies *[]Policy `json:"policies,omitempty" yaml:"policies,omitempty"` // SubscriptionPlans List of subscription plan names available for this API @@ -6122,399 +6127,401 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index d8cf689ad0..0ecb080d0b 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -996,7 +996,13 @@ type CreateGraphQLAPIRequest struct { // 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 List of policies to be applied on the API. Reused unmodified from + // REST APIs. A `cors` policy applies only to the API's single `POST` + // route — a GraphQL API has no per-operation list to add an + // `OPTIONS` entry to, so a browser preflight request is not routed + // at all and a `cors` policy will not run for it; cross-origin + // browser clients that trigger a preflight are not currently + // supported. Policies *[]Policy `json:"policies,omitempty" yaml:"policies,omitempty"` ProjectId string `binding:"required" json:"projectId" yaml:"projectId"` @@ -1019,9 +1025,13 @@ type CreateGraphQLAPIRequest struct { // 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 + // is fetched through the same shared SSRF-guarded HTTP client every other + // operator/tenant-supplied fetch in this API uses, under the operator- + // configured policy (default `netguard.PermitPrivateBlockMetadata()`): the + // host is resolved and every candidate IP — including each redirect hop — + // is checked at dial time, refusing link-local/metadata/unspecified/ + // multicast addresses while private and in-cluster addresses (a Kubernetes + // ClusterIP, a service-DNS name, localhost) remain reachable. 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"` @@ -1575,7 +1585,13 @@ type GraphQLAPI struct { // 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 List of policies to be applied on the API. Reused unmodified from + // REST APIs. A `cors` policy applies only to the API's single `POST` + // route — a GraphQL API has no per-operation list to add an + // `OPTIONS` entry to, so a browser preflight request is not routed + // at all and a `cors` policy will not run for it; cross-origin + // browser clients that trigger a preflight are not currently + // supported. Policies *[]Policy `json:"policies,omitempty" yaml:"policies,omitempty"` ProjectId string `binding:"required" json:"projectId" yaml:"projectId"` @@ -1598,9 +1614,13 @@ type GraphQLAPI struct { // 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 + // is fetched through the same shared SSRF-guarded HTTP client every other + // operator/tenant-supplied fetch in this API uses, under the operator- + // configured policy (default `netguard.PermitPrivateBlockMetadata()`): the + // host is resolved and every candidate IP — including each redirect hop — + // is checked at dial time, refusing link-local/metadata/unspecified/ + // multicast addresses while private and in-cluster addresses (a Kubernetes + // ClusterIP, a service-DNS name, localhost) remain reachable. 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"` @@ -1642,7 +1662,13 @@ type GraphQLAPIDetail struct { // 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 List of policies to be applied on the API. Reused unmodified from + // REST APIs. A `cors` policy applies only to the API's single `POST` + // route — a GraphQL API has no per-operation list to add an + // `OPTIONS` entry to, so a browser preflight request is not routed + // at all and a `cors` policy will not run for it; cross-origin + // browser clients that trigger a preflight are not currently + // supported. Policies *[]Policy `json:"policies,omitempty" yaml:"policies,omitempty"` ProjectId string `binding:"required" json:"projectId" yaml:"projectId"` diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index e5ebb53055..3179c6e1b4 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -8985,7 +8985,14 @@ components: example: ENDPOINT policies: type: array - description: List of policies to be applied on the API. Reused unmodified from REST APIs. + description: | + List of policies to be applied on the API. Reused unmodified from + REST APIs. A `cors` policy applies only to the API's single `POST` + route — a GraphQL API has no per-operation list to add an + `OPTIONS` entry to, so a browser preflight request is not routed + at all and a `cors` policy will not run for it; cross-origin + browser clients that trigger a preflight are not currently + supported. items: $ref: '#/components/schemas/Policy' subscriptionPlans: @@ -9096,7 +9103,14 @@ components: example: ENDPOINT policies: type: array - description: List of policies to be applied on the API. Reused unmodified from REST APIs. + description: | + List of policies to be applied on the API. Reused unmodified from + REST APIs. A `cors` policy applies only to the API's single `POST` + route — a GraphQL API has no per-operation list to add an + `OPTIONS` entry to, so a browser preflight request is not routed + at all and a `cors` policy will not run for it; cross-origin + browser clients that trigger a preflight are not currently + supported. items: $ref: '#/components/schemas/Policy' subscriptionPlans: From 0df3046051b2b30a798379833861b9aace3da3dc Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Mon, 31 Aug 2026 11:57:44 +0530 Subject: [PATCH 16/38] gofmt: fix struct field/tag alignment and trailing blank line --- cli/src/cmd/gateway/apply.go | 1 - platform-api/internal/service/graphql_api.go | 16 ++++++++-------- .../internal/service/graphql_introspection.go | 16 ++++++++-------- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/cli/src/cmd/gateway/apply.go b/cli/src/cmd/gateway/apply.go index e8b7273964..2989e94faf 100644 --- a/cli/src/cmd/gateway/apply.go +++ b/cli/src/cmd/gateway/apply.go @@ -243,4 +243,3 @@ 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/platform-api/internal/service/graphql_api.go b/platform-api/internal/service/graphql_api.go index db842da7b8..d28d67379b 100644 --- a/platform-api/internal/service/graphql_api.go +++ b/platform-api/internal/service/graphql_api.go @@ -225,14 +225,14 @@ func (s *GraphQLAPIService) Create(orgUUID, createdBy string, req *api.CreateGra 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, + 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, diff --git a/platform-api/internal/service/graphql_introspection.go b/platform-api/internal/service/graphql_introspection.go index 9cbdd2abd6..95f1ccd4e4 100644 --- a/platform-api/internal/service/graphql_introspection.go +++ b/platform-api/internal/service/graphql_introspection.go @@ -156,14 +156,14 @@ type graphQLIntrospectionTypeRef struct { } 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"` + 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 { From 88654af67fdd4110459c2fd13b7fbd3de9160bc6 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Mon, 31 Aug 2026 11:59:18 +0530 Subject: [PATCH 17/38] Fix comment citing a nonexistent CLI constant for the SDL upload limit --- platform-api/internal/utils/graphql_multipart.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/platform-api/internal/utils/graphql_multipart.go b/platform-api/internal/utils/graphql_multipart.go index fe3e493711..db186b16e7 100644 --- a/platform-api/internal/utils/graphql_multipart.go +++ b/platform-api/internal/utils/graphql_multipart.go @@ -26,10 +26,9 @@ import ( ) 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 bounds an uploaded SDL file at 5 MiB, matching + // defaultOpenAPISpecMaxFetchBytes (openapi_spec_fetcher.go) so the ceiling + // is the same whether the schema arrives as a file upload or via sdlUrl. maxGraphQLSDLUploadBytes = 5 << 20 // maxGraphQLMultipartRequestBytes bounds the whole multipart request body From 451f3db65a6a6259dab9dd2947e9e7509d611eb7 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Mon, 31 Aug 2026 12:02:47 +0530 Subject: [PATCH 18/38] Stop aliasing one PolicyChain pointer across GraphQL's main and sandbox routes --- gateway/gateway-controller/pkg/transform/graphql.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/gateway/gateway-controller/pkg/transform/graphql.go b/gateway/gateway-controller/pkg/transform/graphql.go index f8bd6df692..56f5eee443 100644 --- a/gateway/gateway-controller/pkg/transform/graphql.go +++ b/gateway/gateway-controller/pkg/transform/graphql.go @@ -94,7 +94,6 @@ func (t *GraphQLAPITransformer) Transform(cfg *models.StoredConfig) (*models.Run 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, @@ -130,7 +129,7 @@ func (t *GraphQLAPITransformer) Transform(cfg *models.StoredConfig) (*models.Run Default: &mainUpstreamInfo, }, } - rdc.PolicyChains[mainRouteKey] = policyChain + rdc.PolicyChains[mainRouteKey] = sdkChainToModel(injected) // Sandbox is active when a sandbox upstream is configured (GraphQLApi only // supports a direct url — see validateGraphQLUpstream — never a ref). @@ -166,7 +165,7 @@ func (t *GraphQLAPITransformer) Transform(cfg *models.StoredConfig) (*models.Run Default: &sbUpstreamInfo, }, } - rdc.PolicyChains[sandboxRouteKey] = policyChain + rdc.PolicyChains[sandboxRouteKey] = sdkChainToModel(injected) } return rdc, nil From 983486e5edcb2c96faef95a8a0f64400c7c2f278 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Mon, 31 Aug 2026 12:04:34 +0530 Subject: [PATCH 19/38] Fix comment overstating that /graphql-apis routes are authorized here --- .../gateway-controller/cmd/controller/main.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/event-gateway/gateway-controller/cmd/controller/main.go b/event-gateway/gateway-controller/cmd/controller/main.go index f557928de0..2aa207237a 100644 --- a/event-gateway/gateway-controller/cmd/controller/main.go +++ b/event-gateway/gateway-controller/cmd/controller/main.go @@ -410,10 +410,14 @@ func main() { // 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. + // /graphql-apis CRUD and api-key routes are served here by the shared + // *handlers.APIServer, but (like /rest-apis) they are not listed in this + // binary's generateAuthConfig role map below, so they 403 when auth is + // enabled (see common/authenticators/authz.go's deny-on-unlisted-route + // behavior) — reachability here means routable, not authorized. Without a + // transformer wired in, a created GraphQLApi would accept and store but + // silently fail to ever deploy; build one exactly the way restTransformer + // is built above so it actually can. graphqlTransformer := transform.NewGraphQLAPITransformer(&cfg.Router, cfg, policyDefinitions) transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer, agentTransformer, graphqlTransformer) From 74b9c05f9ef840ffc9f4ce4cf743468820a98273 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Mon, 31 Aug 2026 12:11:57 +0530 Subject: [PATCH 20/38] Honor sortBy/sortOrder/query on GET /graphql-apis instead of ignoring them --- platform-api/internal/handler/graphql_api.go | 4 +- .../internal/repository/graphql_api.go | 61 +++++----- .../internal/repository/graphql_api_test.go | 111 +++++++++++++++++- .../internal/repository/interfaces.go | 4 +- platform-api/internal/service/graphql_api.go | 10 +- .../internal/service/graphql_api_test.go | 22 ++-- 6 files changed, 161 insertions(+), 51 deletions(-) diff --git a/platform-api/internal/handler/graphql_api.go b/platform-api/internal/handler/graphql_api.go index 0e96882a53..d39057e4f1 100644 --- a/platform-api/internal/handler/graphql_api.go +++ b/platform-api/internal/handler/graphql_api.go @@ -154,9 +154,9 @@ func (h *GraphQLAPIHandler) ListGraphQLAPIs(w http.ResponseWriter, r *http.Reque return apperror.ValidationFailed.New("projectId query parameter is required") } - limit, offset := parsePagination(r) + opts := parseListOptions(r) - resp, err := h.graphqlAPIService.List(orgId, projectId, limit, offset) + resp, err := h.graphqlAPIService.List(orgId, projectId, opts) if err != nil { return serviceError(err, fmt.Sprintf("failed to get GraphQL APIs for project %s in org %s", projectId, orgId)) } diff --git a/platform-api/internal/repository/graphql_api.go b/platform-api/internal/repository/graphql_api.go index 3c2bc79d91..9ffb3aea09 100644 --- a/platform-api/internal/repository/graphql_api.go +++ b/platform-api/internal/repository/graphql_api.go @@ -129,32 +129,29 @@ func (r *GraphQLAPIRepo) GetByUUID(uuid, orgUUID string) (*model.GraphQLAPI, err } // 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) +func (r *GraphQLAPIRepo) List(orgUUID, projectUUID string, opts ListOptions) ([]*model.GraphQLAPI, error) { + query := ` + SELECT + uuid, handle, display_name, version, organization_uuid, origin, created_at, updated_at, + project_uuid, description, created_by, updated_by, configuration, data_version + FROM graphql_apis + WHERE organization_uuid = ?` + args := []interface{}{orgUUID} if projectUUID != "" { - query = ` - 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...) + query += " AND project_uuid = ?" + args = append(args, projectUUID) } + if searchClause, searchArgs := handleSearchClause(opts.Search); searchClause != "" { + query += searchClause + args = append(args, searchArgs...) + } + col, dir := opts.resolveSort(listSortColumns, "created_at") + query += " ORDER BY " + col + " " + dir + ", handle ASC" + + pageClause, pageArgs := r.db.PaginationClause(opts.Limit, opts.Offset) + query += " " + pageClause + args = append(args, pageArgs...) rows, err := r.db.Query(r.db.Rebind(query), args...) if err != nil { @@ -178,13 +175,19 @@ 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) { +// CountByProject returns the total number of GraphQL APIs for a specific +// project, optionally narrowed by the same case-insensitive handle search +// List applies. +func (r *GraphQLAPIRepo) CountByProject(orgUUID, projectUUID, search string) (int, error) { + query := `SELECT COUNT(*) FROM graphql_apis WHERE organization_uuid = ? AND project_uuid = ?` + args := []interface{}{orgUUID, projectUUID} + if searchClause, searchArgs := handleSearchClause(search); searchClause != "" { + query += searchClause + args = append(args, searchArgs...) + } + var count int - 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 { + if err := r.db.QueryRow(r.db.Rebind(query), args...).Scan(&count); err != nil { return 0, err } return count, nil diff --git a/platform-api/internal/repository/graphql_api_test.go b/platform-api/internal/repository/graphql_api_test.go index 4160d0002a..e1a48ada66 100644 --- a/platform-api/internal/repository/graphql_api_test.go +++ b/platform-api/internal/repository/graphql_api_test.go @@ -291,7 +291,7 @@ func TestGraphQLAPIRepo_List(t *testing.T) { t.Fatalf("Create failed: %v", err) } - all, err := repo.List(orgUUID, "", 100, 0) + all, err := repo.List(orgUUID, "", ListOptions{Limit: 100, Offset: 0}) if err != nil { t.Fatalf("List (no project filter) failed: %v", err) } @@ -299,7 +299,7 @@ func TestGraphQLAPIRepo_List(t *testing.T) { t.Fatalf("expected 2 GraphQL APIs for org, got %d", len(all)) } - filtered, err := repo.List(orgUUID, projectUUID, 100, 0) + filtered, err := repo.List(orgUUID, projectUUID, ListOptions{Limit: 100, Offset: 0}) if err != nil { t.Fatalf("List (project filter) failed: %v", err) } @@ -309,7 +309,7 @@ func TestGraphQLAPIRepo_List(t *testing.T) { otherOrg := "org-graphql-list-002" createTestOrganizationAndProject(t, db, otherOrg, "project-graphql-list-other-org") - emptyList, err := repo.List(otherOrg, "", 100, 0) + emptyList, err := repo.List(otherOrg, "", ListOptions{Limit: 100, Offset: 0}) if err != nil { t.Fatalf("List for a different org failed: %v", err) } @@ -341,7 +341,7 @@ func TestGraphQLAPIRepo_List_PaginationBoundaries(t *testing.T) { } } - page1, err := repo.List(orgUUID, "", 1, 0) + page1, err := repo.List(orgUUID, "", ListOptions{Limit: 1, Offset: 0}) if err != nil { t.Fatalf("List (limit=1, offset=0) failed: %v", err) } @@ -349,7 +349,7 @@ func TestGraphQLAPIRepo_List_PaginationBoundaries(t *testing.T) { t.Fatalf("expected page 1 = [page-graphql-c], got %+v", page1) } - page2, err := repo.List(orgUUID, "", 1, 1) + page2, err := repo.List(orgUUID, "", ListOptions{Limit: 1, Offset: 1}) if err != nil { t.Fatalf("List (limit=1, offset=1) failed: %v", err) } @@ -357,7 +357,7 @@ func TestGraphQLAPIRepo_List_PaginationBoundaries(t *testing.T) { t.Fatalf("expected page 2 = [page-graphql-b], got %+v", page2) } - pastEnd, err := repo.List(orgUUID, "", 10, 3) + pastEnd, err := repo.List(orgUUID, "", ListOptions{Limit: 10, Offset: 3}) if err != nil { t.Fatalf("List (offset past the end) failed: %v", err) } @@ -366,6 +366,105 @@ func TestGraphQLAPIRepo_List_PaginationBoundaries(t *testing.T) { } } +// TestGraphQLAPIRepo_List_Search pins the fix for the gap where List/ +// CountByProject silently ignored the spec's documented query parameter: a +// search with no matching handle must return an empty result and a total of +// 0, not the whole unfiltered collection. +func TestGraphQLAPIRepo_List_Search(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-search-001" + projectUUID := "project-graphql-search-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + if err := repo.Create(newTestGraphQLAPI("countries-graphql-api", orgUUID, projectUUID)); err != nil { + t.Fatalf("Create failed: %v", err) + } + + matched, err := repo.List(orgUUID, projectUUID, ListOptions{Limit: 100, Search: "countries"}) + if err != nil { + t.Fatalf("List (matching search) failed: %v", err) + } + if len(matched) != 1 { + t.Fatalf("expected 1 match for a search matching the handle, got %d", len(matched)) + } + + noMatch, err := repo.List(orgUUID, projectUUID, ListOptions{Limit: 100, Search: "zzz-no-match"}) + if err != nil { + t.Fatalf("List (non-matching search) failed: %v", err) + } + if len(noMatch) != 0 { + t.Fatalf("expected an empty result for a non-matching search, got %+v", noMatch) + } + + total, err := repo.CountByProject(orgUUID, projectUUID, "zzz-no-match") + if err != nil { + t.Fatalf("CountByProject (non-matching search) failed: %v", err) + } + if total != 0 { + t.Fatalf("expected total 0 for a non-matching search, got %d", total) + } +} + +// TestGraphQLAPIRepo_List_SortBy pins sortBy=name changing the ordering +// (previously always ORDER BY created_at DESC regardless of the request), +// and that an unrecognized sortBy token falls back to the default order +// (matching the shared allowlist's documented fallback behavior) rather than +// erroring. +func TestGraphQLAPIRepo_List_SortBy(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + repo := NewGraphQLAPIRepo(db, NewArtifactTableRegistry()) + + orgUUID := "org-graphql-sort-001" + projectUUID := "project-graphql-sort-001" + createTestOrganizationAndProject(t, db, orgUUID, projectUUID) + + fixtures := []struct{ handle, name string }{ + {"sort-graphql-a", "Charlie API"}, + {"sort-graphql-b", "Alpha API"}, + {"sort-graphql-c", "Bravo API"}, + } + for _, f := range fixtures { + a := newTestGraphQLAPI(f.handle, orgUUID, projectUUID) + a.Name = f.name + if err := repo.Create(a); err != nil { + t.Fatalf("Create %s failed: %v", f.handle, err) + } + } + + byNameAsc, err := repo.List(orgUUID, projectUUID, ListOptions{Limit: 100, SortBy: "name", SortOrder: "asc"}) + if err != nil { + t.Fatalf("List (sortBy=name, asc) failed: %v", err) + } + if len(byNameAsc) != 3 || byNameAsc[0].Name != "Alpha API" || byNameAsc[1].Name != "Bravo API" || byNameAsc[2].Name != "Charlie API" { + names := make([]string, len(byNameAsc)) + for i, a := range byNameAsc { + names[i] = a.Name + } + t.Fatalf("expected [Alpha API, Bravo API, Charlie API], got %v", names) + } + + // An unrecognized sortBy token must fall back to the default order + // (created_at) rather than erroring or being interpolated into SQL — + // creation order here is a, b, c, so default DESC order is c, b, a. + fallback, err := repo.List(orgUUID, projectUUID, ListOptions{Limit: 100, SortBy: "not-a-real-column"}) + if err != nil { + t.Fatalf("List (unrecognized sortBy) failed: %v", err) + } + if len(fallback) != 3 || fallback[0].Handle != "sort-graphql-c" || fallback[1].Handle != "sort-graphql-b" || fallback[2].Handle != "sort-graphql-a" { + handles := make([]string, len(fallback)) + for i, a := range fallback { + handles[i] = a.Handle + } + t.Fatalf("expected fallback to default created_at DESC order [c, b, a], got %v", handles) + } +} + func TestGraphQLAPIRepo_Update(t *testing.T) { db, cleanup := setupTestDB(t) t.Cleanup(cleanup) diff --git a/platform-api/internal/repository/interfaces.go b/platform-api/internal/repository/interfaces.go index 9fb353b6d5..3801baaf49 100644 --- a/platform-api/internal/repository/interfaces.go +++ b/platform-api/internal/repository/interfaces.go @@ -423,9 +423,9 @@ 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) + List(orgUUID, projectUUID string, opts ListOptions) ([]*model.GraphQLAPI, error) Count(orgUUID string) (int, error) - CountByProject(orgUUID, projectUUID string) (int, error) + CountByProject(orgUUID, projectUUID, search string) (int, error) Update(a *model.GraphQLAPI) error Delete(handle, orgUUID string) error Exists(handle, orgUUID string) (bool, error) diff --git a/platform-api/internal/service/graphql_api.go b/platform-api/internal/service/graphql_api.go index d28d67379b..d1f7df4988 100644 --- a/platform-api/internal/service/graphql_api.go +++ b/platform-api/internal/service/graphql_api.go @@ -375,7 +375,7 @@ func (s *GraphQLAPIService) GetSDL(orgUUID, handle string) (string, error) { } // List retrieves GraphQL APIs for an organization, filtered by project. -func (s *GraphQLAPIService) List(orgUUID, projectHandle string, limit, offset int) (*api.GraphQLAPIListResponse, error) { +func (s *GraphQLAPIService) List(orgUUID, projectHandle string, opts repository.ListOptions) (*api.GraphQLAPIListResponse, error) { projectUUID := "" // If a project handle is provided, resolve it and validate that it belongs // to the organization (mirrors internal/service/api.go's @@ -392,14 +392,14 @@ func (s *GraphQLAPIService) List(orgUUID, projectHandle string, limit, offset in projectUUID = project.ID } - apis, err := s.repo.List(orgUUID, projectUUID, limit, offset) + apis, err := s.repo.List(orgUUID, projectUUID, opts) if err != nil { return nil, fmt.Errorf("failed to list GraphQL APIs: %w", err) } var totalCount int if projectUUID != "" { - totalCount, err = s.repo.CountByProject(orgUUID, projectUUID) + totalCount, err = s.repo.CountByProject(orgUUID, projectUUID, opts.Search) } else { totalCount, err = s.repo.Count(orgUUID) } @@ -410,8 +410,8 @@ func (s *GraphQLAPIService) List(orgUUID, projectHandle string, limit, offset in resp := &api.GraphQLAPIListResponse{ Count: len(apis), Pagination: api.Pagination{ - Limit: limit, - Offset: offset, + Limit: opts.Limit, + Offset: opts.Offset, Total: totalCount, }, } diff --git a/platform-api/internal/service/graphql_api_test.go b/platform-api/internal/service/graphql_api_test.go index 59eefdeb1f..648c143333 100644 --- a/platform-api/internal/service/graphql_api_test.go +++ b/platform-api/internal/service/graphql_api_test.go @@ -61,7 +61,11 @@ type mockGraphQLAPIRepo struct { countErr error countByProjectResult int countByProjectErr error - countByProjectCapture struct{ orgUUID, projectUUID string } + countByProjectCapture struct{ orgUUID, projectUUID, search string } + listCapture struct { + orgUUID, projectUUID string + opts repository.ListOptions + } gatewayDetails []*model.APIGatewayWithDetails getGatewaysFunc func(apiUUID, orgUUID string) ([]*model.APIGatewayWithDetails, error) @@ -93,15 +97,19 @@ func (m *mockGraphQLAPIRepo) GetByUUID(uuid, orgUUID string) (*model.GraphQLAPI, return nil, nil } -func (m *mockGraphQLAPIRepo) List(orgUUID, projectUUID string, limit, offset int) ([]*model.GraphQLAPI, error) { +func (m *mockGraphQLAPIRepo) List(orgUUID, projectUUID string, opts repository.ListOptions) ([]*model.GraphQLAPI, error) { + m.listCapture.orgUUID = orgUUID + m.listCapture.projectUUID = projectUUID + m.listCapture.opts = opts return m.listResult, m.listErr } func (m *mockGraphQLAPIRepo) Count(orgUUID string) (int, error) { return m.countResult, m.countErr } -func (m *mockGraphQLAPIRepo) CountByProject(orgUUID, projectUUID string) (int, error) { +func (m *mockGraphQLAPIRepo) CountByProject(orgUUID, projectUUID, search string) (int, error) { m.countByProjectCapture.orgUUID = orgUUID m.countByProjectCapture.projectUUID = projectUUID + m.countByProjectCapture.search = search return m.countByProjectResult, m.countByProjectErr } @@ -675,7 +683,7 @@ func TestGraphQLUpstreamAuth_RedactedAcrossAllResponseShapes(t *testing.T) { } assertRedacted(t, "GetDetail", &detail.Upstream) - list, err := svc.List("org-1", "", 25, 0) + list, err := svc.List("org-1", "", repository.ListOptions{Limit: 25, Offset: 0}) if err != nil { t.Fatalf("List: unexpected error: %v", err) } @@ -706,7 +714,7 @@ func TestGraphQLList_NoProjectFilter_ReturnsAllAndResolvesHandles(t *testing.T) project := &model.Project{ID: "project-uuid", Handle: "default-project", OrganizationID: "org-1"} svc := newGraphQLTestService(repo, project) - resp, err := svc.List("org-1", "", 100, 0) + resp, err := svc.List("org-1", "", repository.ListOptions{Limit: 100, Offset: 0}) if err != nil { t.Fatalf("List failed: %v", err) } @@ -732,7 +740,7 @@ func TestGraphQLList_ProjectFilter_ResolvesHandleToUUIDBeforeFiltering(t *testin 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 { + if _, err := svc.List("org-1", "default-project", repository.ListOptions{Limit: 100, Offset: 0}); err != nil { t.Fatalf("List failed: %v", err) } @@ -748,7 +756,7 @@ 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) + _, err := svc.List("org-1", "does-not-exist", repository.ListOptions{Limit: 100, Offset: 0}) if err == nil { t.Fatal("expected an error for an unknown project handle") } From 57e848021985d73e548e88173afb89303e245663 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Mon, 31 Aug 2026 12:24:34 +0530 Subject: [PATCH 21/38] Regenerate third-party Go license report to cover gqlparser --- .../go/gateway-controller-third-party-go-licenses.csv | 3 +-- .../go/gateway-runtime-third-party-go-licenses.csv | 1 - license-reports/go/platform-api-third-party-go-licenses.csv | 2 ++ license-reports/go/summary.md | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/license-reports/go/gateway-controller-third-party-go-licenses.csv b/license-reports/go/gateway-controller-third-party-go-licenses.csv index 6633cdc677..f7670f468f 100644 --- a/license-reports/go/gateway-controller-third-party-go-licenses.csv +++ b/license-reports/go/gateway-controller-third-party-go-licenses.csv @@ -48,7 +48,6 @@ github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil,htt github.com/prometheus/client_golang/prometheus,https://github.com/prometheus/client_golang/blob/v1.23.2/LICENSE,Apache-2.0 github.com/prometheus/client_model/go,https://github.com/prometheus/client_model/blob/v0.6.2/LICENSE,Apache-2.0 github.com/prometheus/common,https://github.com/prometheus/common/blob/v0.66.1/LICENSE,Apache-2.0 -github.com/prometheus/procfs,https://github.com/prometheus/procfs/blob/v0.19.2/LICENSE,Apache-2.0 github.com/shopspring/decimal,https://github.com/shopspring/decimal/blob/v1.4.0/LICENSE,MIT github.com/woodsbury/decimal128,https://github.com/woodsbury/decimal128/blob/v1.3.0/LICENCE,BSD-0-Clause github.com/xeipuuv/gojsonpointer,https://github.com/xeipuuv/gojsonpointer/blob/02993c407bfb/LICENSE-APACHE-2.0.txt,Apache-2.0 @@ -58,7 +57,7 @@ go.yaml.in/yaml/v2,https://github.com/yaml/go-yaml/blob/v2.4.3/LICENSE,Apache-2. golang.org/x/crypto,https://cs.opensource.google/go/x/crypto/+/v0.54.0:LICENSE,BSD-3-Clause golang.org/x/net,https://cs.opensource.google/go/x/net/+/v0.56.0:LICENSE,BSD-3-Clause golang.org/x/sync/semaphore,https://cs.opensource.google/go/x/sync/+/v0.22.0:LICENSE,BSD-3-Clause -golang.org/x/sys,https://cs.opensource.google/go/x/sys/+/v0.47.0:LICENSE,BSD-3-Clause +golang.org/x/sys/unix,https://cs.opensource.google/go/x/sys/+/v0.47.0:LICENSE,BSD-3-Clause golang.org/x/text,https://cs.opensource.google/go/x/text/+/v0.40.0:LICENSE,BSD-3-Clause golang.org/x/time/rate,https://cs.opensource.google/go/x/time/+/v0.14.0:LICENSE,BSD-3-Clause google.golang.org/genproto/googleapis/api,https://github.com/googleapis/go-genproto/blob/3dc84a4a5aaa/googleapis/api/LICENSE,Apache-2.0 diff --git a/license-reports/go/gateway-runtime-third-party-go-licenses.csv b/license-reports/go/gateway-runtime-third-party-go-licenses.csv index 66041fd9ed..112aaf062b 100644 --- a/license-reports/go/gateway-runtime-third-party-go-licenses.csv +++ b/license-reports/go/gateway-runtime-third-party-go-licenses.csv @@ -29,7 +29,6 @@ github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil,htt github.com/prometheus/client_golang/prometheus,https://github.com/prometheus/client_golang/blob/v1.23.2/LICENSE,Apache-2.0 github.com/prometheus/client_model/go,https://github.com/prometheus/client_model/blob/v0.6.2/LICENSE,Apache-2.0 github.com/prometheus/common,https://github.com/prometheus/common/blob/v0.66.1/LICENSE,Apache-2.0 -github.com/prometheus/procfs,https://github.com/prometheus/procfs/blob/v0.19.2/LICENSE,Apache-2.0 github.com/stoewer/go-strcase,https://github.com/stoewer/go-strcase/blob/v1.3.1/LICENSE,MIT go.opentelemetry.io/auto/sdk,https://github.com/open-telemetry/opentelemetry-go-instrumentation/blob/sdk/v1.2.1/sdk/LICENSE,Apache-2.0 go.opentelemetry.io/otel,https://github.com/open-telemetry/opentelemetry-go/blob/v1.44.0/LICENSE,Apache-2.0 diff --git a/license-reports/go/platform-api-third-party-go-licenses.csv b/license-reports/go/platform-api-third-party-go-licenses.csv index f4be5982e0..ac690670e2 100644 --- a/license-reports/go/platform-api-third-party-go-licenses.csv +++ b/license-reports/go/platform-api-third-party-go-licenses.csv @@ -1,5 +1,6 @@ github.com/MicahParks/jwkset,https://github.com/MicahParks/jwkset/blob/v0.11.0/LICENSE,Apache-2.0 github.com/MicahParks/keyfunc/v3,https://github.com/MicahParks/keyfunc/blob/v3.7.0/LICENSE,Apache-2.0 +github.com/agnivade/levenshtein,https://github.com/agnivade/levenshtein/blob/v1.2.1/License.txt,MIT github.com/apapsch/go-jsonmerge/v2,https://github.com/apapsch/go-jsonmerge/blob/v2.0.0/LICENSE,MIT github.com/fsnotify/fsnotify,https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE,BSD-3-Clause github.com/gabriel-vasile/mimetype,https://github.com/gabriel-vasile/mimetype/blob/v1.4.12/LICENSE,MIT @@ -31,6 +32,7 @@ github.com/mitchellh/reflectwalk,https://github.com/mitchellh/reflectwalk/blob/v github.com/oapi-codegen/runtime,https://github.com/oapi-codegen/runtime/blob/v1.5.0/LICENSE,Apache-2.0 github.com/pelletier/go-toml/v2,https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE,MIT github.com/shopspring/decimal,https://github.com/shopspring/decimal/blob/v1.4.0/LICENSE,MIT +github.com/vektah/gqlparser/v2,https://github.com/vektah/gqlparser/blob/v2.5.36/LICENSE,MIT golang.org/x/crypto,https://cs.opensource.google/go/x/crypto/+/v0.54.0:LICENSE,BSD-3-Clause golang.org/x/net,https://cs.opensource.google/go/x/net/+/v0.56.0:LICENSE,BSD-3-Clause golang.org/x/sync/semaphore,https://cs.opensource.google/go/x/sync/+/v0.22.0:LICENSE,BSD-3-Clause diff --git a/license-reports/go/summary.md b/license-reports/go/summary.md index 5edecba325..2d0f2ee76c 100644 --- a/license-reports/go/summary.md +++ b/license-reports/go/summary.md @@ -14,14 +14,14 @@ ## License Counts ### gateway-controller -- Apache-2.0: 23 +- Apache-2.0: 22 - BSD-0-Clause: 1 - BSD-2-Clause: 1 - BSD-3-Clause: 14 - MIT: 29 ### gateway-runtime -- Apache-2.0: 24 +- Apache-2.0: 23 - BSD-3-Clause: 18 - MIT: 15 @@ -34,5 +34,5 @@ - Apache-2.0: 4 - BSD-2-Clause: 1 - BSD-3-Clause: 10 -- MIT: 25 +- MIT: 27 From ccc279415e1abb6212e9b413217dd4549130052b Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Mon, 31 Aug 2026 12:47:11 +0530 Subject: [PATCH 22/38] Fix review comments --- gateway/it/features/graphql-api-keys.feature | 6 +++--- gateway/it/features/graphql_deploy.feature | 22 ++++++++++---------- gateway/it/steps_graphql.go | 3 ++- platform-api/internal/handler/graphql_api.go | 2 +- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/gateway/it/features/graphql-api-keys.feature b/gateway/it/features/graphql-api-keys.feature index 6c5d719b8f..51c55804f6 100644 --- a/gateway/it/features/graphql-api-keys.feature +++ b/gateway/it/features/graphql-api-keys.feature @@ -17,9 +17,9 @@ # -------------------------------------------------------------------- # 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 +# /graphql-apis/{id}/api-keys endpoints. The API key CRUD logic itself is +# shared, kind-agnostic service code (utils.APIKeyService), so this exists +# primarily to # guard the gateway-controller wiring specific to the GraphQL path: the OpenAPI # spec paths, the ServerInterface methods, and the relativeRoles auth-route map # entries in cmd/controller/main.go (a route missing from that map is denied as diff --git a/gateway/it/features/graphql_deploy.feature b/gateway/it/features/graphql_deploy.feature index b752f7ea9e..26bf88fe72 100644 --- a/gateway/it/features/graphql_deploy.feature +++ b/gateway/it/features/graphql_deploy.feature @@ -114,9 +114,10 @@ Feature: Test GraphQL API CRUD and connectivity (gateway-only path) # ==================== 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 + # layer, and the artifact carries no schema field at all: a mutation is + # just another POST body sent to the same single route a query uses, + # since GraphQL always resolves to exactly one route, never a + # per-operation list like REST's. This 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. @@ -733,14 +734,13 @@ Feature: Test GraphQL API CRUD and connectivity (gateway-only path) 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. + # exactly one POST route with an Exact path/method match, so an + # OPTIONS preflight never matches that route at all — Envoy 404s + # before the cors policy, or any policy, ever runs. REST's cors + # preflight support (which relies on an explicit `- method: OPTIONS` + # entry in operations[]) does NOT carry over to GraphQL; there is no + # operations[] to add one to. This is a genuine, current limitation — + # not yet supported. Given I clear all headers When I set header "Origin" to "http://example.com" And I set header "Access-Control-Request-Method" to "POST" diff --git a/gateway/it/steps_graphql.go b/gateway/it/steps_graphql.go index 0f01a8ef2a..2622bf1097 100644 --- a/gateway/it/steps_graphql.go +++ b/gateway/it/steps_graphql.go @@ -31,7 +31,8 @@ import ( // 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). +// per-operation routes: a GraphQL API always resolves to exactly one POST +// route, unlike REST's operations[] list. func RegisterGraphQLSteps(ctx *godog.ScenarioContext, state *TestState, httpSteps *steps.HTTPSteps, jwtSteps *JWTSteps) { deployGraphQLAPI := func(body *godog.DocString) error { httpSteps.SetHeader("Content-Type", "application/yaml") diff --git a/platform-api/internal/handler/graphql_api.go b/platform-api/internal/handler/graphql_api.go index d39057e4f1..31e0bdf242 100644 --- a/platform-api/internal/handler/graphql_api.go +++ b/platform-api/internal/handler/graphql_api.go @@ -217,7 +217,7 @@ func (h *GraphQLAPIHandler) DeleteGraphQLAPI(w http.ResponseWriter, r *http.Requ return serviceError(err, fmt.Sprintf("failed to delete GraphQL API %s in org %s", apiId, orgId)) } - httputil.WriteJSON(w, http.StatusNoContent, nil) + w.WriteHeader(http.StatusNoContent) return nil } From 71e0701659ce831eae4101ea34c19fad5459d5bb Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Mon, 31 Aug 2026 15:35:27 +0530 Subject: [PATCH 23/38] Fix build issue --- .../pkg/api/handlers/graphql_api_handler.go | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go b/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go index e94d24a9f4..6183962421 100644 --- a/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go +++ b/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go @@ -85,6 +85,13 @@ func (s *APIServer) CreateGraphQLAPI(w http.ResponseWriter, r *http.Request) { }) return } + if isGraphQLAPICreateBadRequest(err) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) + return + } httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{ Status: "error", Message: "Failed to create configuration", @@ -564,6 +571,32 @@ func (s *APIServer) ListGraphQLAPIKeys(w http.ResponseWriter, r *http.Request, i // mapValidationError maps a *utils.ValidationErrorListError to a 400 response with // structured field errors, mirroring RestAPIHandler.mapCreateError's handling of the // same error type. +// isGraphQLAPICreateBadRequest reports whether err is a client-input failure from +// the shared DeployAPIConfiguration path (config-parse failure, kind mismatch, or +// missing origin) that CreateGraphQLAPI's other error mappers (mapRenderError, +// mapValidationError, storage.IsConflictError) don't recognize — without this, +// these fell through to a generic 500 instead of the 400 the caller actually made. +// Mirrors isRestAPICreateBadRequest (rest_api_handler.go); DeployAPIConfiguration +// is the same shared function for every kind, so most error message classes are +// identical. One is GraphQL-specific: this endpoint hardcodes params.Kind = +// "GraphQLApi" so the body is always parsed as a GraphQLAPI regardless of what its +// own kind field says, but DeployAPIConfiguration's validator lookup keys off that +// parsed kind field, not the resolved one — a body with a wrong kind value parses +// fine, then fails validator lookup with "unexpected configuration type" instead +// of a recognized validation error. +func isGraphQLAPICreateBadRequest(err error) bool { + if err == nil { + return false + } + + message := strings.ToLower(err.Error()) + return strings.Contains(message, "failed to parse configuration") || + strings.Contains(message, "resource kind is required") || + strings.Contains(message, "unsupported resource kind") || + strings.Contains(message, "invalid or missing origin") || + strings.Contains(message, "unexpected configuration type") +} + func mapValidationError(w http.ResponseWriter, err error) bool { var validationErr *utils.ValidationErrorListError if !errors.As(err, &validationErr) { From 4d7bc356f924301d8058661c2bbd5509c5102363 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Wed, 2 Sep 2026 12:16:13 +0530 Subject: [PATCH 24/38] Set OperationPath on GraphQLApi routes so api-key-auth doesn't fail-closed --- gateway/examples/blog-graphql-api.yaml | 2 ++ gateway/examples/countries-graphql-api.yaml | 2 ++ .../pkg/transform/graphql.go | 19 +++++++++++++++---- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/gateway/examples/blog-graphql-api.yaml b/gateway/examples/blog-graphql-api.yaml index ad420284e5..5edb36eedd 100644 --- a/gateway/examples/blog-graphql-api.yaml +++ b/gateway/examples/blog-graphql-api.yaml @@ -36,6 +36,8 @@ apiVersion: gateway.api-platform.wso2.com/v1 kind: GraphQLApi metadata: name: blog-graphql-v1 + annotations: + gateway.api-platform.wso2.com/project-id: default spec: displayName: Blog version: v1 diff --git a/gateway/examples/countries-graphql-api.yaml b/gateway/examples/countries-graphql-api.yaml index 711e2f4763..828f532976 100644 --- a/gateway/examples/countries-graphql-api.yaml +++ b/gateway/examples/countries-graphql-api.yaml @@ -20,6 +20,8 @@ apiVersion: gateway.api-platform.wso2.com/v1 kind: GraphQLApi metadata: name: countries-graphql-v1 + annotations: + gateway.api-platform.wso2.com/project-id: default-project spec: displayName: Countries version: v1 diff --git a/gateway/gateway-controller/pkg/transform/graphql.go b/gateway/gateway-controller/pkg/transform/graphql.go index 56f5eee443..e4f8346da3 100644 --- a/gateway/gateway-controller/pkg/transform/graphql.go +++ b/gateway/gateway-controller/pkg/transform/graphql.go @@ -119,8 +119,17 @@ func (t *GraphQLAPITransformer) Transform(cfg *models.StoredConfig) (*models.Run mainRouteKey := xds.GenerateRouteName("POST", apiData.Context, apiData.Version, "", mainVhost) rdc.Routes[mainRouteKey] = &models.Route{ - Method: "POST", - Path: fullPath, + Method: "POST", + Path: fullPath, + // A GraphQLApi has no operations to derive a per-route path from (see the + // package doc above), but leaving this "" makes every request look + // operation-less to a request-scoped policy. Some policies — api-key-auth + // v1.2.1 among them — treat an empty OperationPath as "missing API details" + // and fail closed, instead of "not applicable" as SharedContext.OperationPath's + // own doc comment (sdk/core/policy/v1alpha2/context.go) says an empty value + // must be read. "/" is not a fake sub-path: it is this API's one and only + // operation, at its own root. + OperationPath: "/", PathMatchType: "Exact", Vhost: mainVhost, AutoHostRewrite: mainAutoHostRewrite, @@ -155,8 +164,10 @@ func (t *GraphQLAPITransformer) Transform(cfg *models.StoredConfig) (*models.Run sandboxRouteKey := xds.GenerateRouteName("POST", apiData.Context, apiData.Version, "", sandboxVhost) rdc.Routes[sandboxRouteKey] = &models.Route{ - Method: "POST", - Path: fullPath, + Method: "POST", + Path: fullPath, + // See the main route's OperationPath comment above. + OperationPath: "/", PathMatchType: "Exact", Vhost: sandboxVhost, AutoHostRewrite: sbAutoHostRewrite, From a7e7f06002d27edf9af22df608980f41ffe7ae69 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Wed, 2 Sep 2026 12:41:48 +0530 Subject: [PATCH 25/38] Include GraphQLApi in the connected-gateway API key bulk sync --- .../pkg/controlplane/client.go | 12 +++++++----- .../gateway-controller/pkg/utils/api_utils.go | 2 ++ .../internal/handler/gateway_internal.go | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/gateway/gateway-controller/pkg/controlplane/client.go b/gateway/gateway-controller/pkg/controlplane/client.go index 80a1bd51fa..09014b2ac8 100644 --- a/gateway/gateway-controller/pkg/controlplane/client.go +++ b/gateway/gateway-controller/pkg/controlplane/client.go @@ -1057,9 +1057,10 @@ var onPremSupportedAPIKeyKinds = map[string]bool{ } // syncAPIKeysForExistingArtifacts performs a one-time bulk sync of API keys for all -// currently known RestApi, WebSubApi, LlmProvider, and LlmProxy artifacts after the WebSocket connection -// is established. Upserts fetched keys into the DB, reconciles deletions per artifact, -// then reloads the in-memory store and refreshes the xDS snapshot once. +// currently known RestApi, WebSubApi, LlmProvider, LlmProxy, and GraphQLApi artifacts +// after the WebSocket connection is established. Upserts fetched keys into the DB, +// reconciles deletions per artifact, then reloads the in-memory store and refreshes +// the xDS snapshot once. // For on-prem control planes only KindRestApi is synced; other kinds are skipped because // the corresponding backfill endpoints do not exist in carbon-apimgt for now. func (c *Client) syncAPIKeysForExistingArtifacts(gatewayID string) { @@ -1090,7 +1091,8 @@ func (c *Client) syncAPIKeysForExistingArtifacts(gatewayID string) { continue } if cfg.Kind != models.KindLlmProvider && cfg.Kind != models.KindLlmProxy && - cfg.Kind != models.KindRestApi && cfg.Kind != models.KindWebSubApi && cfg.Kind != models.KindWebBrokerApi { + cfg.Kind != models.KindRestApi && cfg.Kind != models.KindWebSubApi && + cfg.Kind != models.KindWebBrokerApi && cfg.Kind != models.KindGraphQLApi { continue } artifactUUIDsByKind[cfg.Kind] = append(artifactUUIDsByKind[cfg.Kind], cfg.UUID) @@ -1109,7 +1111,7 @@ func (c *Client) syncAPIKeysForExistingArtifacts(gatewayID string) { localArtifactIDs[cfg.CPArtifactID] = cfg.UUID } - for _, kind := range []string{models.KindRestApi, models.KindWebSubApi, models.KindWebBrokerApi, models.KindLlmProvider, models.KindLlmProxy} { + for _, kind := range []string{models.KindRestApi, models.KindWebSubApi, models.KindWebBrokerApi, models.KindLlmProvider, models.KindLlmProxy, models.KindGraphQLApi} { // On-prem APIM only exposes backfill endpoints for RestApi keys. if c.isOnPrem() && !onPremSupportedAPIKeyKinds[kind] { c.logger.Debug("Skipping API key bulk sync for kind: not supported by on-prem control plane", diff --git a/gateway/gateway-controller/pkg/utils/api_utils.go b/gateway/gateway-controller/pkg/utils/api_utils.go index 1444029c35..4ef77c83b2 100644 --- a/gateway/gateway-controller/pkg/utils/api_utils.go +++ b/gateway/gateway-controller/pkg/utils/api_utils.go @@ -340,6 +340,8 @@ func (s *APIUtilsService) FetchAPIKeysByKind(artifactKind, issuer string) ([]mod path = "/websub-apis/api-keys" case models.KindWebBrokerApi: path = "/webbroker-apis/api-keys" + case models.KindGraphQLApi: + path = "/graphql-apis/api-keys" default: return nil, fmt.Errorf("unsupported artifact kind for API key fetch: %s", artifactKind) } diff --git a/platform-api/internal/handler/gateway_internal.go b/platform-api/internal/handler/gateway_internal.go index 61dc6625fb..5303958517 100644 --- a/platform-api/internal/handler/gateway_internal.go +++ b/platform-api/internal/handler/gateway_internal.go @@ -866,6 +866,22 @@ func (h *GatewayInternalAPIHandler) GetWebBrokerAPIAPIKeys(w http.ResponseWriter httputil.WriteJSON(w, http.StatusOK, keys) } +// GetGraphQLAPIAPIKeys handles GET /api/internal/v1/graphql-apis/api-keys +func (h *GatewayInternalAPIHandler) GetGraphQLAPIAPIKeys(w http.ResponseWriter, r *http.Request) { + orgID, gatewayID, ok := h.authenticateRequest(w, r) + if !ok { + return + } + issuer := r.URL.Query().Get("issuer") + keys, err := h.gatewayInternalService.GetAPIKeysByKind(gatewayID, orgID, constants.GraphQLApi, issuer) + if err != nil { + h.slogger.Error("Failed to get API keys for GraphQL APIs", "gatewayID", gatewayID, "error", err) + httputil.WriteJSON(w, http.StatusInternalServerError, dto.NewInternalErrorResponse(500, "Internal Server Error", "Failed to get API keys")) + return + } + httputil.WriteJSON(w, http.StatusOK, keys) +} + // CheckArtifactsExist handles POST /api/internal/v1/artifacts/exists // Returns the subset of provided artifact UUIDs that still exist on the platform. // Used by the gateway during sync to avoid deleting artifacts that still exist @@ -1075,6 +1091,7 @@ func (h *GatewayInternalAPIHandler) RegisterRoutes(mux router.Router) { mux.HandleFunc("GET /api/internal/v1/deployments", h.GetGatewayDeployments) mux.HandleFunc("POST /api/internal/v1/deployments/fetch-batch", h.BatchFetchDeployments) mux.HandleFunc("GET /api/internal/v1/mcp-proxies/{proxyId}", h.GetMCPProxy) + mux.HandleFunc("GET /api/internal/v1/graphql-apis/api-keys", h.GetGraphQLAPIAPIKeys) mux.HandleFunc("GET /api/internal/v1/graphql-apis/{apiId}", h.GetGraphQLAPI) mux.HandleFunc("GET /api/internal/v1/websub-apis/api-keys", h.GetWebSubAPIAPIKeys) mux.HandleFunc("GET /api/internal/v1/websub-apis/{apiId}", h.GetWebSubAPI) From a6196b94fd507114367921834af5ff20ac4bfe5c Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Wed, 2 Sep 2026 14:51:47 +0530 Subject: [PATCH 26/38] Stop deleting locally-generated API keys during CP bulk-sync reconciliation --- .../gateway-controller/cmd/controller/main.go | 11 ++++--- .../cmd/controller/runtime_bootstrap.go | 2 +- .../pkg/storage/interface.go | 3 ++ .../pkg/storage/sql_store.go | 11 +++++-- .../pkg/storage/sqlite_test.go | 31 +++++++++++++++++++ 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index 6659402145..0192c30cd2 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -364,6 +364,7 @@ func main() { // Initialize SDS secret manager if custom certificates are configured var sdsSecretManager *xds.SDSSecretManager translator := snapshotManager.GetTranslator() + if translator != nil && translator.GetCertStore() != nil { // Use the same cache and node ID as the main xDS to ensure Envoy can fetch secrets sdsSecretManager = xds.NewSDSSecretManager( @@ -415,11 +416,13 @@ func main() { // registry learns to transform cannot be silently left off this map — WebSubApi's exclusion // (it keeps the async-specific legacy translation path) is declared alongside the registry's // own kind list instead. - envoyTransformers := make(map[string]models.ConfigTransformer) - for _, kind := range transform.EnvoyTranslatorKinds() { - envoyTransformers[kind] = transformerRegistry + if translator != nil { + envoyTransformers := make(map[string]models.ConfigTransformer) + for _, kind := range transform.EnvoyTranslatorKinds() { + envoyTransformers[kind] = transformerRegistry + } + translator.SetTransformers(envoyTransformers) } - translator.SetTransformers(envoyTransformers) // Generate initial xDS snapshot log.Info("Generating initial xDS snapshot") diff --git a/gateway/gateway-controller/cmd/controller/runtime_bootstrap.go b/gateway/gateway-controller/cmd/controller/runtime_bootstrap.go index 2ce822d1f7..05f6aa4c0e 100644 --- a/gateway/gateway-controller/cmd/controller/runtime_bootstrap.go +++ b/gateway/gateway-controller/cmd/controller/runtime_bootstrap.go @@ -166,7 +166,7 @@ func loadRuntimeConfigsFromExistingAPIConfigurations( func supportsRuntimeBootstrapKind(kind string) bool { switch kind { - case models.KindRestApi, models.KindMcp, models.KindLlmProvider, models.KindLlmProxy, models.KindAgent: + case models.KindRestApi, models.KindMcp, models.KindLlmProvider, models.KindLlmProxy, models.KindAgent, models.KindGraphQLApi: return true default: return false diff --git a/gateway/gateway-controller/pkg/storage/interface.go b/gateway/gateway-controller/pkg/storage/interface.go index ac0e90f5f2..1cfdea245a 100644 --- a/gateway/gateway-controller/pkg/storage/interface.go +++ b/gateway/gateway-controller/pkg/storage/interface.go @@ -281,6 +281,9 @@ type Storage interface { // ListAPIKeysForArtifactsNotIn returns the minimal key info (uuid + artifact_uuid) for // keys whose artifact_uuid is in artifactUUIDs but whose own UUID is not in keyUUIDs. // Used to collect identifiers before deletion so callers can publish EventHub events. + // Only considers source='external' (control-plane-issued) keys — a source='local' key + // was never reported to the control plane, so its absence from keyUUIDs never makes it + // stale. ListAPIKeysForArtifactsNotIn(artifactUUIDs []string, keyUUIDs []string) ([]*models.APIKey, error) // DeleteAPIKeysByUUIDs removes API keys by their UUIDs. Used after ListAPIKeysForArtifactsNotIn diff --git a/gateway/gateway-controller/pkg/storage/sql_store.go b/gateway/gateway-controller/pkg/storage/sql_store.go index 5b6f636fb1..4b55f399a4 100644 --- a/gateway/gateway-controller/pkg/storage/sql_store.go +++ b/gateway/gateway-controller/pkg/storage/sql_store.go @@ -3649,6 +3649,13 @@ func (s *sqlStore) SecretExists(handle string) (bool, error) { // ListAPIKeysForArtifactsNotIn returns uuid + artifact_uuid for keys that would be removed // by DeleteAPIKeysForArtifactsNotIn. Call this before the delete to collect identifiers // needed for publishing EventHub events. +// +// Only source='external' (control-plane-issued) keys are considered: this powers the CP +// bulk-sync reconciliation, whose whole premise is "delete whatever the control plane no +// longer reports for this artifact." A source='local' key was generated on the gateway +// itself and was never reported to (or known by) the control plane in the first place, so +// its absence from a CP fetch is expected, not a sign it was revoked — treating it as stale +// deleted every locally-generated key on the very next reconnect/restart. func (s *sqlStore) ListAPIKeysForArtifactsNotIn(artifactUUIDs []string, keyUUIDs []string) ([]*models.APIKey, error) { if len(artifactUUIDs) == 0 { return nil, nil @@ -3663,7 +3670,7 @@ func (s *sqlStore) ListAPIKeysForArtifactsNotIn(artifactUUIDs []string, keyUUIDs var query string if len(keyUUIDs) == 0 { query = fmt.Sprintf( - `SELECT uuid, artifact_uuid, name FROM api_keys WHERE gateway_id = ? AND artifact_uuid IN (%s)`, + `SELECT uuid, artifact_uuid, name FROM api_keys WHERE gateway_id = ? AND artifact_uuid IN (%s) AND source = 'external'`, strings.Join(artifactPlaceholders, ","), ) } else { @@ -3673,7 +3680,7 @@ func (s *sqlStore) ListAPIKeysForArtifactsNotIn(artifactUUIDs []string, keyUUIDs args = append(args, id) } query = fmt.Sprintf( - `SELECT uuid, artifact_uuid, name FROM api_keys WHERE gateway_id = ? AND artifact_uuid IN (%s) AND uuid NOT IN (%s)`, + `SELECT uuid, artifact_uuid, name FROM api_keys WHERE gateway_id = ? AND artifact_uuid IN (%s) AND uuid NOT IN (%s) AND source = 'external'`, strings.Join(artifactPlaceholders, ","), strings.Join(keyPlaceholders, ","), ) diff --git a/gateway/gateway-controller/pkg/storage/sqlite_test.go b/gateway/gateway-controller/pkg/storage/sqlite_test.go index 77513d288d..10b16cf35c 100644 --- a/gateway/gateway-controller/pkg/storage/sqlite_test.go +++ b/gateway/gateway-controller/pkg/storage/sqlite_test.go @@ -843,6 +843,37 @@ func TestSQLiteStorage_GetAPIKeysByAPI_Success(t *testing.T) { assert.Assert(t, keyIDs["0000-key2-0000-000000000000"]) } +// TestSQLiteStorage_ListAPIKeysForArtifactsNotIn_ExcludesLocalKeys guards against +// regressing the CP bulk-sync reconciliation into treating every locally-generated key as +// stale. A source="local" key was generated on the gateway itself and was never reported +// to the control plane, so its absence from a CP fetch (keyUUIDs) must never make it a +// deletion candidate — only source="external" keys the control plane once knew about and +// has since stopped reporting are genuinely stale. +func TestSQLiteStorage_ListAPIKeysForArtifactsNotIn_ExcludesLocalKeys(t *testing.T) { + storage := setupTestStorage(t) + defer storage.db.Close() + + config := createTestStoredConfig() + err := storage.SaveConfig(config) + assert.NilError(t, err) + + localKey := createTestAPIKey() + localKey.ArtifactUUID = config.UUID + localKey.Source = "local" + assert.NilError(t, storage.SaveAPIKey(localKey)) + + externalKey := createTestAPIKey() + externalKey.ArtifactUUID = config.UUID + externalKey.Source = "external" + assert.NilError(t, storage.SaveAPIKey(externalKey)) + + // Simulate a CP bulk-sync round that reported zero keys for this artifact's kind. + stale, err := storage.ListAPIKeysForArtifactsNotIn([]string{config.UUID}, []string{}) + assert.NilError(t, err) + assert.Equal(t, len(stale), 1, "only the control-plane-issued key should be reported stale") + assert.Equal(t, stale[0].UUID, externalKey.UUID) +} + func TestLoadAPIKeysFromDatabase_Success(t *testing.T) { storage := setupTestStorage(t) defer storage.db.Close() From d7cf892124335eefa93096cd40cb7285bb810cf0 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Wed, 2 Sep 2026 15:40:49 +0530 Subject: [PATCH 27/38] Fix sandbox-upstream path corruption from GraphQLApi's synthetic OperationPath --- .../pkg/transform/graphql.go | 17 +++++-- .../gateway-controller/pkg/xds/translator.go | 15 +++++- .../pkg/xds/translator_test.go | 48 +++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/gateway/gateway-controller/pkg/transform/graphql.go b/gateway/gateway-controller/pkg/transform/graphql.go index e4f8346da3..6d5414c11f 100644 --- a/gateway/gateway-controller/pkg/transform/graphql.go +++ b/gateway/gateway-controller/pkg/transform/graphql.go @@ -127,9 +127,18 @@ func (t *GraphQLAPITransformer) Transform(cfg *models.StoredConfig) (*models.Run // v1.2.1 among them — treat an empty OperationPath as "missing API details" // and fail closed, instead of "not applicable" as SharedContext.OperationPath's // own doc comment (sdk/core/policy/v1alpha2/context.go) says an empty value - // must be read. "/" is not a fake sub-path: it is this API's one and only - // operation, at its own root. - OperationPath: "/", + // must be read. + // + // This must NOT be "/": xds/translator.go's createRouteFromRDC/setMatchPathSpecifier + // special-case operationPath=="/" as a literal REST root-path operation (matching both + // "/ctx" and "/ctx/", rewriting the upstream to end in a trailing "/") — semantics that + // don't apply here and that broke the sandbox-upstream IT scenario by appending an + // unwanted "/" to the upstream path. Any value that isn't "/", doesn't end in "/*", and + // doesn't contain "{" falls through those translators' plain default case instead, + // which is a byte-for-byte pass-through of the configured upstream path — exactly what + // "" used to produce before this field started being read. "graphql" is never rendered + // into a route or a rewritten path; it only has to be non-empty and non-special. + OperationPath: "graphql", PathMatchType: "Exact", Vhost: mainVhost, AutoHostRewrite: mainAutoHostRewrite, @@ -167,7 +176,7 @@ func (t *GraphQLAPITransformer) Transform(cfg *models.StoredConfig) (*models.Run Method: "POST", Path: fullPath, // See the main route's OperationPath comment above. - OperationPath: "/", + OperationPath: "graphql", PathMatchType: "Exact", Vhost: sandboxVhost, AutoHostRewrite: sbAutoHostRewrite, diff --git a/gateway/gateway-controller/pkg/xds/translator.go b/gateway/gateway-controller/pkg/xds/translator.go index f47daf885a..7decb336a2 100644 --- a/gateway/gateway-controller/pkg/xds/translator.go +++ b/gateway/gateway-controller/pkg/xds/translator.go @@ -391,7 +391,20 @@ func (t *Translator) createRouteFromRDC(routeKey string, rdcRoute *models.Route, // For a wildcard operation path like "/foo/*", strip only the context so the matched literal // prefix ("/foo") is PRESERVED on the upstream — consistent with exact paths. The bare "/*" // catch-all (empty literal prefix) and "/" root are unaffected. See issue #2071. - contextWithVersion := strings.TrimSuffix(fullPath, operationPath) + // + // That "fullPath = context + operationPath" invariant holds for every RestAPITransformer + // route by construction, but GraphQLApi's single fixed route builds Path from context+version + // alone and never appends OperationPath — OperationPath there is a synthetic non-empty + // placeholder some policies require (see GraphQLAPITransformer), unrelated to fullPath's + // actual content. A string-based guard here (only trim when operationPath "is" a suffix) is + // not safe either: an API context an operator names e.g. "/sandbox-graphql" genuinely ends + // with the literal placeholder text, so the trim would fire and corrupt the rewrite anyway. + // Route the two cases on the one thing that's unambiguous — the route's own kind — rather + // than on any string relationship between fullPath and operationPath. + contextWithVersion := fullPath + if rdc.Metadata.Kind != string(models.KindGraphQLApi) { + contextWithVersion = strings.TrimSuffix(fullPath, operationPath) + } escapedContext := regexp.QuoteMeta(contextWithVersion) if rdcRoute.UpstreamPathOverride != "" { diff --git a/gateway/gateway-controller/pkg/xds/translator_test.go b/gateway/gateway-controller/pkg/xds/translator_test.go index ce5e5dbd3e..ef768d20d7 100644 --- a/gateway/gateway-controller/pkg/xds/translator_test.go +++ b/gateway/gateway-controller/pkg/xds/translator_test.go @@ -1059,6 +1059,54 @@ func TestTranslator_ExactPathUsesNativeMatcher(t *testing.T) { "exact route must rank as Exact for SortRoutesByPriority") } +// applyEnvoyRegexRewrite is applyEnvoyRewrite without the SafeRegex match-specifier +// precondition, for routes matched via Envoy's native exact matcher (RouteMatch_Path) +// instead of a safe_regex — the RegexRewrite route action applies independently of how +// the route was matched. +func applyEnvoyRegexRewrite(t *testing.T, r *route.Route, requestPath string) string { + t.Helper() + rw := r.GetRoute().GetRegexRewrite() + require.NotNil(t, rw, "route should have a RegexRewrite") + pattern := regexp.MustCompile(rw.GetPattern().GetRegex()) + goSub := strings.ReplaceAll(rw.GetSubstitution(), `\1`, `${1}`) + return pattern.ReplaceAllString(requestPath, goSub) +} + +// TestTranslator_GraphQLOperationPathNotAppendedToUpstream guards the fix for a +// GraphQLApi route whose context coincidentally ends with the same text as its +// synthetic OperationPath placeholder (GraphQLAPITransformer sets a fixed, non-empty +// OperationPath so policies like api-key-auth that misread "" as "missing API details" +// don't fail closed — see graphql.go). createRouteFromRDC previously derived the +// route's context by blindly trimming OperationPath off the end of the full path, +// an invariant that only genuinely holds for RestAPITransformer routes. For a context +// like "/sandbox-graphql" with OperationPath "graphql", that trim fired anyway +// (the text really is there, just not because of a real appended operation) and +// doubled the upstream path segment: an upstream of ".../graphql" was rewritten to +// ".../graphqlgraphql" instead of being passed through unchanged. +func TestTranslator_GraphQLOperationPathNotAppendedToUpstream(t *testing.T) { + logger := createTestLogger() + translator := NewTranslator(logger, testRouterConfig(), nil, testConfig()) + + rdc := &models.RuntimeDeployConfig{ + Metadata: models.Metadata{Kind: string(models.KindGraphQLApi)}, + UpstreamClusters: map[string]*models.UpstreamCluster{ + "main": {BasePath: "/graphql", Endpoints: []models.Endpoint{{Host: "sample-backend", Port: 9080}}}, + }, + } + rdcRoute := &models.Route{ + Method: "POST", + Path: "/sandbox-graphql", + OperationPath: "graphql", // the synthetic placeholder, coincidentally a suffix of Path + PathMatchType: "Exact", + AutoHostRewrite: true, + Upstream: models.RouteUpstream{ClusterKey: "main"}, + } + r := translator.createRouteFromRDC("POST|/sandbox-graphql|", rdcRoute, rdc) + require.NotNil(t, r) + assert.Equal(t, "/graphql", applyEnvoyRegexRewrite(t, r, "/sandbox-graphql"), + "upstream path must be passed through unchanged, not have OperationPath appended a second time") +} + // TestSortRoutesByPriority_ExactBeatsLongerPrefixRegex reproduces the HTTPRoutePathMatchOrder // conformance shape: an exact /match must outrank the /match/ prefix even though the prefix's // regex string is longer. Before the fix the exact route was a safe_regex and lost on length. From 8a3748c6208ee548f228062d26de8fdb4f5978a7 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Thu, 3 Sep 2026 14:42:15 +0530 Subject: [PATCH 28/38] Require multipart for GraphQL API create/update, add schemaSource, make SDL optional --- platform-api/api/generated.go | 159 +++++++--- platform-api/internal/handler/graphql_api.go | 27 +- .../internal/handler/graphql_api_test.go | 51 +-- platform-api/internal/service/graphql_api.go | 189 ++++++++--- .../internal/service/graphql_api_test.go | 295 +++++++++--------- platform-api/resources/openapi.yaml | 130 ++++---- 6 files changed, 546 insertions(+), 305 deletions(-) diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index 0ecb080d0b..396b085588 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -58,6 +58,14 @@ const ( CreateGatewayRequestFunctionalityTypeRegular CreateGatewayRequestFunctionalityType = "regular" ) +// Defines values for CreateGraphQLAPIRequestSchemaSource. +const ( + CreateGraphQLAPIRequestSchemaSourceFile CreateGraphQLAPIRequestSchemaSource = "file" + CreateGraphQLAPIRequestSchemaSourceInline CreateGraphQLAPIRequestSchemaSource = "inline" + CreateGraphQLAPIRequestSchemaSourceIntrospection CreateGraphQLAPIRequestSchemaSource = "introspection" + CreateGraphQLAPIRequestSchemaSourceUrl CreateGraphQLAPIRequestSchemaSource = "url" +) + // Defines values for CreateRESTAPIRequestLifeCycleStatus. const ( CreateRESTAPIRequestLifeCycleStatusBLOCKED CreateRESTAPIRequestLifeCycleStatus = "BLOCKED" @@ -119,6 +127,14 @@ const ( GatewayResponseFunctionalityTypeRegular GatewayResponseFunctionalityType = "regular" ) +// Defines values for GraphQLAPISchemaSource. +const ( + GraphQLAPISchemaSourceFile GraphQLAPISchemaSource = "file" + GraphQLAPISchemaSourceInline GraphQLAPISchemaSource = "inline" + GraphQLAPISchemaSourceIntrospection GraphQLAPISchemaSource = "introspection" + GraphQLAPISchemaSourceUrl GraphQLAPISchemaSource = "url" +) + // Defines values for GraphQLIntrospectionMode. const ( ENDPOINT GraphQLIntrospectionMode = "ENDPOINT" @@ -1009,31 +1025,45 @@ type CreateGraphQLAPIRequest struct { // 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 + // SchemaSource Declares how the schema is being supplied, so the server validates + // against stated intent instead of guessing it from which fields happen + // to be populated. `inline` requires `sdl`; `url` requires `sdlUrl`; + // `file` requires the `sdlFile` multipart part (see + // GraphQLAPIMultipartRequest); `introspection` (the default) requires a + // literal `upstream.main.url` and derives the schema by querying it. + // Only the field matching the declared source may be present — a + // mismatch (wrong field populated, nothing populated, more than one + // populated) is a `400` (`VALIDATION_FAILED`), not a silent + // fall-through to a different resolution path. Schema *resolution* is + // separate and best-effort: a failure to actually resolve (bad SDL, + // unreachable URL, introspection failing) never fails the request — + // see `sdl` below. + SchemaSource *CreateGraphQLAPIRequestSchemaSource `json:"schemaSource,omitempty" yaml:"schemaSource,omitempty"` + + // Sdl The GraphQL schema in SDL form — resolved per `schemaSource`, from a + // directly-supplied document (`inline`/`file`), fetched from `sdlUrl` + // (`url`), or derived from `upstream.main.url` (`introspection`). Always // the *resolved* schema, never a document-supplied schema-location - // reference. `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. + // reference. Optional in practice: if resolution fails, the API is still + // created/updated and this is left empty (create) or unchanged from its + // previous value (update) rather than the request failing — see + // `schemaSource`. Sdl *string `json:"sdl,omitempty" yaml:"sdl,omitempty"` - // SdlUrl A URL to a raw SDL document to fetch and use as `sdl` — the write-side - // counterpart to how an OpenAPI document can be supplied by reference for - // other artifact kinds (see LlmProviderTemplate's `metadata.openapiSpecUrl`). - // Distinct from `upstream.main.url`: this is a plain HTTP(S) GET of a static - // schema file, not a live introspection query against a GraphQL server, and - // is fetched through the same shared SSRF-guarded HTTP client every other + // SdlUrl A URL to a raw SDL document to fetch and use as `sdl` when + // `schemaSource` is `url` — the write-side counterpart to how an OpenAPI + // document can be supplied by reference for other artifact kinds (see + // LlmProviderTemplate's `metadata.openapiSpecUrl`). Distinct from + // `upstream.main.url`: this is a plain HTTP(S) GET of a static schema + // file, not a live introspection query against a GraphQL server, and is + // fetched through the same shared SSRF-guarded HTTP client every other // operator/tenant-supplied fetch in this API uses, under the operator- // configured policy (default `netguard.PermitPrivateBlockMetadata()`): the // host is resolved and every candidate IP — including each redirect hop — // is checked at dial time, refusing link-local/metadata/unspecified/ // multicast addresses while private and in-cluster addresses (a Kubernetes - // ClusterIP, a service-DNS name, localhost) remain reachable. Mutually - // exclusive with `sdl`. Never stored or echoed back; only the fetched `sdl` - // text is persisted and returned. + // ClusterIP, a service-DNS name, localhost) remain reachable. Never stored + // or echoed back; only the fetched `sdl` text is persisted and returned. SdlUrl *string `json:"sdlUrl,omitempty" yaml:"sdlUrl,omitempty"` // SubscriptionPlans List of subscription plan names enabled for this API. @@ -1048,6 +1078,21 @@ type CreateGraphQLAPIRequest struct { Version string `binding:"required" json:"version" yaml:"version"` } +// CreateGraphQLAPIRequestSchemaSource Declares how the schema is being supplied, so the server validates +// against stated intent instead of guessing it from which fields happen +// to be populated. `inline` requires `sdl`; `url` requires `sdlUrl`; +// `file` requires the `sdlFile` multipart part (see +// GraphQLAPIMultipartRequest); `introspection` (the default) requires a +// literal `upstream.main.url` and derives the schema by querying it. +// Only the field matching the declared source may be present — a +// mismatch (wrong field populated, nothing populated, more than one +// populated) is a `400` (`VALIDATION_FAILED`), not a silent +// fall-through to a different resolution path. Schema *resolution* is +// separate and best-effort: a failure to actually resolve (bad SDL, +// unreachable URL, introspection failing) never fails the request — +// see `sdl` below. +type CreateGraphQLAPIRequestSchemaSource string + // CreateLLMProviderAPIKeyRequest defines model for CreateLLMProviderAPIKeyRequest. type CreateLLMProviderAPIKeyRequest struct { // AllowedTargets Comma-separated list of gateways this key is valid for. @@ -1598,31 +1643,45 @@ type GraphQLAPI struct { // 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 + // SchemaSource Declares how the schema is being supplied, so the server validates + // against stated intent instead of guessing it from which fields happen + // to be populated. `inline` requires `sdl`; `url` requires `sdlUrl`; + // `file` requires the `sdlFile` multipart part (see + // GraphQLAPIMultipartRequest); `introspection` (the default) requires a + // literal `upstream.main.url` and derives the schema by querying it. + // Only the field matching the declared source may be present — a + // mismatch (wrong field populated, nothing populated, more than one + // populated) is a `400` (`VALIDATION_FAILED`), not a silent + // fall-through to a different resolution path. Schema *resolution* is + // separate and best-effort: a failure to actually resolve (bad SDL, + // unreachable URL, introspection failing) never fails the request — + // see `sdl` below. + SchemaSource *GraphQLAPISchemaSource `json:"schemaSource,omitempty" yaml:"schemaSource,omitempty"` + + // Sdl The GraphQL schema in SDL form — resolved per `schemaSource`, from a + // directly-supplied document (`inline`/`file`), fetched from `sdlUrl` + // (`url`), or derived from `upstream.main.url` (`introspection`). Always // the *resolved* schema, never a document-supplied schema-location - // reference. `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. + // reference. Optional in practice: if resolution fails, the API is still + // created/updated and this is left empty (create) or unchanged from its + // previous value (update) rather than the request failing — see + // `schemaSource`. Sdl *string `json:"sdl,omitempty" yaml:"sdl,omitempty"` - // SdlUrl A URL to a raw SDL document to fetch and use as `sdl` — the write-side - // counterpart to how an OpenAPI document can be supplied by reference for - // other artifact kinds (see LlmProviderTemplate's `metadata.openapiSpecUrl`). - // Distinct from `upstream.main.url`: this is a plain HTTP(S) GET of a static - // schema file, not a live introspection query against a GraphQL server, and - // is fetched through the same shared SSRF-guarded HTTP client every other + // SdlUrl A URL to a raw SDL document to fetch and use as `sdl` when + // `schemaSource` is `url` — the write-side counterpart to how an OpenAPI + // document can be supplied by reference for other artifact kinds (see + // LlmProviderTemplate's `metadata.openapiSpecUrl`). Distinct from + // `upstream.main.url`: this is a plain HTTP(S) GET of a static schema + // file, not a live introspection query against a GraphQL server, and is + // fetched through the same shared SSRF-guarded HTTP client every other // operator/tenant-supplied fetch in this API uses, under the operator- // configured policy (default `netguard.PermitPrivateBlockMetadata()`): the // host is resolved and every candidate IP — including each redirect hop — // is checked at dial time, refusing link-local/metadata/unspecified/ // multicast addresses while private and in-cluster addresses (a Kubernetes - // ClusterIP, a service-DNS name, localhost) remain reachable. Mutually - // exclusive with `sdl`. Never stored or echoed back; only the fetched `sdl` - // text is persisted and returned. + // ClusterIP, a service-DNS name, localhost) remain reachable. Never stored + // or echoed back; only the fetched `sdl` text is persisted and returned. SdlUrl *string `json:"sdlUrl,omitempty" yaml:"sdlUrl,omitempty"` // SubscriptionPlans List of subscription plan names enabled for this API. @@ -1637,6 +1696,21 @@ type GraphQLAPI struct { Version string `binding:"required" json:"version" yaml:"version"` } +// GraphQLAPISchemaSource Declares how the schema is being supplied, so the server validates +// against stated intent instead of guessing it from which fields happen +// to be populated. `inline` requires `sdl`; `url` requires `sdlUrl`; +// `file` requires the `sdlFile` multipart part (see +// GraphQLAPIMultipartRequest); `introspection` (the default) requires a +// literal `upstream.main.url` and derives the schema by querying it. +// Only the field matching the declared source may be present — a +// mismatch (wrong field populated, nothing populated, more than one +// populated) is a `400` (`VALIDATION_FAILED`), not a silent +// fall-through to a different resolution path. Schema *resolution* is +// separate and best-effort: a failure to actually resolve (bad SDL, +// unreachable URL, introspection failing) never fails the request — +// see `sdl` below. +type GraphQLAPISchemaSource string + // GraphQLAPIDetail defines model for GraphQLAPIDetail. type GraphQLAPIDetail struct { // Context Base path for the single GraphQL endpoint. Suggested (not enforced) @@ -1714,13 +1788,16 @@ type GraphQLAPIListResponse struct { // 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. + // GraphQLAPI fields for update, including `schemaSource`. When + // `schemaSource` is `file`, the `sdlFile` part below is required and any + // `sdl`/`sdlUrl` in this metadata is a structural-validation error, not a + // silent override — every schema-source variant is expressed + // consistently through the `schemaSource` field rather than by which + // part happens to be present. Metadata string `binding:"required" json:"metadata" yaml:"metadata"` // SdlFile The GraphQL SDL document as a file upload (e.g. schema.graphql). + // Required when `schemaSource` is `file`; must be omitted otherwise. SdlFile *openapi_types.File `json:"sdlFile,omitempty" yaml:"sdlFile,omitempty"` } @@ -4186,15 +4263,9 @@ 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 diff --git a/platform-api/internal/handler/graphql_api.go b/platform-api/internal/handler/graphql_api.go index 31e0bdf242..885efa6ede 100644 --- a/platform-api/internal/handler/graphql_api.go +++ b/platform-api/internal/handler/graphql_api.go @@ -289,14 +289,19 @@ func (h *GraphQLAPIHandler) GetAPIGateways(w http.ResponseWriter, r *http.Reques 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. +// decodeCreateGraphQLAPIRequest decodes a create request from +// multipart/form-data — a JSON "metadata" field plus an optional "sdlFile" +// upload — see GraphQLAPIMultipartRequest in resources/openapi.yaml. This is +// the only accepted content type: every schemaSource variant (inline, url, +// file, introspection) is expressed the same way rather than splitting file +// uploads onto a second content type. A file part's content is copied into +// req.Sdl as the schemaSource="file" candidate text; it is not silently +// preferred over a conflicting sdl/sdlUrl already in metadata — that +// conflict is a schemaSource structural-validation error, caught in the +// service layer (graphql_api.go's resolveSchema), not resolved here. func decodeCreateGraphQLAPIRequest(r *http.Request, req *api.CreateGraphQLAPIRequest) error { if !utils.IsMultipartFormRequest(r) { - return json.NewDecoder(r.Body).Decode(req) + return fmt.Errorf("Content-Type must be multipart/form-data") } metadataJSON, sdl, err := utils.ParseGraphQLAPIMultipartRequest(r) if err != nil { @@ -307,18 +312,17 @@ func decodeCreateGraphQLAPIRequest(r *http.Request, req *api.CreateGraphQLAPIReq } 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). +// counterpart, targeting api.GraphQLAPI instead of api.CreateGraphQLAPIRequest +// (oapi-codegen generates these as distinct, non-embedding struct types, so +// the two can't share one generic function). func decodeUpdateGraphQLAPIRequest(r *http.Request, req *api.GraphQLAPI) error { if !utils.IsMultipartFormRequest(r) { - return json.NewDecoder(r.Body).Decode(req) + return fmt.Errorf("Content-Type must be multipart/form-data") } metadataJSON, sdl, err := utils.ParseGraphQLAPIMultipartRequest(r) if err != nil { @@ -329,7 +333,6 @@ func decodeUpdateGraphQLAPIRequest(r *http.Request, req *api.GraphQLAPI) error { } if sdl != "" { req.Sdl = &sdl - req.SdlUrl = nil } return nil } diff --git a/platform-api/internal/handler/graphql_api_test.go b/platform-api/internal/handler/graphql_api_test.go index 65b3b99382..5af983af9c 100644 --- a/platform-api/internal/handler/graphql_api_test.go +++ b/platform-api/internal/handler/graphql_api_test.go @@ -58,21 +58,30 @@ func newGraphQLAPIMultipartHandlerRequest(t *testing.T, metadata, sdlFileContent const graphQLHandlerTestSDL = "type Query { countries: [String] }" -func TestDecodeCreateGraphQLAPIRequest_JSON(t *testing.T) { +// TestDecodeCreateGraphQLAPIRequest_JSON_Rejected guards the multipart-only +// requirement: application/json is no longer an accepted content type for +// GraphQL API create/update, so every schemaSource variant is expressed the +// same way instead of splitting file uploads onto a second content type. +func TestDecodeCreateGraphQLAPIRequest_JSON_Rejected(t *testing.T) { body := `{"displayName":"Countries","context":"/countries","version":"v1.0","projectId":"default-project","sdl":"type Query { x: String }"}` req := httptest.NewRequest(http.MethodPost, "/graphql-apis", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") var out api.CreateGraphQLAPIRequest - if err := decodeCreateGraphQLAPIRequest(req, &out); err != nil { - t.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) + if err := decodeCreateGraphQLAPIRequest(req, &out); err == nil { + t.Fatal("expected application/json to be rejected now that multipart/form-data is the only accepted content type") } } -func TestDecodeCreateGraphQLAPIRequest_Multipart_FileWinsOverMetadataSDLUrl(t *testing.T) { +// TestDecodeCreateGraphQLAPIRequest_Multipart_FileContentAndMetadataSDLUrlBothSurvive +// guards a deliberate behavior change from the old "file always wins, +// clearing sdlUrl" decoder logic: the decoder no longer resolves a +// file-vs-sdlUrl conflict itself, it only copies the file's content into +// req.Sdl and leaves whatever else was in metadata untouched. Detecting (and +// rejecting) a request that populated more than one schema source is now +// resolveSchema's job, driven by the declared schemaSource — see +// graphql_api.go in internal/service. +func TestDecodeCreateGraphQLAPIRequest_Multipart_FileContentAndMetadataSDLUrlBothSurvive(t *testing.T) { metadata := `{"displayName":"Countries","context":"/countries","version":"v1.0","projectId":"default-project","sdlUrl":"https://example.com/schema.graphql"}` req := newGraphQLAPIMultipartHandlerRequest(t, metadata, graphQLHandlerTestSDL, true) @@ -81,10 +90,10 @@ func TestDecodeCreateGraphQLAPIRequest_Multipart_FileWinsOverMetadataSDLUrl(t *t 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) + t.Errorf("expected sdl to carry the uploaded file's content, 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.SdlUrl == nil || *out.SdlUrl != "https://example.com/schema.graphql" { + t.Errorf("expected sdlUrl from metadata to be left as-is (not silently cleared), got %v", out.SdlUrl) } if out.DisplayName != "Countries" { t.Errorf("expected other metadata fields to still be populated, got %+v", out) @@ -116,21 +125,23 @@ func TestDecodeCreateGraphQLAPIRequest_Multipart_MissingMetadata(t *testing.T) { } } -func TestDecodeUpdateGraphQLAPIRequest_JSON(t *testing.T) { +// TestDecodeUpdateGraphQLAPIRequest_JSON_Rejected is Update's counterpart to +// TestDecodeCreateGraphQLAPIRequest_JSON_Rejected. +func TestDecodeUpdateGraphQLAPIRequest_JSON_Rejected(t *testing.T) { body := `{"displayName":"Countries","context":"/countries","version":"v1.0","sdl":"type Query { x: String }"}` req := httptest.NewRequest(http.MethodPut, "/graphql-apis/countries", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") var out api.GraphQLAPI - if err := decodeUpdateGraphQLAPIRequest(req, &out); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if out.Sdl == nil || *out.Sdl != "type Query { x: String }" { - t.Errorf("unexpected decode result: %+v", out) + if err := decodeUpdateGraphQLAPIRequest(req, &out); err == nil { + t.Fatal("expected application/json to be rejected now that multipart/form-data is the only accepted content type") } } -func TestDecodeUpdateGraphQLAPIRequest_Multipart_FileWinsOverMetadataSDLUrl(t *testing.T) { +// TestDecodeUpdateGraphQLAPIRequest_Multipart_FileContentAndMetadataSDLUrlBothSurvive +// is Update's counterpart to +// TestDecodeCreateGraphQLAPIRequest_Multipart_FileContentAndMetadataSDLUrlBothSurvive. +func TestDecodeUpdateGraphQLAPIRequest_Multipart_FileContentAndMetadataSDLUrlBothSurvive(t *testing.T) { metadata := `{"displayName":"Countries","context":"/countries","version":"v1.0","sdlUrl":"https://example.com/schema.graphql"}` req := newGraphQLAPIMultipartHandlerRequest(t, metadata, graphQLHandlerTestSDL, true) @@ -139,10 +150,10 @@ func TestDecodeUpdateGraphQLAPIRequest_Multipart_FileWinsOverMetadataSDLUrl(t *t 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) + t.Errorf("expected sdl to carry the uploaded file's content, 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.SdlUrl == nil || *out.SdlUrl != "https://example.com/schema.graphql" { + t.Errorf("expected sdlUrl from metadata to be left as-is (not silently cleared), got %v", out.SdlUrl) } } diff --git a/platform-api/internal/service/graphql_api.go b/platform-api/internal/service/graphql_api.go index d1f7df4988..e611df183c 100644 --- a/platform-api/internal/service/graphql_api.go +++ b/platform-api/internal/service/graphql_api.go @@ -213,10 +213,20 @@ func (s *GraphQLAPIService) Create(orgUUID, createdBy string, req *api.CreateGra } upstream := mapUpstreamAPIToModel(req.Upstream) - sdl, introspectionMode, err := s.resolveSchema(utils.ValueOrEmpty(req.Sdl), utils.ValueOrEmpty(req.SdlUrl), upstream) + var schemaSource string + if req.SchemaSource != nil { + schemaSource = string(*req.SchemaSource) + } + // A blank schemaSource is not defaulted here — resolveSchema infers it + // from which of sdl/sdlUrl is populated, for backward compatibility with + // a caller that predates this field. + resolution, err := s.resolveSchema(schemaSource, utils.ValueOrEmpty(req.Sdl), utils.ValueOrEmpty(req.SdlUrl), upstream) if err != nil { return nil, err } + // resolution.Resolved false is not an error on create — there is no + // previous schema to fall back to, so an unresolved schema just means the + // new API's sdl/introspectionMode start out empty (see resolveSchema). var subscriptionPlans []string if req.SubscriptionPlans != nil { @@ -237,8 +247,8 @@ func (s *GraphQLAPIService) Create(orgUUID, createdBy string, req *api.CreateGra Name: req.DisplayName, Version: req.Version, Context: &context, - SDL: sdl, - IntrospectionMode: introspectionMode, + SDL: resolution.SDL, + IntrospectionMode: resolution.IntrospectionMode, Upstream: *upstream, Policies: mapMCPPoliciesAPIToModel(req.Policies), SubscriptionPlans: subscriptionPlans, @@ -259,50 +269,140 @@ func (s *GraphQLAPIService) Create(orgUUID, createdBy string, req *api.CreateGra 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) { +// graphQLSchemaResolution is resolveSchema's outcome. Resolved is false when +// the declared schemaSource was structurally valid but the actual content +// couldn't be turned into a usable schema (bad SDL text, an unreachable +// sdlUrl, introspection failing) — that is never an error (see resolveSchema). +type graphQLSchemaResolution struct { + SDL string + IntrospectionMode string + Resolved bool +} + +// resolveSchema validates the request's declared schemaSource against which +// of sdl/sdlUrl/upstream.main.url was actually supplied, then attempts to +// resolve the schema that way. It returns two independent kinds of outcome: +// +// - A non-nil error means the request itself is structurally inconsistent +// with its declared schemaSource — a field not matching it was supplied +// (or more than one was), the field/part its value requires is missing, +// or introspection was declared against an upstream.main.ref (which has +// no URL to introspect). These are request-shape problems the caller can +// fix by changing what they sent, reported via the "%s"-templated +// ValidationFailed catalog entry (400) with a specific, actionable +// message — same as the pre-existing sdl/sdlUrl mutual-exclusivity check +// this replaces. This is deliberately not GraphQLAPISchemaResolveFailed: +// that entry's message is a fixed sterile string with no format verb, by +// design (error-handling.md — never reveal which resolution-quality +// reason applied), so passing it a specific reason for a *structural* +// mismatch would either be silently dropped or (worse) surface as a +// literal Go fmt "%!(EXTRA ...)" artifact — a structural problem is safe +// to explain to the caller precisely because it's about their own +// request shape, not about the upstream/parser internals that entry +// exists to hide. +// - A nil error with Resolved=false means the request was well-formed but +// resolving the schema didn't actually work this time (invalid SDL text, +// an unreachable sdlUrl, introspection failing/disabled). This never +// blocks the request — see the doc comments on Create and Update for how +// each handles it. +func (s *GraphQLAPIService) resolveSchema(schemaSource, suppliedSDL, sdlURL string, upstream *model.UpstreamConfig) (graphQLSchemaResolution, error) { + schemaSource = strings.TrimSpace(schemaSource) suppliedSDL = strings.TrimSpace(suppliedSDL) sdlURL = strings.TrimSpace(sdlURL) - if suppliedSDL != "" && sdlURL != "" { - return "", "", apperror.ValidationFailed.New("The sdl and sdlUrl fields are mutually exclusive — provide only one.") + // A caller that doesn't set schemaSource at all predates the field (or + // simply doesn't need to be explicit); infer it from whichever field is + // actually populated, the same way this resolved before schemaSource + // existed. Defaulting unconditionally to "introspection" here would + // reject that caller's own sdl/sdlUrl as an "introspection but sdl was + // also provided" structural error — a real caller sending exactly what + // they always sent. Only when schemaSource is explicitly set does an + // unmatched field become a structural error (see below) — that's the + // whole reason to set it explicitly, and inferring around a stated intent + // would defeat it. + if schemaSource == "" { + switch { + case suppliedSDL != "": + schemaSource = string(api.GraphQLAPISchemaSourceInline) + case sdlURL != "": + schemaSource = string(api.GraphQLAPISchemaSourceUrl) + default: + schemaSource = string(api.GraphQLAPISchemaSourceIntrospection) + } } - 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) + // Structural validation: exactly the field(s) matching the declared + // schemaSource may be populated — a mismatch is a request-shape problem, + // reported immediately rather than silently falling through to whatever + // happens to be non-empty. + switch schemaSource { + case string(api.GraphQLAPISchemaSourceInline): + if sdlURL != "" { + return graphQLSchemaResolution{}, apperror.ValidationFailed.New("schemaSource is 'inline' but sdlUrl was also provided.") + } + if suppliedSDL == "" { + return graphQLSchemaResolution{}, apperror.ValidationFailed.New("schemaSource is 'inline' but no sdl was provided.") + } + case string(api.GraphQLAPISchemaSourceUrl): + if suppliedSDL != "" { + return graphQLSchemaResolution{}, apperror.ValidationFailed.New("schemaSource is 'url' but sdl was also provided.") + } + if sdlURL == "" { + return graphQLSchemaResolution{}, apperror.ValidationFailed.New("schemaSource is 'url' but no sdlUrl was provided.") + } + case string(api.GraphQLAPISchemaSourceFile): + // The handler copies an uploaded sdlFile's content into suppliedSDL — + // from here a file is just inline text; only the structural + // expectation (no sdlUrl) differs from schemaSource "inline". + if sdlURL != "" { + return graphQLSchemaResolution{}, apperror.ValidationFailed.New("schemaSource is 'file' but sdlUrl was also provided.") + } + if suppliedSDL == "" { + return graphQLSchemaResolution{}, apperror.ValidationFailed.New("schemaSource is 'file' but no sdlFile was uploaded.") + } + case string(api.GraphQLAPISchemaSourceIntrospection): + if suppliedSDL != "" || sdlURL != "" { + return graphQLSchemaResolution{}, apperror.ValidationFailed.New("schemaSource is 'introspection' but sdl/sdlUrl was also provided.") + } + if upstream == nil || upstream.Main == nil || strings.TrimSpace(upstream.Main.URL) == "" { + if upstream != nil && upstream.Main != nil && strings.TrimSpace(upstream.Main.Ref) != "" { + return graphQLSchemaResolution{}, apperror.ValidationFailed.New("schemaSource is 'introspection' but upstream.main is a ref, not a literal url — introspection has nothing to call.") + } + return graphQLSchemaResolution{}, apperror.ValidationFailed.New("schemaSource is 'introspection' but upstream.main.url is not set.") } - suppliedSDL = strings.TrimSpace(fetched) + default: + return graphQLSchemaResolution{}, apperror.ValidationFailed.New(fmt.Sprintf("Invalid schemaSource %q — must be one of inline, url, file, introspection.", schemaSource)) } - if suppliedSDL != "" { + // Resolution: best-effort from here — a failure never returns an error, + // it just means Resolved is false. + switch schemaSource { + case string(api.GraphQLAPISchemaSourceInline), string(api.GraphQLAPISchemaSourceFile): if err := validateGraphQLSDL(suppliedSDL); err != nil { - s.slogger.Warn("Supplied GraphQL SDL failed validation", "error", err) - return "", "", apperror.GraphQLAPISchemaResolveFailed.Wrap(err) + s.slogger.Warn("Supplied GraphQL SDL failed validation", "schemaSource", schemaSource, "error", err) + return graphQLSchemaResolution{}, nil } - 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 graphQLSchemaResolution{SDL: suppliedSDL, IntrospectionMode: "SDL", Resolved: true}, nil + case string(api.GraphQLAPISchemaSourceUrl): + fetched, err := utils.FetchOpenAPISpecFromURL(context.Background(), sdlURL, s.maxSDLFetchBytes) + if err != nil { + s.slogger.Warn("Failed to fetch GraphQL SDL from sdlUrl", "error", err) + return graphQLSchemaResolution{}, nil + } + fetched = strings.TrimSpace(fetched) + if err := validateGraphQLSDL(fetched); err != nil { + s.slogger.Warn("Fetched GraphQL SDL failed validation", "error", err) + return graphQLSchemaResolution{}, nil + } + return graphQLSchemaResolution{SDL: fetched, IntrospectionMode: "SDL", Resolved: true}, nil + default: // introspection + derived, err := fetchAndConvertGraphQLSchema(upstream.Main.URL) + if err != nil { + s.slogger.Warn("GraphQL introspection failed", "error", err) + return graphQLSchemaResolution{}, nil + } + return graphQLSchemaResolution{SDL: derived, IntrospectionMode: "ENDPOINT", Resolved: true}, nil } - return derived, "ENDPOINT", nil } // handleExistsCheck returns a function that checks if a GraphQL API handle @@ -503,10 +603,25 @@ func (s *GraphQLAPIService) Update(orgUUID, handle, updatedBy string, req *api.G } upstream := mapUpstreamAPIToModel(req.Upstream) - sdl, introspectionMode, err := s.resolveSchema(utils.ValueOrEmpty(req.Sdl), utils.ValueOrEmpty(req.SdlUrl), upstream) + var schemaSource string + if req.SchemaSource != nil { + schemaSource = string(*req.SchemaSource) + } + // A blank schemaSource is not defaulted here — resolveSchema infers it + // from which of sdl/sdlUrl is populated, for backward compatibility with + // a caller that predates this field. + resolution, err := s.resolveSchema(schemaSource, utils.ValueOrEmpty(req.Sdl), utils.ValueOrEmpty(req.SdlUrl), upstream) if err != nil { return nil, err } + // Unlike Create, a failed resolution on update keeps the previously-stored + // schema instead of blanking it out — the whole point of best-effort + // resolution is that a metadata-only edit (or a transient upstream issue) + // shouldn't destroy a schema that was working before this request. + sdl, introspectionMode := existing.Configuration.SDL, existing.Configuration.IntrospectionMode + if resolution.Resolved { + sdl, introspectionMode = resolution.SDL, resolution.IntrospectionMode + } var subscriptionPlans []string if req.SubscriptionPlans != nil { diff --git a/platform-api/internal/service/graphql_api_test.go b/platform-api/internal/service/graphql_api_test.go index 648c143333..8056cdb5b6 100644 --- a/platform-api/internal/service/graphql_api_test.go +++ b/platform-api/internal/service/graphql_api_test.go @@ -334,7 +334,12 @@ func TestGraphQLCreate_WithIntrospection_Success(t *testing.T) { // same shared-client policy as MCP and as sdlUrl's own fetcher), so a local // httptest.Server genuinely exercises this path rather than tripping an SSRF // block first. -func TestGraphQLCreate_IntrospectionFailure_UnprocessableEntity(t *testing.T) { +// TestGraphQLCreate_IntrospectionFailure_SucceedsWithEmptySchema guards +// resolveSchema's best-effort posture: introspection failing (upstream +// unreachable, disabled, or misbehaving) is a resolution-quality problem, not +// a structural one, so it must never block creation — the API is created with +// an empty sdl instead, fetchable/refreshable later. +func TestGraphQLCreate_IntrospectionFailure_SucceedsWithEmptySchema(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("not json at all")) @@ -353,35 +358,27 @@ func TestGraphQLCreate_IntrospectionFailure_UnprocessableEntity(t *testing.T) { 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) + resp, err := svc.Create("org-1", "creator-uuid", req) + if err != nil { + t.Fatalf("expected creation to succeed despite a failed introspection, got: %v", err) } - if appErr.HTTPStatus != http.StatusUnprocessableEntity { - t.Errorf("expected 422, got %d", appErr.HTTPStatus) + if resp == nil { + t.Fatal("expected a response, got nil") } - 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.Fatal("expected repo.Create to be called") } - if repo.created != nil { - t.Error("expected no repository write when introspection fails") + if repo.created.Configuration.SDL != "" { + t.Errorf("expected empty SDL when introspection fails, got %q", repo.created.Configuration.SDL) } } // 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. +// resolveSchema's best-effort posture uniformly across both resolution- +// failure causes it can hit: a malformed inline SDL and a failed +// introspection must both succeed with an empty schema — neither is a +// structural problem, so neither may block the request, and the outcome +// shouldn't depend on which cause produced it. func TestGraphQLCreate_SchemaResolveFailure_IdenticalShapeRegardlessOfCause(t *testing.T) { introspectionServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) @@ -399,21 +396,19 @@ func TestGraphQLCreate_SchemaResolveFailure_IdenticalShapeRegardlessOfCause(t *t } 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) + sdlRepo, introspectRepo := &mockGraphQLAPIRepo{}, &mockGraphQLAPIRepo{} + sdlResp, sdlErr := newGraphQLTestService(sdlRepo, project).Create("org-1", "creator-uuid", malformedSDLReq) + introspectResp, introspectErr := newGraphQLTestService(introspectRepo, project).Create("org-1", "creator-uuid", introspectionFailureReq) - 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 sdlErr != nil || introspectErr != nil { + t.Fatalf("expected both to succeed, got sdlErr=%v introspectErr=%v", sdlErr, introspectErr) } - if sdlAppErr.Code != introspectAppErr.Code { - t.Errorf("expected identical error codes, got %q vs %q", sdlAppErr.Code, introspectAppErr.Code) + if sdlResp == nil || introspectResp == nil { + t.Fatal("expected both responses to be non-nil") } - 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) + if sdlRepo.created.Configuration.SDL != "" || introspectRepo.created.Configuration.SDL != "" { + t.Errorf("expected empty SDL for both causes, got %q and %q", + sdlRepo.created.Configuration.SDL, introspectRepo.created.Configuration.SDL) } } @@ -765,7 +760,11 @@ func TestGraphQLList_UnknownProjectHandle_NotFound(t *testing.T) { } } -func TestGraphQLCreate_MalformedSDL_UnprocessableEntity(t *testing.T) { +// TestGraphQLCreate_MalformedSDL_SucceedsWithEmptySchema guards resolveSchema's +// best-effort posture for the "inline" source: invalid SDL text is a +// resolution-quality problem (like a failed introspection or sdlUrl fetch), +// not a structural one, so it must not block creation. +func TestGraphQLCreate_MalformedSDL_SucceedsWithEmptySchema(t *testing.T) { repo := &mockGraphQLAPIRepo{} project := &model.Project{ID: "project-uuid", OrganizationID: "org-1"} svc := newGraphQLTestService(repo, project) @@ -779,34 +778,27 @@ func TestGraphQLCreate_MalformedSDL_UnprocessableEntity(t *testing.T) { 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) + resp, err := svc.Create("org-1", "creator-uuid", req) + if err != nil { + t.Fatalf("expected creation to succeed despite malformed SDL, got: %v", err) } - if appErr.HTTPStatus != http.StatusUnprocessableEntity { - t.Errorf("expected 422, got %d", appErr.HTTPStatus) + if resp == nil { + t.Fatal("expected a response, got nil") } - // 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.Fatal("expected repo.Create to be called") } - if repo.created != nil { - t.Error("expected no repository write for a schema that failed validation") + if repo.created.Configuration.SDL != "" { + t.Errorf("expected empty SDL for a schema that failed validation, got %q", repo.created.Configuration.SDL) } } -// TestGraphQLCreate_SDLWithNoQueryRoot_UnprocessableEntity covers the +// TestGraphQLCreate_SDLWithNoQueryRoot_SucceedsWithEmptySchema covers the // schema.Query == nil branch in validateGraphQLSDL — syntactically valid SDL // that nonetheless never defines a Query root type. Distinct from the -// malformed-syntax case above, which never reaches that check. -func TestGraphQLCreate_SDLWithNoQueryRoot_UnprocessableEntity(t *testing.T) { +// malformed-syntax case above, which never reaches that check. Like any other +// resolution-quality failure, this must not block creation. +func TestGraphQLCreate_SDLWithNoQueryRoot_SucceedsWithEmptySchema(t *testing.T) { repo := &mockGraphQLAPIRepo{} project := &model.Project{ID: "project-uuid", OrganizationID: "org-1"} svc := newGraphQLTestService(repo, project) @@ -820,15 +812,18 @@ func TestGraphQLCreate_SDLWithNoQueryRoot_UnprocessableEntity(t *testing.T) { 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") + resp, err := svc.Create("org-1", "creator-uuid", req) + if err != nil { + t.Fatalf("expected creation to succeed despite a schema with no Query root type, got: %v", err) } - if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPISchemaResolveFailed { - t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPISchemaResolveFailed, code) + if resp == nil { + t.Fatal("expected a response, got nil") } - if repo.created != nil { - t.Error("expected no repository write for a schema with no Query root type") + if repo.created == nil { + t.Fatal("expected repo.Create to be called") + } + if repo.created.Configuration.SDL != "" { + t.Errorf("expected empty SDL for a schema with no Query root type, got %q", repo.created.Configuration.SDL) } } @@ -868,8 +863,12 @@ func TestGraphQLCreate_SDLTakesPrecedenceOverIntrospection(t *testing.T) { } // TestGraphQLCreate_SDLAndSDLUrlMutuallyExclusive guards resolveSchema's -// precedence check for the third onboarding input (sdlUrl) — sdl and sdlUrl -// must never both be honored silently. +// structural validation — sdl and sdlUrl must never both be honored silently. +// With no explicit schemaSource, the presence of sdl infers "inline", so this +// is a schemaSource mismatch — a request-shape problem reported via the +// specific-message ValidationFailed entry (400), not the sterile +// GraphQLAPISchemaResolveFailed entry, which has no format verb for a +// specific reason and is reserved for resolution-quality failures. func TestGraphQLCreate_SDLAndSDLUrlMutuallyExclusive(t *testing.T) { repo := &mockGraphQLAPIRepo{} project := &model.Project{ID: "project-uuid", OrganizationID: "org-1"} @@ -896,15 +895,16 @@ func TestGraphQLCreate_SDLAndSDLUrlMutuallyExclusive(t *testing.T) { } } -// 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) { +// TestGraphQLCreate_SDLUrlFetchFailure_SucceedsWithEmptySchema covers the +// sdlUrl fetch path: a URL the SSRF guard refuses (loopback, standing in for +// "unreachable/disallowed") is a resolution-quality failure, not a structural +// one, so — like a failed introspection or malformed inline SDL — it must not +// block creation. The successful-fetch path is covered by +// utils.TestFetchOpenAPISpecFromURL_*, mirroring TestResolveTemplateOpenAPISpec's +// convention for the identical LLM-provider-template case — ipIsAllowed can't +// be overridden from this package, so a real successful fetch isn't +// exercisable here. +func TestGraphQLCreate_SDLUrlFetchFailure_SucceedsWithEmptySchema(t *testing.T) { repo := &mockGraphQLAPIRepo{} project := &model.Project{ID: "project-uuid", OrganizationID: "org-1"} svc := newGraphQLTestService(repo, project) @@ -917,23 +917,28 @@ func TestGraphQLCreate_SDLUrlFetchFailure_SchemaResolveFailed(t *testing.T) { 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") + resp, err := svc.Create("org-1", "creator-uuid", req) + if err != nil { + t.Fatalf("expected creation to succeed despite a blocked sdlUrl, got: %v", err) } - if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPISchemaResolveFailed { - t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPISchemaResolveFailed, code) + if resp == nil { + t.Fatal("expected a response, got nil") } - if repo.created != nil { - t.Error("expected no repository write when sdlUrl fetch fails") + if repo.created == nil { + t.Fatal("expected repo.Create to be called") + } + if repo.created.Configuration.SDL != "" { + t.Errorf("expected empty SDL when sdlUrl fetch fails, got %q", repo.created.Configuration.SDL) } } // TestGraphQLCreate_SDLUrlFetchFailure_DoesNotFallBackToIntrospection locks in -// a real design decision in resolveSchema: 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. +// a real design decision in resolveSchema: schemaSource "url" (inferred here +// from sdlUrl being the only schema field populated) only ever attempts the +// URL fetch — it does NOT silently fall back to introspecting +// upstream.main.url just because that upstream is present and reachable. The +// introspection endpoint must never be called in this case, and the failed +// fetch still succeeds with an empty schema rather than erroring. func TestGraphQLCreate_SDLUrlFetchFailure_DoesNotFallBackToIntrospection(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("introspection endpoint must not be called when sdlUrl was supplied and failed") @@ -954,18 +959,30 @@ func TestGraphQLCreate_SDLUrlFetchFailure_DoesNotFallBackToIntrospection(t *test 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") + resp, err := svc.Create("org-1", "creator-uuid", req) + if err != nil { + t.Fatalf("expected creation to succeed despite a blocked sdlUrl, even with a reachable upstream present, got: %v", err) } - if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPISchemaResolveFailed { - t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPISchemaResolveFailed, code) + if resp == nil { + t.Fatal("expected a response, got nil") } - if repo.created != nil { - t.Error("expected no repository write when sdlUrl fetch fails") + if repo.created == nil { + t.Fatal("expected repo.Create to be called") + } + if repo.created.Configuration.SDL != "" { + t.Errorf("expected empty SDL when sdlUrl fetch fails, got %q", repo.created.Configuration.SDL) } } +// TestGraphQLCreate_MissingSDLAndUpstream_ValidationFailed covers the case +// where nothing to derive a schema from was supplied at all. With no +// explicit schemaSource, this infers "introspection" (the same default as +// before schemaSource existed), and introspection has no upstream.main.url to +// call — that's a structural problem (there's nothing to even attempt), not a +// resolution-quality one, so it's reported immediately via the +// specific-message ValidationFailed entry (400), not the sterile +// GraphQLAPISchemaResolveFailed entry reserved for resolution-quality +// failures. func TestGraphQLCreate_MissingSDLAndUpstream_ValidationFailed(t *testing.T) { repo := &mockGraphQLAPIRepo{} project := &model.Project{ID: "project-uuid", OrganizationID: "org-1"} @@ -1214,11 +1231,12 @@ func TestGraphQLUpdate_ReIntrospect_RefreshesSchema(t *testing.T) { } } -// 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) { +// TestGraphQLUpdate_ReIntrospectFails_PreservesExistingSchema pins Update's +// soft-fail fallback: unlike Create (which has no previous schema to fall +// back to), a failed re-introspection on Update must not blank out a schema +// that was working before this request — the update still succeeds, and the +// previously-stored sdl/introspectionMode are carried forward unchanged. +func TestGraphQLUpdate_ReIntrospectFails_PreservesExistingSchema(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) })) @@ -1244,26 +1262,26 @@ func TestGraphQLUpdate_ReIntrospectFails_NoPartialWrite(t *testing.T) { 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 _, err := svc.Update("org-1", "countries-graphql-api", "updater-uuid", req); err != nil { + t.Fatalf("expected update to succeed despite a failed re-introspection, got: %v", err) } - if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPISchemaResolveFailed { - t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPISchemaResolveFailed, code) + if repo.updated == nil { + t.Fatal("expected the repository Update to be called") } - if repo.updated != nil { - t.Error("expected no repository write when re-introspection fails") + if repo.updated.Configuration.SDL != validCountriesGraphQLSDL { + t.Errorf("expected the previously-stored SDL to be preserved, got: %q", repo.updated.Configuration.SDL) } - if stored.Configuration.SDL != validCountriesGraphQLSDL { - t.Errorf("expected the in-memory existing record to be left unchanged, got sdl: %q", stored.Configuration.SDL) + if repo.updated.Configuration.IntrospectionMode != "SDL" { + t.Errorf("expected the previously-stored introspectionMode to be preserved, got: %q", repo.updated.Configuration.IntrospectionMode) } } -// TestGraphQLUpdate_MalformedSDL_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) { +// TestGraphQLUpdate_MalformedSDL_PreservesExistingSchema is Update's +// counterpart to TestGraphQLCreate_MalformedSDL_SucceedsWithEmptySchema — +// resolveSchema's SDL parse validation is shared by both entry points, but +// Update's failure fallback differs from Create's: instead of ending up with +// an empty schema, a broken update keeps whatever schema was already stored. +func TestGraphQLUpdate_MalformedSDL_PreservesExistingSchema(t *testing.T) { stored := &model.GraphQLAPI{ ID: "some-uuid", Handle: "countries-graphql-api", @@ -1285,21 +1303,20 @@ func TestGraphQLUpdate_MalformedSDL_UnprocessableEntity(t *testing.T) { 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 _, err := svc.Update("org-1", "countries-graphql-api", "updater-uuid", req); err != nil { + t.Fatalf("expected update to succeed despite malformed SDL, got: %v", err) } - if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPISchemaResolveFailed { - t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPISchemaResolveFailed, code) + if repo.updated == nil { + t.Fatal("expected the repository Update to be called") } - if repo.updated != nil { - t.Error("expected no repository write for malformed SDL") + if repo.updated.Configuration.SDL != validCountriesGraphQLSDL { + t.Errorf("expected the previously-stored SDL to be preserved, got: %q", repo.updated.Configuration.SDL) } } -// TestGraphQLUpdate_SDLWithNoQueryRoot_UnprocessableEntity is Update's -// counterpart to TestGraphQLCreate_SDLWithNoQueryRoot_UnprocessableEntity. -func TestGraphQLUpdate_SDLWithNoQueryRoot_UnprocessableEntity(t *testing.T) { +// TestGraphQLUpdate_SDLWithNoQueryRoot_PreservesExistingSchema is Update's +// counterpart to TestGraphQLCreate_SDLWithNoQueryRoot_SucceedsWithEmptySchema. +func TestGraphQLUpdate_SDLWithNoQueryRoot_PreservesExistingSchema(t *testing.T) { stored := &model.GraphQLAPI{ ID: "some-uuid", Handle: "countries-graphql-api", @@ -1321,21 +1338,22 @@ func TestGraphQLUpdate_SDLWithNoQueryRoot_UnprocessableEntity(t *testing.T) { 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 _, err := svc.Update("org-1", "countries-graphql-api", "updater-uuid", req); err != nil { + t.Fatalf("expected update to succeed despite a schema with no Query root type, got: %v", err) } - if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPISchemaResolveFailed { - t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPISchemaResolveFailed, code) + if repo.updated == nil { + t.Fatal("expected the repository Update to be called") } - if repo.updated != nil { - t.Error("expected no repository write for a schema with no Query root type") + if repo.updated.Configuration.SDL != validCountriesGraphQLSDL { + t.Errorf("expected the previously-stored SDL to be preserved, got: %q", repo.updated.Configuration.SDL) } } // TestGraphQLUpdate_SDLAndSDLUrlMutuallyExclusive is Update's counterpart to // TestGraphQLCreate_SDLAndSDLUrlMutuallyExclusive — the same resolveSchema -// validation is shared by both entry points. +// structural validation is shared by both entry points, so this is still a +// hard failure, reported via the specific-message ValidationFailed entry +// (400). func TestGraphQLUpdate_SDLAndSDLUrlMutuallyExclusive(t *testing.T) { stored := &model.GraphQLAPI{ ID: "some-uuid", @@ -1371,9 +1389,9 @@ func TestGraphQLUpdate_SDLAndSDLUrlMutuallyExclusive(t *testing.T) { } } -// TestGraphQLUpdate_SDLUrlFetchFailure_SchemaResolveFailed is Update's -// counterpart to TestGraphQLCreate_SDLUrlFetchFailure_SchemaResolveFailed. -func TestGraphQLUpdate_SDLUrlFetchFailure_SchemaResolveFailed(t *testing.T) { +// TestGraphQLUpdate_SDLUrlFetchFailure_PreservesExistingSchema is Update's +// counterpart to TestGraphQLCreate_SDLUrlFetchFailure_SucceedsWithEmptySchema. +func TestGraphQLUpdate_SDLUrlFetchFailure_PreservesExistingSchema(t *testing.T) { stored := &model.GraphQLAPI{ ID: "some-uuid", Handle: "countries-graphql-api", @@ -1395,15 +1413,14 @@ func TestGraphQLUpdate_SDLUrlFetchFailure_SchemaResolveFailed(t *testing.T) { 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 _, err := svc.Update("org-1", "countries-graphql-api", "updater-uuid", req); err != nil { + t.Fatalf("expected update to succeed despite a blocked sdlUrl, got: %v", err) } - if code := graphQLCatalogCode(t, err); code != apperror.CodeGraphQLAPISchemaResolveFailed { - t.Errorf("expected %s, got %s", apperror.CodeGraphQLAPISchemaResolveFailed, code) + if repo.updated == nil { + t.Fatal("expected the repository Update to be called") } - if repo.updated != nil { - t.Error("expected no repository write when sdlUrl fetch fails") + if repo.updated.Configuration.SDL != validCountriesGraphQLSDL { + t.Errorf("expected the previously-stored SDL to be preserved, got: %q", repo.updated.Configuration.SDL) } } diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 3179c6e1b4..1c44341674 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -1956,12 +1956,22 @@ paths: 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 + Creates a new GraphQL API in the platform. `schemaSource` declares how the + schema is supplied: `inline` (the `sdl` field), `url` (fetched from + `sdlUrl`), `file` (the `sdlFile` multipart part), or `introspection` (the + default — `upstream.main.url` must expose standard GraphQL introspection). + Only the field matching the declared source may be present — a request + that supplies a field not matching the declared `schemaSource` (or more + than one schema field at once), omits the field/part its declared source + requires, or declares `introspection` against an `upstream.main.ref` + instead of a literal `url`, is a request-shape problem and is rejected + with `400` (`VALIDATION_FAILED`) describing exactly what's inconsistent. + Once the request shape itself is valid, schema resolution is best-effort: + if the declared source can't actually be resolved (unreachable URL, + invalid SDL, introspection failing/disabled), the API is still created + with an empty schema rather than failing — fetch it later via + `GET /graphql-apis/{graphqlApiId}/sdl` once it can be resolved. The API is + associated with a project, which must belong to the organization specified in the JWT token. operationId: CreateGraphQLAPI security: @@ -1972,14 +1982,12 @@ paths: - 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. + GraphQL API object that needs to be added, as `multipart/form-data` — see + GraphQLAPIMultipartRequest. This is the only accepted content type, even + when `schemaSource` is `inline`, `url`, or `introspection` and no file is + being uploaded, so that every schema-source variant is expressed the + same way. content: - application/json: - schema: - $ref: '#/components/schemas/CreateGraphQLAPIRequest' multipart/form-data: schema: $ref: '#/components/schemas/GraphQLAPIMultipartRequest' @@ -2004,12 +2012,6 @@ paths: $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' @@ -2055,8 +2057,14 @@ paths: 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. + Updates an existing GraphQL API's details. `schemaSource` behaves as on + create (see `POST /graphql-apis`), including the same `400` + (`VALIDATION_FAILED`) response for a request shape that's inconsistent + with the declared `schemaSource` — re-supply `sdl`/`sdlUrl`/`sdlFile`, or + leave it as `introspection` to re-query `upstream.main.url` and pick up a + changed backend schema. If resolution fails (the source can't actually be + resolved right now), the previously-stored schema is left unchanged rather + than being cleared. operationId: UpdateGraphQLAPI security: - OAuth2Security: @@ -2075,10 +2083,10 @@ paths: example: countries-graphql-api requestBody: required: true + description: | + As `multipart/form-data` only — see GraphQLAPIMultipartRequest and the + note on `POST /graphql-apis`. content: - application/json: - schema: - $ref: '#/components/schemas/GraphQLAPI' multipart/form-data: schema: $ref: '#/components/schemas/GraphQLAPIMultipartRequest' @@ -2099,12 +2107,6 @@ paths: $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: @@ -8931,18 +8933,36 @@ components: description: Kind of the API based on its communication protocol or architectural style example: GraphQLApi default: GraphQLApi + schemaSource: + type: string + enum: [inline, url, file, introspection] + default: introspection + description: | + Declares how the schema is being supplied, so the server validates + against stated intent instead of guessing it from which fields happen + to be populated. `inline` requires `sdl`; `url` requires `sdlUrl`; + `file` requires the `sdlFile` multipart part (see + GraphQLAPIMultipartRequest); `introspection` (the default) requires a + literal `upstream.main.url` and derives the schema by querying it. + Only the field matching the declared source may be present — a + mismatch (wrong field populated, nothing populated, more than one + populated) is a `400` (`VALIDATION_FAILED`), not a silent + fall-through to a different resolution path. Schema *resolution* is + separate and best-effort: a failure to actually resolve (bad SDL, + unreachable URL, introspection failing) never fails the request — + see `sdl` below. + example: introspection sdl: type: string 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 GraphQL schema in SDL form — resolved per `schemaSource`, from a + directly-supplied document (`inline`/`file`), fetched from `sdlUrl` + (`url`), or derived from `upstream.main.url` (`introspection`). Always the *resolved* schema, never a document-supplied schema-location - reference. `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. + reference. Optional in practice: if resolution fails, the API is still + created/updated and this is left empty (create) or unchanged from its + previous value (update) rather than the request failing — see + `schemaSource`. example: | type Query { countries: [Country] @@ -8958,20 +8978,20 @@ components: 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 through the same shared SSRF-guarded HTTP client every other + A URL to a raw SDL document to fetch and use as `sdl` when + `schemaSource` is `url` — the write-side counterpart to how an OpenAPI + document can be supplied by reference for other artifact kinds (see + LlmProviderTemplate's `metadata.openapiSpecUrl`). Distinct from + `upstream.main.url`: this is a plain HTTP(S) GET of a static schema + file, not a live introspection query against a GraphQL server, and is + fetched through the same shared SSRF-guarded HTTP client every other operator/tenant-supplied fetch in this API uses, under the operator- configured policy (default `netguard.PermitPrivateBlockMetadata()`): the host is resolved and every candidate IP — including each redirect hop — is checked at dial time, refusing link-local/metadata/unspecified/ multicast addresses while private and in-cluster addresses (a Kubernetes - ClusterIP, a service-DNS name, localhost) remain reachable. Mutually - exclusive with `sdl`. Never stored or echoed back; only the fetched `sdl` - text is persisted and returned. + ClusterIP, a service-DNS name, localhost) remain reachable. Never stored + or echoed back; only the fetched `sdl` text is persisted and returned. example: https://raw.githubusercontent.com/example/countries-api/main/schema.graphql introspectionMode: allOf: @@ -9164,16 +9184,20 @@ components: 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. + GraphQLAPI fields for update, including `schemaSource`. When + `schemaSource` is `file`, the `sdlFile` part below is required and any + `sdl`/`sdlUrl` in this metadata is a structural-validation error, not a + silent override — every schema-source variant is expressed + consistently through the `schemaSource` field rather than by which + part happens to be present. example: | - {"displayName":"Countries GraphQL API","context":"/countries","version":"v1.0","projectId":"default-project","upstream":{"main":{"url":"https://countries.trevorblades.com/graphql"}}} + {"displayName":"Countries GraphQL API","context":"/countries","version":"v1.0","projectId":"default-project","schemaSource":"introspection","upstream":{"main":{"url":"https://countries.trevorblades.com/graphql"}}} sdlFile: type: string format: binary - description: The GraphQL SDL document as a file upload (e.g. schema.graphql). + description: | + The GraphQL SDL document as a file upload (e.g. schema.graphql). + Required when `schemaSource` is `file`; must be omitted otherwise. GraphQLAPIListItem: title: GraphQL API list item From 64b29ce3aa8da7222b56add2cb3a2f9185954dab Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Thu, 3 Sep 2026 15:05:58 +0530 Subject: [PATCH 29/38] Replace CR-file input with flags for graphql-api api-key create --- .../graphqlapi/apikey/commands_test.go | 125 ++++++++++-------- .../cmd/gateway/graphqlapi/apikey/create.go | 118 +++++++---------- cli/src/cmd/gateway/graphqlapi/apikey/root.go | 4 +- cli/src/utils/flags.go | 2 + 4 files changed, 126 insertions(+), 123 deletions(-) diff --git a/cli/src/cmd/gateway/graphqlapi/apikey/commands_test.go b/cli/src/cmd/gateway/graphqlapi/apikey/commands_test.go index d2427ea476..b5c115dbf4 100644 --- a/cli/src/cmd/gateway/graphqlapi/apikey/commands_test.go +++ b/cli/src/cmd/gateway/graphqlapi/apikey/commands_test.go @@ -21,8 +21,6 @@ package apikey import ( "encoding/json" "net/http" - "os" - "path/filepath" "strings" "testing" @@ -59,31 +57,6 @@ func writeGatewayConfig(t *testing.T, serverURL string) { }) } -// 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) @@ -101,7 +74,10 @@ func TestRunCreateCommand_PostsToAPIKeysEndpoint(t *testing.T) { }) writeGatewayConfig(t, server.URL) - createFilePath = writeAPIKeyCR(t, "smoke-key-1", "GraphQLApi", "countries-graphql-api", "") + createAPIID = "countries-graphql-api" + createName = "smoke-key-1" + createExpiresInDuration = 0 + createExpiresInUnit = "" if err := runCreateCommand(newTestCommand()); err != nil { t.Fatalf("unexpected error: %v", err) @@ -113,11 +89,14 @@ func TestRunCreateCommand_PostsToAPIKeysEndpoint(t *testing.T) { 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"]) + t.Fatalf("expected request body name to be the --name flag value, got %v", gotBody["name"]) + } + if _, present := gotBody["expiresIn"]; present { + t.Fatalf("expected no expiresIn field when duration/unit are unset, got %v", gotBody) } } -func TestRunCreateCommand_ForwardsExtraSpecFields(t *testing.T) { +func TestRunCreateCommand_NameOmitted_NotSentInBody(t *testing.T) { testutil.WithTempHome(t) var gotBody map[string]interface{} @@ -125,53 +104,91 @@ func TestRunCreateCommand_ForwardsExtraSpecFields(t *testing.T) { _ = json.NewDecoder(req.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"status":"success","message":"ok"}`)) + _, _ = w.Write([]byte(`{"status":"success","apiKey":{"name":"auto-generated-name"}}`)) }) 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") + createAPIID = "countries-graphql-api" + createName = "" + createExpiresInDuration = 0 + createExpiresInUnit = "" 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["name"]; present { + t.Fatalf("expected no 'name' field in the request body when --name is omitted, letting the server auto-generate one, got %v", gotBody) } - if _, present := gotBody["parentRef"]; present { - t.Fatalf("parentRef must not be forwarded into the request body, got %v", gotBody) +} + +func TestRunCreateCommand_WithExpiresIn_SendsDurationAndUnit(t *testing.T) { + testutil.WithTempHome(t) + + var gotBody map[string]interface{} + server := testutil.NewGatewayServer(t, func(w http.ResponseWriter, req *http.Request) { + _ = json.NewDecoder(req.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"status":"success","apiKey":{"name":"smoke-key-1"}}`)) + }) + writeGatewayConfig(t, server.URL) + + createAPIID = "countries-graphql-api" + createName = "smoke-key-1" + createExpiresInDuration = 30 + createExpiresInUnit = "days" + + if err := runCreateCommand(newTestCommand()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + expiresIn, ok := gotBody["expiresIn"].(map[string]interface{}) + if !ok { + t.Fatalf("expected request body to contain an expiresIn object, got %v", gotBody) + } + if expiresIn["duration"] != float64(30) || expiresIn["unit"] != "days" { + t.Fatalf("expected expiresIn {duration: 30, unit: days}, got %v", expiresIn) } } -func TestRunCreateCommand_RejectsNonGraphQLParentKind(t *testing.T) { +func TestRunCreateCommand_RequiresID(t *testing.T) { testutil.WithTempHome(t) - createFilePath = writeAPIKeyCR(t, "smoke-key-3", "RestApi", "some-rest-api", "") + createAPIID = "" + createName = "" + createExpiresInDuration = 0 + createExpiresInUnit = "" err := runCreateCommand(newTestCommand()) - if err == nil || !strings.Contains(err.Error(), "RestApi") { - t.Fatalf("expected a parentRef.kind validation error mentioning RestApi, got %v", err) + if err == nil { + t.Fatal("expected an --id validation error, got nil") } } -func TestRunCreateCommand_RequiresParentRefName(t *testing.T) { +func TestRunCreateCommand_ExpiresInDurationWithoutUnit_Errors(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) + createAPIID = "countries-graphql-api" + createName = "" + createExpiresInDuration = 30 + createExpiresInUnit = "" + + err := runCreateCommand(newTestCommand()) + if err == nil || !strings.Contains(err.Error(), "expires-in-unit") { + t.Fatalf("expected an error about --expires-in-unit being required alongside --expires-in-duration, got %v", err) } - createFilePath = path +} + +func TestRunCreateCommand_InvalidExpiresInUnit_Errors(t *testing.T) { + testutil.WithTempHome(t) + + createAPIID = "countries-graphql-api" + createName = "" + createExpiresInDuration = 30 + createExpiresInUnit = "fortnights" err := runCreateCommand(newTestCommand()) - if err == nil || !strings.Contains(err.Error(), "parentRef.name") { - t.Fatalf("expected a parentRef.name validation error, got %v", err) + if err == nil || !strings.Contains(err.Error(), "fortnights") { + t.Fatalf("expected an invalid-unit error mentioning the bad value, got %v", err) } } diff --git a/cli/src/cmd/gateway/graphqlapi/apikey/create.go b/cli/src/cmd/gateway/graphqlapi/apikey/create.go index 2ebe75acf2..6697de7ec2 100644 --- a/cli/src/cmd/gateway/graphqlapi/apikey/create.go +++ b/cli/src/cmd/gateway/graphqlapi/apikey/create.go @@ -31,40 +31,38 @@ import ( "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" -) +// validExpiresInUnits mirrors gateway-controller's +// APIKeyCreationRequestExpiresInUnit enum (pkg/utils/api_key.go) — the only +// units the server accepts for expiresIn.unit. +var validExpiresInUnits = map[string]bool{ + "seconds": true, + "minutes": true, + "hours": true, + "days": true, + "weeks": true, + "months": true, +} const ( CreateCmdLiteral = "create" - CreateCmdExample = `# Generate an API key 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` + CreateCmdExample = `# Generate an API key with an auto-generated name that never expires +ap gateway graphql-api api-key create --id countries-graphql-api + +# Generate a named API key that expires in 30 days +ap gateway graphql-api api-key create --id countries-graphql-api --name my-production-key --expires-in-duration 30 --expires-in-unit days` ) -var createFilePath string +var ( + createAPIID string + createName string + createExpiresInDuration int + createExpiresInUnit string +) var createCmd = &cobra.Command{ Use: CreateCmdLiteral, Short: "Generate an API key for a GraphQL API", - Long: "Generates a new API key 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.", + Long: "Generates a new API key for a GraphQL API. --name is optional — if omitted, the server generates a unique name. --expires-in-duration and --expires-in-unit must be supplied together to set an expiry; omit both for a key that never expires. The plaintext key is returned once in the response.", Example: CreateCmdExample, Run: func(cmd *cobra.Command, args []string) { if err := runCreateCommand(cmd); err != nil { @@ -76,35 +74,42 @@ var createCmd = &cobra.Command{ func init() { gateway.AddSelectionFlags(createCmd) - utils.AddStringFlag(createCmd, utils.FlagFile, &createFilePath, "", "Path to the ApiKey CR file (YAML or JSON)") - createCmd.MarkFlagRequired(utils.FlagFile) + utils.AddStringFlag(createCmd, utils.FlagID, &createAPIID, "", "GraphQL API ID (required)") + utils.AddStringFlag(createCmd, utils.FlagPropertyName, &createName, "", "Name for the API key. Omit to let the server generate a unique name.") + utils.AddIntFlag(createCmd, utils.FlagExpiresInDuration, &createExpiresInDuration, 0, "Expiry duration; must be paired with --expires-in-unit. Omit both for a key that never expires.") + utils.AddStringFlag(createCmd, utils.FlagExpiresInUnit, &createExpiresInUnit, "", "Expiry duration unit: seconds, minutes, hours, days, weeks, or months. Must be paired with --expires-in-duration.") + createCmd.MarkFlagRequired(utils.FlagID) } func runCreateCommand(cmd *cobra.Command) error { - if strings.TrimSpace(createFilePath) == "" { - return fmt.Errorf("--%s is required", utils.FlagFile) + if strings.TrimSpace(createAPIID) == "" { + return fmt.Errorf("--%s is required", utils.FlagID) } - 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 + // A duration of 0 / an empty unit both mean "not set" - there is no + // meaningful key that expires in 0 seconds, so treating either as unset + // requires the pair to be supplied together rather than one silently + // defaulting the other. + durationSet := createExpiresInDuration != 0 + unitSet := strings.TrimSpace(createExpiresInUnit) != "" + if durationSet != unitSet { + return fmt.Errorf("--%s and --%s must be provided together", utils.FlagExpiresInDuration, utils.FlagExpiresInUnit) } body := map[string]interface{}{} - for k, v := range cr.Spec { - if k == "parentRef" { - continue + if name := strings.TrimSpace(createName); name != "" { + body["name"] = name + } + if durationSet { + unit := strings.ToLower(strings.TrimSpace(createExpiresInUnit)) + if !validExpiresInUnits[unit] { + return fmt.Errorf("invalid --%s %q: must be one of seconds, minutes, hours, days, weeks, months", utils.FlagExpiresInUnit, createExpiresInUnit) + } + body["expiresIn"] = map[string]interface{}{ + "duration": createExpiresInDuration, + "unit": unit, } - body[k] = v } - body["name"] = cr.Metadata.Name data, err := json.Marshal(body) if err != nil { @@ -119,33 +124,12 @@ func runCreateCommand(cmd *cobra.Command) error { // 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)) + endpoint := fmt.Sprintf(utils.GatewayGraphQLAPIKeysPath, url.PathEscape(createAPIID)) resp, err := client.Post(endpoint, bytes.NewReader(data)) if err != nil { return fmt.Errorf("failed to create API key: %w", err) } - fmt.Printf("API key %q generated successfully.\n", cr.Metadata.Name) + fmt.Println("API key generated successfully.") 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/root.go b/cli/src/cmd/gateway/graphqlapi/apikey/root.go index eb6c648c04..1f71c28211 100644 --- a/cli/src/cmd/gateway/graphqlapi/apikey/root.go +++ b/cli/src/cmd/gateway/graphqlapi/apikey/root.go @@ -27,8 +27,8 @@ const ( 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` +# Generate a new API key with an auto-generated name +ap gateway graphql-api api-key create --id countries-graphql-api` ) // APIKeyCmd represents the gateway GraphQL API api-key command group. API keys diff --git a/cli/src/utils/flags.go b/cli/src/utils/flags.go index 6a675e1998..769f73f4a9 100644 --- a/cli/src/utils/flags.go +++ b/cli/src/utils/flags.go @@ -85,6 +85,8 @@ const ( FlagGatewayType = "gateway-type" FlagProjectID = "project-id" FlagEnvFile = "env-file" + FlagExpiresInDuration = "expires-in-duration" + FlagExpiresInUnit = "expires-in-unit" ) var shortFlags = map[string]string{ From 418f5183e16790ff2da1da967317e6832dba96db Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Fri, 4 Sep 2026 17:32:09 +0530 Subject: [PATCH 30/38] Improve api key error handling --- .../pkg/api/handlers/api_key_handler.go | 16 +++++++++ .../pkg/api/handlers/graphql_api_handler.go | 6 ++++ .../handlers/graphql_apikey_handler_test.go | 29 +++++++++++++++ .../pkg/api/handlers/handlers_test.go | 30 ++++++++++++++++ .../pkg/api/handlers/llm_provider_handler.go | 7 ++++ .../pkg/api/handlers/llm_proxy_handler.go | 6 ++++ .../gateway-controller/pkg/utils/api_key.go | 36 ++++++++++++------- 7 files changed, 117 insertions(+), 13 deletions(-) diff --git a/gateway/gateway-controller/pkg/api/handlers/api_key_handler.go b/gateway/gateway-controller/pkg/api/handlers/api_key_handler.go index 392672aad4..452caeac93 100644 --- a/gateway/gateway-controller/pkg/api/handlers/api_key_handler.go +++ b/gateway/gateway-controller/pkg/api/handlers/api_key_handler.go @@ -19,6 +19,7 @@ package handlers import ( + "errors" "fmt" "log/slog" "net/http" @@ -89,6 +90,11 @@ func (s *APIServer) CreateAPIKey(w http.ResponseWriter, r *http.Request, id stri Status: "error", Message: err.Error(), }) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) } else { log.Error("Failed to create API key", slog.Any("error", err), @@ -243,6 +249,11 @@ func (s *APIServer) UpdateAPIKey(w http.ResponseWriter, r *http.Request, id stri Status: "error", Message: err.Error(), }) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) } else { log.Error("Failed to update API key", slog.Any("error", err), @@ -317,6 +328,11 @@ func (s *APIServer) RegenerateAPIKey(w http.ResponseWriter, r *http.Request, id Status: "error", Message: err.Error(), }) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) } else { log.Error("Failed to regenerate API key", slog.Any("error", err), diff --git a/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go b/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go index 6183962421..3eb5011295 100644 --- a/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go +++ b/gateway/gateway-controller/pkg/api/handlers/graphql_api_handler.go @@ -396,6 +396,8 @@ func (s *APIServer) CreateGraphQLAPIKey(w http.ResponseWriter, r *http.Request, httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) } else if storage.IsConflictError(err) || strings.Contains(err.Error(), "already exists") { httputil.WriteJSON(w, http.StatusConflict, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { log.Error("Failed to create GraphQL API key", slog.String("handle", handle), slog.Any("error", err)) httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: "Failed to create API key"}) @@ -482,6 +484,8 @@ func (s *APIServer) UpdateGraphQLAPIKey(w http.ResponseWriter, r *http.Request, httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) } else if storage.IsConflictError(err) || strings.Contains(err.Error(), "already exists") { httputil.WriteJSON(w, http.StatusConflict, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { log.Error("Failed to update GraphQL API key", slog.String("handle", handle), slog.String("key", apiKeyName), slog.Any("error", err)) httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: "Failed to update API key"}) @@ -524,6 +528,8 @@ func (s *APIServer) RegenerateGraphQLAPIKey(w http.ResponseWriter, r *http.Reque if err != nil { if strings.Contains(err.Error(), "not found") { httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { log.Error("Failed to regenerate GraphQL API key", slog.String("handle", handle), slog.String("key", apiKeyName), slog.Any("error", err)) httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: "Failed to regenerate API key"}) 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 index d3b2164f40..fd843025ad 100644 --- a/gateway/gateway-controller/pkg/api/handlers/graphql_apikey_handler_test.go +++ b/gateway/gateway-controller/pkg/api/handlers/graphql_apikey_handler_test.go @@ -168,6 +168,35 @@ func TestCreateGraphQLAPIKeyDBError(t *testing.T) { require.Error(t, err) } +// TestCreateGraphQLAPIKeyExpirationInPast_ReturnsBadRequest guards against a regression where +// an expiresIn duration that computes to a past timestamp — a client input error — was mapped +// to a generic 500 instead of 400; see the identical fix applied to REST's CreateAPIKey. +func TestCreateGraphQLAPIKeyExpirationInPast_ReturnsBadRequest(t *testing.T) { + server := createTestAPIServer() + seedGraphQLAPIForAPIKeyHandlerTests(t, server, "test-handle") + + name := "test-key" + request := api.APIKeyCreationRequest{ + Name: &name, + ExpiresIn: &struct { + Duration int `json:"duration" yaml:"duration"` + Unit api.APIKeyCreationRequestExpiresInUnit `json:"unit" yaml:"unit"` + }{Duration: -10, Unit: api.APIKeyCreationRequestExpiresInUnitSeconds}, + } + body, err := json.Marshal(request) + require.NoError(t, err) + + w, r := createTestContextWithHeader("POST", "/graphql-apis/test-handle/api-keys", body, map[string]string{ + "Content-Type": "application/json", + }) + r = withAuthContext(r, commonmodels.AuthContext{UserID: "test-user", Roles: []string{"admin"}}) + + server.CreateGraphQLAPIKey(w, r, "test-handle") + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "must be in the future") +} + func TestCreateGraphQLAPIKeyAPINotFound(t *testing.T) { server := createTestAPIServer() diff --git a/gateway/gateway-controller/pkg/api/handlers/handlers_test.go b/gateway/gateway-controller/pkg/api/handlers/handlers_test.go index fbfe07a66c..7a88f8b62c 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers_test.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers_test.go @@ -2349,6 +2349,36 @@ func TestCreateAPIKeyDBError(t *testing.T) { require.Error(t, err) } +// TestCreateAPIKeyExpirationInPast_ReturnsBadRequest guards against a regression where a client +// input error (an expiresIn duration that computes to a past timestamp) was mapped to a generic +// 500 instead of 400 — the same fix applied identically to the GraphQL, LLM provider, and LLM +// proxy API-key handlers, which all share this same createAPIKeyFromRequest/CreateAPIKey path. +func TestCreateAPIKeyExpirationInPast_ReturnsBadRequest(t *testing.T) { + server := createTestAPIServer() + seedAPIForAPIKeyHandlerTests(t, server, "test-handle") + + name := "test-key" + request := api.APIKeyCreationRequest{ + Name: &name, + ExpiresIn: &struct { + Duration int `json:"duration" yaml:"duration"` + Unit api.APIKeyCreationRequestExpiresInUnit `json:"unit" yaml:"unit"` + }{Duration: -10, Unit: api.APIKeyCreationRequestExpiresInUnitSeconds}, + } + body, err := json.Marshal(request) + require.NoError(t, err) + + w, r := createTestContextWithHeader("POST", "/rest-apis/test-handle/api-keys", body, map[string]string{ + "Content-Type": "application/json", + }) + r = withAuthContext(r, commonmodels.AuthContext{UserID: "test-user", Roles: []string{"admin"}}) + + server.CreateAPIKey(w, r, "test-handle") + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "must be in the future") +} + // TestRevokeAPIKeyNoAuth tests RevokeAPIKey without authentication func TestRevokeAPIKeyNoAuth(t *testing.T) { server := createTestAPIServer() diff --git a/gateway/gateway-controller/pkg/api/handlers/llm_provider_handler.go b/gateway/gateway-controller/pkg/api/handlers/llm_provider_handler.go index 1081c91f41..a497583c6a 100644 --- a/gateway/gateway-controller/pkg/api/handlers/llm_provider_handler.go +++ b/gateway/gateway-controller/pkg/api/handlers/llm_provider_handler.go @@ -20,6 +20,7 @@ package handlers import ( "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -292,6 +293,8 @@ func (s *APIServer) CreateLLMProviderAPIKey(w http.ResponseWriter, r *http.Reque httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) } else if storage.IsConflictError(err) || strings.Contains(err.Error(), "already exists") { httputil.WriteJSON(w, http.StatusConflict, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: err.Error()}) } @@ -376,6 +379,8 @@ func (s *APIServer) UpdateLLMProviderAPIKey(w http.ResponseWriter, r *http.Reque httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) } else if storage.IsConflictError(err) || strings.Contains(err.Error(), "already exists") { httputil.WriteJSON(w, http.StatusConflict, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: err.Error()}) } @@ -417,6 +422,8 @@ func (s *APIServer) RegenerateLLMProviderAPIKey(w http.ResponseWriter, r *http.R if err != nil { if strings.Contains(err.Error(), "not found") { httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: err.Error()}) } diff --git a/gateway/gateway-controller/pkg/api/handlers/llm_proxy_handler.go b/gateway/gateway-controller/pkg/api/handlers/llm_proxy_handler.go index 59bac53c5a..b5eaa57ac4 100644 --- a/gateway/gateway-controller/pkg/api/handlers/llm_proxy_handler.go +++ b/gateway/gateway-controller/pkg/api/handlers/llm_proxy_handler.go @@ -307,6 +307,8 @@ func (s *APIServer) CreateLLMProxyAPIKey(w http.ResponseWriter, r *http.Request, httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: fmt.Sprintf("LLM proxy '%s' not found", handle)}) } else if storage.IsConflictError(err) { httputil.WriteJSON(w, http.StatusConflict, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { log.Error("Failed to create LLM proxy API key", slog.String("handle", handle), slog.Any("error", err)) httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: "Failed to create API key"}) @@ -393,6 +395,8 @@ func (s *APIServer) UpdateLLMProxyAPIKey(w http.ResponseWriter, r *http.Request, httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: fmt.Sprintf("LLM proxy or API key '%s' not found", apiKeyName)}) } else if storage.IsConflictError(err) { httputil.WriteJSON(w, http.StatusConflict, api.ErrorResponse{Status: "error", Message: err.Error()}) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { log.Error("Failed to update LLM proxy API key", slog.String("handle", handle), slog.String("key", apiKeyName), slog.Any("error", err)) httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: "Failed to update API key"}) @@ -435,6 +439,8 @@ func (s *APIServer) RegenerateLLMProxyAPIKey(w http.ResponseWriter, r *http.Requ if err != nil { if storage.IsNotFoundError(err) { httputil.WriteJSON(w, http.StatusNotFound, api.ErrorResponse{Status: "error", Message: fmt.Sprintf("LLM proxy or API key '%s' not found", apiKeyName)}) + } else if errors.Is(err, utils.ErrAPIKeyExpirationInPast) || errors.Is(err, utils.ErrUnsupportedAPIKeyExpirationUnit) { + httputil.WriteJSON(w, http.StatusBadRequest, api.ErrorResponse{Status: "error", Message: err.Error()}) } else { log.Error("Failed to regenerate LLM proxy API key", slog.String("handle", handle), slog.String("key", apiKeyName), slog.Any("error", err)) httputil.WriteJSON(w, http.StatusInternalServerError, api.ErrorResponse{Status: "error", Message: "Failed to regenerate API key"}) diff --git a/gateway/gateway-controller/pkg/utils/api_key.go b/gateway/gateway-controller/pkg/utils/api_key.go index 24deeb5571..d986d32ca9 100644 --- a/gateway/gateway-controller/pkg/utils/api_key.go +++ b/gateway/gateway-controller/pkg/utils/api_key.go @@ -43,6 +43,16 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" ) +// ErrAPIKeyExpirationInPast and ErrUnsupportedAPIKeyExpirationUnit are sentinel errors for the +// two ways an API key expiry request can be invalid — a client input problem, not a server +// fault. createAPIKeyFromRequest, updateAPIKeyFromRequest, and regenerateAPIKey wrap one of +// these into every expiry-validation error they return (via fmt.Errorf's %w), so handlers can +// map them to 400 with errors.Is instead of falling through to a generic 500. +var ( + ErrAPIKeyExpirationInPast = errors.New("API key expiration time must be in the future") + ErrUnsupportedAPIKeyExpirationUnit = errors.New("unsupported expiration unit") +) + // APIKeyCreationParams contains parameters for API key creation operations. // Handles both local key generation and external key injection. type APIKeyCreationParams struct { @@ -1067,7 +1077,7 @@ func (s *APIKeyService) createAPIKeyFromRequest(handle string, request *api.APIK case api.APIKeyCreationRequestExpiresInUnitMonths: timeDuration *= 30 * 24 * time.Hour // Approximate month as 30 days default: - return nil, fmt.Errorf("unsupported expiration unit: %s", request.ExpiresIn.Unit) + return nil, fmt.Errorf("%w: %s", ErrUnsupportedAPIKeyExpirationUnit, request.ExpiresIn.Unit) } expiry := now.Add(timeDuration) expiresAt = &expiry @@ -1075,8 +1085,8 @@ func (s *APIKeyService) createAPIKeyFromRequest(handle string, request *api.APIK // Validate that expiresAt is in the future if expiresAt != nil && expiresAt.Before(now) { - return nil, fmt.Errorf("API key expiration time must be in the future, got: %s (current time: %s)", - expiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) + return nil, fmt.Errorf("%w, got: %s (current time: %s)", + ErrAPIKeyExpirationInPast, expiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) } keyCreatedAt := now @@ -1223,8 +1233,8 @@ func (s *APIKeyService) updateAPIKeyFromRequest(existingKey *models.APIKey, requ if request.ExpiresAt != nil { if request.ExpiresAt.Before(now) { - return nil, fmt.Errorf("API key expiration time must be in the future, got: %s (current time: %s)", - request.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) + return nil, fmt.Errorf("%w, got: %s (current time: %s)", + ErrAPIKeyExpirationInPast, request.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) } expiresAt = request.ExpiresAt logger.Info("Using provided expires_at for update", slog.Time("expires_at", *expiresAt)) @@ -1244,7 +1254,7 @@ func (s *APIKeyService) updateAPIKeyFromRequest(existingKey *models.APIKey, requ case api.APIKeyCreationRequestExpiresInUnitMonths: timeDuration *= 30 * 24 * time.Hour default: - return nil, fmt.Errorf("unsupported expiration unit: %s", request.ExpiresIn.Unit) + return nil, fmt.Errorf("%w: %s", ErrUnsupportedAPIKeyExpirationUnit, request.ExpiresIn.Unit) } expiry := now.Add(timeDuration) expiresAt = &expiry @@ -1256,8 +1266,8 @@ func (s *APIKeyService) updateAPIKeyFromRequest(existingKey *models.APIKey, requ // Validate that expiresAt is in the future (if set) if expiresAt != nil && expiresAt.Before(now) { - return nil, fmt.Errorf("API key expiration time must be in the future, got: %s (current time: %s)", - expiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) + return nil, fmt.Errorf("%w, got: %s (current time: %s)", + ErrAPIKeyExpirationInPast, expiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) } keyUpdatedAt := now @@ -1307,8 +1317,8 @@ func (s *APIKeyService) regenerateAPIKey(existingKey *models.APIKey, request api if request.ExpiresAt != nil { if request.ExpiresAt.Before(now) { - return nil, fmt.Errorf("API key expiration time must be in the future, got: %s (current time: %s)", - request.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) + return nil, fmt.Errorf("%w, got: %s (current time: %s)", + ErrAPIKeyExpirationInPast, request.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) } expiresAt = request.ExpiresAt logger.Info("Using provided expires_at for regeneration", slog.Time("expires_at", *expiresAt)) @@ -1328,7 +1338,7 @@ func (s *APIKeyService) regenerateAPIKey(existingKey *models.APIKey, request api case api.APIKeyRegenerationRequestExpiresInUnitMonths: timeDuration *= 30 * 24 * time.Hour default: - return nil, fmt.Errorf("unsupported expiration unit: %s", request.ExpiresIn.Unit) + return nil, fmt.Errorf("%w: %s", ErrUnsupportedAPIKeyExpirationUnit, request.ExpiresIn.Unit) } expiry := now.Add(timeDuration) expiresAt = &expiry @@ -1346,8 +1356,8 @@ func (s *APIKeyService) regenerateAPIKey(existingKey *models.APIKey, request api // Validate that expiresAt is in the future (if set) if expiresAt != nil && expiresAt.Before(now) { - return nil, fmt.Errorf("API key expiration time must be in the future, got: %s (current time: %s)", - expiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) + return nil, fmt.Errorf("%w, got: %s (current time: %s)", + ErrAPIKeyExpirationInPast, expiresAt.Format(time.RFC3339), now.Format(time.RFC3339)) } // Create the regenerated API key From 6df192cb4a825c2163b083ea59c680cb80700981 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Mon, 7 Sep 2026 10:28:56 +0530 Subject: [PATCH 31/38] Fix host port collision between mock-oauth2-idp and mock-graphql-backend --- gateway/it/docker-compose.test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/it/docker-compose.test.yaml b/gateway/it/docker-compose.test.yaml index 3bf9182027..160b4709c4 100644 --- a/gateway/it/docker-compose.test.yaml +++ b/gateway/it/docker-compose.test.yaml @@ -341,7 +341,7 @@ services: context: ../../tests/mock-servers/mock-graphql-backend dockerfile: Dockerfile ports: - - "8088:8080" + - "8089:8080" healthcheck: test: ["CMD", "wget", "--spider", "--quiet", "--tries=1", "http://127.0.0.1:8080/health"] interval: 5s From a77daf1c354af50d68fdb47c7da96a64eef76355 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Mon, 7 Sep 2026 11:59:12 +0530 Subject: [PATCH 32/38] Add a SDL validation endpoint --- platform-api/api/generated.go | 60 ++++++++ platform-api/internal/handler/graphql_api.go | 57 +++++++ .../internal/handler/graphql_api_test.go | 40 +++++ platform-api/internal/service/graphql_api.go | 18 +++ .../internal/service/graphql_api_test.go | 140 ++++++++++++++++++ platform-api/resources/openapi.yaml | 117 +++++++++++++++ 6 files changed, 432 insertions(+) diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index 396b085588..1cdb315a72 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -396,6 +396,14 @@ const ( Revoked UserAPIKeyItemStatus = "revoked" ) +// Defines values for ValidateGraphQLSchemaRequestSchemaSource. +const ( + File ValidateGraphQLSchemaRequestSchemaSource = "file" + Inline ValidateGraphQLSchemaRequestSchemaSource = "inline" + Introspection ValidateGraphQLSchemaRequestSchemaSource = "introspection" + Url ValidateGraphQLSchemaRequestSchemaSource = "url" +) + // Defines values for DeploymentStatusQ. const ( DeploymentStatusQARCHIVED DeploymentStatusQ = "ARCHIVED" @@ -3485,6 +3493,55 @@ type UserAPIKeyListResponse struct { Pagination Pagination `json:"pagination" yaml:"pagination"` } +// ValidateGraphQLSchemaMultipartRequest defines model for ValidateGraphQLSchemaMultipartRequest. +type ValidateGraphQLSchemaMultipartRequest struct { + // Metadata JSON-encoded ValidateGraphQLSchemaRequest. + Metadata string `binding:"required" json:"metadata" yaml:"metadata"` + + // SdlFile The GraphQL SDL document as a file upload. Required when + // `schemaSource` is `file`; must be omitted otherwise. + SdlFile *openapi_types.File `json:"sdlFile,omitempty" yaml:"sdlFile,omitempty"` +} + +// ValidateGraphQLSchemaRequest defines model for ValidateGraphQLSchemaRequest. +type ValidateGraphQLSchemaRequest struct { + // SchemaSource Same semantics as `GraphQLAPI.schemaSource` — declares which of + // `sdl`/`sdlUrl`/the `sdlFile` multipart part/`upstream.main.url` + // supplies the schema to resolve. + SchemaSource *ValidateGraphQLSchemaRequestSchemaSource `json:"schemaSource,omitempty" yaml:"schemaSource,omitempty"` + + // Sdl The GraphQL schema in SDL form, when `schemaSource` is `inline` (or the uploaded file's content, when `file`). + Sdl *string `json:"sdl,omitempty" yaml:"sdl,omitempty"` + + // SdlUrl A URL to fetch the SDL from, when `schemaSource` is `url`. + SdlUrl *string `json:"sdlUrl,omitempty" yaml:"sdlUrl,omitempty"` + + // Upstream Upstream backend configuration with main and sandbox endpoints + Upstream *Upstream `json:"upstream,omitempty" yaml:"upstream,omitempty"` +} + +// ValidateGraphQLSchemaRequestSchemaSource Same semantics as `GraphQLAPI.schemaSource` — declares which of +// `sdl`/`sdlUrl`/the `sdlFile` multipart part/`upstream.main.url` +// supplies the schema to resolve. +type ValidateGraphQLSchemaRequestSchemaSource string + +// ValidateGraphQLSchemaResponse defines model for ValidateGraphQLSchemaResponse. +type ValidateGraphQLSchemaResponse struct { + IntrospectionMode *GraphQLIntrospectionMode `json:"introspectionMode,omitempty" yaml:"introspectionMode,omitempty"` + + // Message A generic explanation, set only when `resolved` is `false`. Never + // the specific parser/fetch/introspection failure reason — reuses + // the same sterile message `GraphQLAPISchemaResolveFailed` uses + // elsewhere (`error-handling.md`). + Message *string `json:"message,omitempty" yaml:"message,omitempty"` + + // Resolved Whether the declared schemaSource actually resolved to a usable schema. + Resolved bool `binding:"required" json:"resolved" yaml:"resolved"` + + // Sdl The resolved SDL text when `resolved` is `true`; empty otherwise. + Sdl string `binding:"required" json:"sdl" yaml:"sdl"` +} + // ValidateOpenAPIResponse defines model for ValidateOpenAPIResponse. type ValidateOpenAPIResponse struct { // Errors Validation errors; empty when isValid is true @@ -4266,6 +4323,9 @@ type UpdateGatewayJSONRequestBody = GatewayResponse // CreateGraphQLAPIMultipartRequestBody defines body for CreateGraphQLAPI for multipart/form-data ContentType. type CreateGraphQLAPIMultipartRequestBody = GraphQLAPIMultipartRequest +// ValidateGraphQLSchemaMultipartRequestBody defines body for ValidateGraphQLSchema for multipart/form-data ContentType. +type ValidateGraphQLSchemaMultipartRequestBody = ValidateGraphQLSchemaMultipartRequest + // UpdateGraphQLAPIMultipartRequestBody defines body for UpdateGraphQLAPI for multipart/form-data ContentType. type UpdateGraphQLAPIMultipartRequestBody = GraphQLAPIMultipartRequest diff --git a/platform-api/internal/handler/graphql_api.go b/platform-api/internal/handler/graphql_api.go index 885efa6ede..a3912bd8f5 100644 --- a/platform-api/internal/handler/graphql_api.go +++ b/platform-api/internal/handler/graphql_api.go @@ -94,6 +94,41 @@ func (h *GraphQLAPIHandler) CreateGraphQLAPI(w http.ResponseWriter, r *http.Requ return nil } +// ValidateGraphQLSchema handles POST /api/v0.9/graphql-apis/validate-schema — +// a dry-run of resolveSchema (see graphql_api.go's Create for the mutating +// counterpart) that never persists anything. A structural mismatch +// (schemaSource inconsistent with the fields supplied) surfaces as the usual +// 400 via serviceError; an actual resolution failure is not an error here — +// it comes back as {resolved: false, message: "..."} with a 200. +func (h *GraphQLAPIHandler) ValidateGraphQLSchema(w http.ResponseWriter, r *http.Request) error { + if _, exists := middleware.GetOrganizationFromRequest(r); !exists { + return apperror.Unauthorized.New(). + WithLogMessage("organization claim not found in token") + } + + var req api.ValidateGraphQLSchemaRequest + if err := decodeValidateGraphQLSchemaRequest(r, &req); err != nil { + return apperror.NewValidation(err) + } + + resolution, err := h.graphqlAPIService.ValidateSchema(req) + if err != nil { + return serviceError(err, "failed to validate GraphQL schema") + } + + resp := api.ValidateGraphQLSchemaResponse{Resolved: resolution.Resolved, Sdl: resolution.SDL} + if resolution.Resolved { + mode := api.GraphQLIntrospectionMode(resolution.IntrospectionMode) + resp.IntrospectionMode = &mode + } else { + msg := apperror.GraphQLAPISchemaResolveFailed.New().Message + resp.Message = &msg + } + + httputil.WriteJSON(w, http.StatusOK, resp) + return nil +} + // GetGraphQLAPI handles GET /api/v0.9/graphql-apis/:graphqlApiId and retrieves a GraphQL API by its handle. func (h *GraphQLAPIHandler) GetGraphQLAPI(w http.ResponseWriter, r *http.Request) error { orgId, exists := middleware.GetOrganizationFromRequest(r) @@ -337,11 +372,33 @@ func decodeUpdateGraphQLAPIRequest(r *http.Request, req *api.GraphQLAPI) error { return nil } +// decodeValidateGraphQLSchemaRequest is decodeCreateGraphQLAPIRequest's +// counterpart for the dry-run validate endpoint, targeting +// api.ValidateGraphQLSchemaRequest — the lightweight schema-only shape, +// without displayName/context/version/projectId. +func decodeValidateGraphQLSchemaRequest(r *http.Request, req *api.ValidateGraphQLSchemaRequest) error { + if !utils.IsMultipartFormRequest(r) { + return fmt.Errorf("Content-Type must be multipart/form-data") + } + metadataJSON, sdl, err := utils.ParseGraphQLAPIMultipartRequest(r) + if err != nil { + return err + } + if err := json.Unmarshal(metadataJSON, req); err != nil { + return err + } + if sdl != "" { + req.Sdl = &sdl + } + return nil +} + // RegisterRoutes registers all GraphQL API routes. func (h *GraphQLAPIHandler) RegisterRoutes(mux router.Router) { h.slogger.Debug("Registering GraphQL API routes") base := constants.APIBasePath + "/graphql-apis" mux.HandleFunc("POST "+base, middleware.MapErrors(h.slogger, h.CreateGraphQLAPI)) + mux.HandleFunc("POST "+base+"/validate-schema", middleware.MapErrors(h.slogger, h.ValidateGraphQLSchema)) mux.HandleFunc("GET "+base, middleware.MapErrors(h.slogger, h.ListGraphQLAPIs)) mux.HandleFunc("GET "+base+"/{graphqlApiId}", middleware.MapErrors(h.slogger, h.GetGraphQLAPI)) mux.HandleFunc("GET "+base+"/{graphqlApiId}/sdl", middleware.MapErrors(h.slogger, h.GetGraphQLAPISDL)) diff --git a/platform-api/internal/handler/graphql_api_test.go b/platform-api/internal/handler/graphql_api_test.go index 5af983af9c..26421b0ecd 100644 --- a/platform-api/internal/handler/graphql_api_test.go +++ b/platform-api/internal/handler/graphql_api_test.go @@ -165,3 +165,43 @@ func TestDecodeUpdateGraphQLAPIRequest_Multipart_MissingMetadata(t *testing.T) { t.Fatal("expected an error when the metadata field is missing") } } + +// TestDecodeValidateGraphQLSchemaRequest_JSON_Rejected mirrors +// TestDecodeCreateGraphQLAPIRequest_JSON_Rejected — the validate endpoint is +// multipart-only too, for the same "every schemaSource variant expressed the +// same way" reason. +func TestDecodeValidateGraphQLSchemaRequest_JSON_Rejected(t *testing.T) { + body := `{"sdl":"type Query { x: String }"}` + req := httptest.NewRequest(http.MethodPost, "/graphql-apis/validate-schema", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + var out api.ValidateGraphQLSchemaRequest + if err := decodeValidateGraphQLSchemaRequest(req, &out); err == nil { + t.Fatal("expected application/json to be rejected now that multipart/form-data is the only accepted content type") + } +} + +// TestDecodeValidateGraphQLSchemaRequest_Multipart_FileContentLandsInSdl +// mirrors TestDecodeCreateGraphQLAPIRequest_Multipart_FileContentAndMetadataSDLUrlBothSurvive +// for the validate endpoint's lightweight request type. +func TestDecodeValidateGraphQLSchemaRequest_Multipart_FileContentLandsInSdl(t *testing.T) { + metadata := `{"schemaSource":"file"}` + req := newGraphQLAPIMultipartHandlerRequest(t, metadata, graphQLHandlerTestSDL, true) + + var out api.ValidateGraphQLSchemaRequest + if err := decodeValidateGraphQLSchemaRequest(req, &out); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Sdl == nil || *out.Sdl != graphQLHandlerTestSDL { + t.Errorf("expected sdl to carry the uploaded file's content, got %v", out.Sdl) + } +} + +func TestDecodeValidateGraphQLSchemaRequest_Multipart_MissingMetadata(t *testing.T) { + req := newGraphQLAPIMultipartHandlerRequest(t, "", graphQLHandlerTestSDL, true) + + var out api.ValidateGraphQLSchemaRequest + if err := decodeValidateGraphQLSchemaRequest(req, &out); err == nil { + t.Fatal("expected an error when the metadata field is missing") + } +} diff --git a/platform-api/internal/service/graphql_api.go b/platform-api/internal/service/graphql_api.go index e611df183c..1b5e387f16 100644 --- a/platform-api/internal/service/graphql_api.go +++ b/platform-api/internal/service/graphql_api.go @@ -269,6 +269,24 @@ func (s *GraphQLAPIService) Create(orgUUID, createdBy string, req *api.CreateGra return s.Get(orgUUID, handle) } +// ValidateSchema is the non-mutating dry-run counterpart to resolveSchema, +// backing POST /graphql-apis/validate-schema. It shares the exact same +// structural-vs-best-effort semantics Create/Update use (see resolveSchema's +// doc comment) but never persists anything and takes no org/handle — there +// is nothing org-scoped to check here, only the caller-supplied +// schema-source fields themselves. +func (s *GraphQLAPIService) ValidateSchema(req api.ValidateGraphQLSchemaRequest) (graphQLSchemaResolution, error) { + var schemaSource string + if req.SchemaSource != nil { + schemaSource = string(*req.SchemaSource) + } + var upstream *model.UpstreamConfig + if req.Upstream != nil { + upstream = mapUpstreamAPIToModel(*req.Upstream) + } + return s.resolveSchema(schemaSource, utils.ValueOrEmpty(req.Sdl), utils.ValueOrEmpty(req.SdlUrl), upstream) +} + // graphQLSchemaResolution is resolveSchema's outcome. Resolved is false when // the declared schemaSource was structurally valid but the actual content // couldn't be turned into a usable schema (bad SDL text, an unreachable diff --git a/platform-api/internal/service/graphql_api_test.go b/platform-api/internal/service/graphql_api_test.go index 8056cdb5b6..047424127a 100644 --- a/platform-api/internal/service/graphql_api_test.go +++ b/platform-api/internal/service/graphql_api_test.go @@ -1005,6 +1005,146 @@ func TestGraphQLCreate_MissingSDLAndUpstream_ValidationFailed(t *testing.T) { } } +// TestGraphQLValidateSchema_Inline_Success guards ValidateSchema's happy path +// for schemaSource "inline" — it should behave identically to Create's own +// resolution, just without persisting anything. +func TestGraphQLValidateSchema_Inline_Success(t *testing.T) { + svc := newGraphQLTestService(&mockGraphQLAPIRepo{}, nil) + + req := api.ValidateGraphQLSchemaRequest{ + Sdl: graphQLStrPtr(validCountriesGraphQLSDL), + } + + resolution, err := svc.ValidateSchema(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !resolution.Resolved { + t.Fatal("expected the schema to resolve") + } + if resolution.SDL != validCountriesGraphQLSDL { + t.Errorf("expected the resolved sdl to match the supplied sdl verbatim, got %q", resolution.SDL) + } + if resolution.IntrospectionMode != "SDL" { + t.Errorf("expected introspectionMode SDL, got %q", resolution.IntrospectionMode) + } +} + +// TestGraphQLValidateSchema_Introspection_Success guards the schemaSource +// "introspection" (default) happy path via a real httptest introspection +// endpoint. +func TestGraphQLValidateSchema_Introspection_Success(t *testing.T) { + introspectionJSON := `{ + "data": { + "__schema": { + "queryType": {"name": "Query"}, + "mutationType": null, + "subscriptionType": null, + "types": [ + { + "kind": "OBJECT", + "name": "Query", + "description": "", + "fields": [ + { + "name": "hello", + "description": "", + "args": [], + "type": {"kind": "SCALAR", "name": "String", "ofType": null} + } + ] + } + ] + } + } + }` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(introspectionJSON)) + })) + defer server.Close() + + svc := newGraphQLTestService(&mockGraphQLAPIRepo{}, nil) + + req := api.ValidateGraphQLSchemaRequest{ + Upstream: &api.Upstream{Main: api.UpstreamDefinition{Url: graphQLStrPtr(server.URL)}}, + } + + resolution, err := svc.ValidateSchema(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !resolution.Resolved { + t.Fatal("expected the schema to resolve via introspection") + } + if resolution.IntrospectionMode != "ENDPOINT" { + t.Errorf("expected introspectionMode ENDPOINT, got %q", resolution.IntrospectionMode) + } + if !strings.Contains(resolution.SDL, "type Query") || !strings.Contains(resolution.SDL, "hello") { + t.Errorf("expected derived SDL to contain a Query type and the introspected field, got: %s", resolution.SDL) + } +} + +// TestGraphQLValidateSchema_ResolutionFailure_ReturnsUnresolved guards +// ValidateSchema's best-effort posture: a resolution-quality failure (here, +// malformed SDL) is never an error — it comes back as Resolved: false, the +// same shape Create/Update treat as "no schema, but the request succeeds." +func TestGraphQLValidateSchema_ResolutionFailure_ReturnsUnresolved(t *testing.T) { + svc := newGraphQLTestService(&mockGraphQLAPIRepo{}, nil) + + req := api.ValidateGraphQLSchemaRequest{ + Sdl: graphQLStrPtr("type Query { countries: [Country "), // unterminated brace + } + + resolution, err := svc.ValidateSchema(req) + if err != nil { + t.Fatalf("expected no error for a resolution-quality failure, got: %v", err) + } + if resolution.Resolved { + t.Fatal("expected the malformed SDL to fail to resolve") + } + if resolution.SDL != "" { + t.Errorf("expected an empty sdl when resolution fails, got %q", resolution.SDL) + } +} + +// TestGraphQLValidateSchema_StructuralMismatch_ValidationFailed guards the +// structural side of resolveSchema still applying to ValidateSchema — a +// schemaSource/field mismatch is a real error, not a soft "unresolved" +// outcome, mirroring TestGraphQLCreate_SDLAndSDLUrlMutuallyExclusive. +func TestGraphQLValidateSchema_StructuralMismatch_ValidationFailed(t *testing.T) { + svc := newGraphQLTestService(&mockGraphQLAPIRepo{}, nil) + + req := api.ValidateGraphQLSchemaRequest{ + Sdl: graphQLStrPtr(validCountriesGraphQLSDL), + SdlUrl: graphQLStrPtr("https://example.com/schema.graphql"), + } + + _, err := svc.ValidateSchema(req) + if err == nil { + t.Fatal("expected an error when both sdl and sdlUrl are supplied") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeCommonValidationFailed { + t.Errorf("expected %s, got %s", apperror.CodeCommonValidationFailed, code) + } +} + +// TestGraphQLValidateSchema_NoFieldsSupplied_ValidationFailed covers the case +// where nothing is supplied at all: schemaSource infers "introspection" (the +// default), which then has no upstream.main.url to call — a structural +// problem, not a resolution-quality one, matching Create's equivalent check. +func TestGraphQLValidateSchema_NoFieldsSupplied_ValidationFailed(t *testing.T) { + svc := newGraphQLTestService(&mockGraphQLAPIRepo{}, nil) + + _, err := svc.ValidateSchema(api.ValidateGraphQLSchemaRequest{}) + if err == nil { + t.Fatal("expected an error when no schema-source fields are supplied at all") + } + if code := graphQLCatalogCode(t, err); code != apperror.CodeCommonValidationFailed { + t.Errorf("expected %s, got %s", apperror.CodeCommonValidationFailed, code) + } +} + // TestGraphQLCreate_MissingContext_ValidationFailed covers the // displayName/version/context required-fields check with context specifically // omitted, matching the test-scenarios sheet's "context omitted" case. diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 1c44341674..f68d3d48a5 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -2179,6 +2179,51 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /graphql-apis/validate-schema: + post: + summary: Dry-run GraphQL schema resolution + description: | + Attempts to resolve a schema exactly as `POST`/`PUT /graphql-apis` + would — the same `schemaSource`-driven structural validation, the + same best-effort resolution (§5.2) — without persisting anything. A + request-shape mismatch (`schemaSource` inconsistent with the fields + supplied) is a `400` (`VALIDATION_FAILED`), same as create/update. An + actual resolution failure (bad SDL, an unreachable `sdlUrl`, a failed + introspection query) is **not** an error here either — the response + reports `resolved: false` so the caller can decide what to do, rather + than having to create a real API just to find out. + operationId: ValidateGraphQLSchema + security: + - OAuth2Security: + - ap:graphql_api:create + - ap:graphql_api:manage + tags: + - GraphQL APIs + requestBody: + description: | + As `multipart/form-data` only, following the same convention as + `POST /graphql-apis` — see `GraphQLAPIMultipartRequest`. + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/ValidateGraphQLSchemaMultipartRequest' + responses: + '200': + description: Schema resolution attempted — see `resolved` for the outcome. + content: + application/json: + schema: + $ref: '#/components/schemas/ValidateGraphQLSchemaResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + /graphql-apis/{graphqlApiId}/gateways: get: summary: Get gateways for GraphQL API @@ -9270,6 +9315,78 @@ components: pagination: $ref: '#/components/schemas/Pagination' + ValidateGraphQLSchemaRequest: + title: GraphQL schema validation request + type: object + properties: + schemaSource: + type: string + enum: [inline, url, file, introspection] + default: introspection + description: | + Same semantics as `GraphQLAPI.schemaSource` — declares which of + `sdl`/`sdlUrl`/the `sdlFile` multipart part/`upstream.main.url` + supplies the schema to resolve. + example: introspection + sdl: + type: string + description: The GraphQL schema in SDL form, when `schemaSource` is `inline` (or the uploaded file's content, when `file`). + sdlUrl: + type: string + format: uri + writeOnly: true + description: A URL to fetch the SDL from, when `schemaSource` is `url`. + upstream: + $ref: '#/components/schemas/Upstream' + description: | + Only relevant when `schemaSource` is `introspection` (explicit or + inferred) — unlike `GraphQLAPI.upstream`, this is not required, + since a validation request for `inline`/`url`/`file` has no use + for it. + + ValidateGraphQLSchemaMultipartRequest: + title: GraphQL schema validation request with SDL file upload + type: object + required: + - metadata + properties: + metadata: + type: string + description: JSON-encoded ValidateGraphQLSchemaRequest. + example: | + {"schemaSource":"introspection","upstream":{"main":{"url":"https://countries.trevorblades.com/graphql"}}} + sdlFile: + type: string + format: binary + description: | + The GraphQL SDL document as a file upload. Required when + `schemaSource` is `file`; must be omitted otherwise. + + ValidateGraphQLSchemaResponse: + title: GraphQL schema validation result + type: object + required: + - resolved + - sdl + properties: + resolved: + type: boolean + description: Whether the declared schemaSource actually resolved to a usable schema. + example: true + sdl: + type: string + description: The resolved SDL text when `resolved` is `true`; empty otherwise. + introspectionMode: + $ref: '#/components/schemas/GraphQLIntrospectionMode' + description: Only set when `resolved` is `true`. + message: + type: string + description: | + A generic explanation, set only when `resolved` is `false`. Never + the specific parser/fetch/introspection failure reason — reuses + the same sterile message `GraphQLAPISchemaResolveFailed` uses + elsewhere (`error-handling.md`). + example: The provided endpoint could not be used to derive a GraphQL schema, or the supplied SDL could not be parsed. TimeUnit: type: string From 0dd7b980b46eed11165236d7497209278ed11902 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Fri, 11 Sep 2026 18:20:15 +0530 Subject: [PATCH 33/38] Add GraphQLApi to UserAPIKey artifactType enum The GraphQL API-key handler (apiKeyService.CreateAPIKey) creates and returns keys with artifactType "GraphQLApi", but the OpenAPI enum only listed RestApi/LlmProvider/LlmProxy, so generated TS types didn't match runtime values. Co-Authored-By: Claude Sonnet 5 --- platform-api/resources/openapi.yaml | 4 +- .../src/api/generated/platform.d.ts | 1346 ++++++++++++++++- 2 files changed, 1311 insertions(+), 39 deletions(-) diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index f68d3d48a5..727889c040 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -7030,7 +7030,7 @@ paths: type: array items: type: string - enum: [RestApi, LlmProvider, LlmProxy] + enum: [RestApi, LlmProvider, LlmProxy, GraphQLApi] style: form explode: false example: LlmProxy,LlmProvider @@ -7511,7 +7511,7 @@ components: example: wso2-openai-provider artifactType: type: string - enum: [RestApi, LlmProvider, LlmProxy] + enum: [RestApi, LlmProvider, LlmProxy, GraphQLApi] description: Type of the artifact this key belongs to UserAPIKeyListResponse: diff --git a/portals/api-control-plane/src/api/generated/platform.d.ts b/portals/api-control-plane/src/api/generated/platform.d.ts index e367a1e68e..171f84285a 100644 --- a/portals/api-control-plane/src/api/generated/platform.d.ts +++ b/portals/api-control-plane/src/api/generated/platform.d.ts @@ -845,6 +845,318 @@ export interface paths { patch?: never; trace?: never; }; + "/graphql-apis": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get all GraphQL APIs for an organization + * @description Retrieves all GraphQL APIs belonging to an organization. Requires the + * projectId query parameter to filter APIs by project. Access is validated + * against the organization in the JWT token. + */ + get: operations["ListGraphQLAPIs"]; + put?: never; + /** + * Create a new GraphQL API + * @description Creates a new GraphQL API in the platform. `schemaSource` declares how the + * schema is supplied: `inline` (the `sdl` field), `url` (fetched from + * `sdlUrl`), `file` (the `sdlFile` multipart part), or `introspection` (the + * default — `upstream.main.url` must expose standard GraphQL introspection). + * Only the field matching the declared source may be present — a request + * that supplies a field not matching the declared `schemaSource` (or more + * than one schema field at once), omits the field/part its declared source + * requires, or declares `introspection` against an `upstream.main.ref` + * instead of a literal `url`, is a request-shape problem and is rejected + * with `400` (`VALIDATION_FAILED`) describing exactly what's inconsistent. + * Once the request shape itself is valid, schema resolution is best-effort: + * if the declared source can't actually be resolved (unreachable URL, + * invalid SDL, introspection failing/disabled), the API is still created + * with an empty schema rather than failing — fetch it later via + * `GET /graphql-apis/{graphqlApiId}/sdl` once it can be resolved. The API is + * associated with a project, which must belong to the organization + * specified in the JWT token. + */ + post: operations["CreateGraphQLAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/{graphqlApiId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get GraphQL API by ID + * @description Retrieves the GraphQL API's metadata and configuration. The `sdl` field + * is deliberately omitted from this response — it can be large, and most + * callers only need the metadata — fetch it separately via + * `GET /graphql-apis/{graphqlApiId}/sdl`. + */ + get: operations["GetGraphQLAPI"]; + /** + * Update GraphQL API + * @description Updates an existing GraphQL API's details. `schemaSource` behaves as on + * create (see `POST /graphql-apis`), including the same `400` + * (`VALIDATION_FAILED`) response for a request shape that's inconsistent + * with the declared `schemaSource` — re-supply `sdl`/`sdlUrl`/`sdlFile`, or + * leave it as `introspection` to re-query `upstream.main.url` and pick up a + * changed backend schema. If resolution fails (the source can't actually be + * resolved right now), the previously-stored schema is left unchanged rather + * than being cleared. + */ + put: operations["UpdateGraphQLAPI"]; + post?: never; + /** Delete GraphQL API */ + delete: operations["DeleteGraphQLAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/{graphqlApiId}/sdl": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the SDL for a GraphQL API + * @description Retrieves the GraphQL API's resolved schema in SDL form — the same text + * `GET /graphql-apis/{graphqlApiId}` would have returned in its `sdl` field + * before that field was split out into this dedicated endpoint (large, and + * rarely needed alongside the rest of the metadata). + */ + get: operations["GetGraphQLAPISDL"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/validate-schema": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Dry-run GraphQL schema resolution + * @description Attempts to resolve a schema exactly as `POST`/`PUT /graphql-apis` + * would — the same `schemaSource`-driven structural validation, the + * same best-effort resolution (§5.2) — without persisting anything. A + * request-shape mismatch (`schemaSource` inconsistent with the fields + * supplied) is a `400` (`VALIDATION_FAILED`), same as create/update. An + * actual resolution failure (bad SDL, an unreachable `sdlUrl`, a failed + * introspection query) is **not** an error here either — the response + * reports `resolved: false` so the caller can decide what to do, rather + * than having to create a real API just to find out. + */ + post: operations["ValidateGraphQLSchema"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/{graphqlApiId}/gateways": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get gateways for GraphQL API + * @description Retrieves all gateways associated with the specified API, including deployment details. + * Returns gateway information along with association timestamps and deployment status. + * Access is validated against the organization in the JWT token. + */ + get: operations["GetGraphQLAPIGateways"]; + put?: never; + /** + * Add gateways for GraphQL API + * @description Associates gateways to the specified API. If gateways are already associated, + * updates the association timestamp. Returns all gateways associated with the API + * including deployment details. Access is validated against the organization + * in the JWT token. + */ + post: operations["AddGatewaysToGraphQLAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/{graphqlApiId}/api-keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create API key + * @description Creates a new API key for the specified GraphQL API. The API key will be hashed before + * storage and broadcasted to all gateways where the API is deployed. This endpoint + * allows external platforms to inject API keys to hybrid gateways. + */ + post: operations["CreateGraphQLAPIKey"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/{graphqlApiId}/api-keys/{apiKeyId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update API key + * @description Updates an existing API key for the specified GraphQL API. The new API key value will + * be hashed before storage and broadcasted to all gateways where the API is deployed. + * This endpoint allows external platforms to rotate API keys on hybrid gateways. + */ + put: operations["UpdateGraphQLAPIKey"]; + post?: never; + /** + * Revoke API key + * @description Revokes an API key for the specified GraphQL API. The revocation will be broadcasted + * to all gateways where the API is deployed. This endpoint allows external platforms + * to revoke API keys on hybrid gateways. + */ + delete: operations["RevokeGraphQLAPIKey"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/{graphqlApiId}/deployments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get deployments for a GraphQL API + * @description Retrieves all deployment artifacts for a specific API. The graphqlApiId parameter is the API handle (identifier), + * not the UUID. Supports filtering by gateway handle and deployment status. + * Access is validated against the organization in the JWT token. + */ + get: operations["GetGraphQLAPIDeployments"]; + put?: never; + /** + * Create and deploy a new deployment + * @description Creates an immutable deployment artifact for a GraphQL API and deploys it to a specified gateway. + * Each deployment targets a single gateway. The graphqlApiId parameter is the API handle (identifier), + * not the UUID. The operation returns a transitional DEPLOYING status. Final success or failure will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. + * Access is validated against the organization in the JWT token. + */ + post: operations["DeployGraphQLAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/{graphqlApiId}/deployments/{deploymentId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get deployment by ID + * @description Retrieves metadata for a specific deployment artifact including status, gateway association, + * and timestamps. Access is validated against the organization in the JWT token. + */ + get: operations["GetGraphQLAPIDeployment"]; + put?: never; + post?: never; + /** + * Delete deployment + * @description Deletes a deployment artifact. Deletion is only allowed when the deployment is in UNDEPLOYED status. + * Access is validated against the organization in the JWT token. + */ + delete: operations["DeleteGraphQLAPIDeployment"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/{graphqlApiId}/deployments/{deploymentId}/undeploy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Undeploy deployment from gateway + * @description Undeploys an active deployment, stopping the API from being served on the specified gateway. + * The deployment artifact remains in the system and can be restored later. + * Returns the updated deployment object with initial status UNDEPLOYING. Final status (UNDEPLOYED or FAILED) will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. + * + * The gatewayId query parameter is validated against deployment's bound gateway to prevent unintended operations. + * Access is validated against the organization in the JWT token. + */ + post: operations["UndeployGraphQLAPIDeployment"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/graphql-apis/{graphqlApiId}/deployments/{deploymentId}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Restore a previous deployment + * @description Initiates restoring a previous deployment (ARCHIVED or UNDEPLOYED) on the specified gateway. + * Returns the deployment with initial status DEPLOYING. Final success or failure will be reported asynchronously via the deployment's status and statusReason once the gateway acknowledges. + * The target deployment must not already be in DEPLOYED status. + * + * The gatewayId query parameter is validated against the deployment's bound gateway to prevent unintended operations. + * Access is validated against the organization in the JWT token. + */ + post: operations["RestoreGraphQLAPIDeployment"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/llm-provider-templates": { parameters: { query?: never; @@ -2443,7 +2755,7 @@ export interface components { * @description Type of the artifact this key belongs to * @enum {string} */ - artifactType: "RestApi" | "LlmProvider" | "LlmProxy"; + artifactType: "RestApi" | "LlmProvider" | "LlmProxy" | "GraphQLApi"; }; UserAPIKeyListResponse: { /** @description List of API keys */ @@ -3478,45 +3790,413 @@ export interface components { content?: string; }; /** - * @description Time unit for API key expiration duration - * @example days + * @example ENDPOINT * @enum {string} */ - TimeUnit: "seconds" | "minutes" | "hours" | "days" | "weeks" | "months"; - ExpirationDuration: { - /** - * @description Duration value (must be positive) - * @example 30 - */ - duration: number; - unit: components["schemas"]["TimeUnit"]; - }; - CreateAPIKeyRequest: { + GraphQLIntrospectionMode: "SDL" | "ENDPOINT"; + /** GraphQL API object */ + GraphQLAPI: { /** - * @description Unique identifier for this API key within the API (optional; if omitted, - * generated from displayName) - * @example production-key-01 + * @description Unique handle/identifier for the API. Can be provided during creation or auto-generated. On update (PUT), if provided must match the path parameter — returns 400 if they differ. + * @example countries-graphql-api */ id?: string; /** - * @description Human-readable name for the API key - * @example Production API Key + * @description Human-readable name for the API + * @example Countries GraphQL API */ displayName: string; + /** @example Public GraphQL API for querying country/region reference data */ + description?: string; /** - * @description Optional. A pre-minted plain text API key to inject (used by external platforms pushing a key to hybrid gateways). Omit it to have the server generate one, the generated value is returned once in the response and is never retrievable afterwards. - * @example sk_example_1234567890abcdef + * @description Base path for the single GraphQL endpoint. Suggested (not enforced) + * convention: end the path with `/graphql`, matching how most standalone + * GraphQL servers name their single endpoint — this is not validated. + * @example /countries/graphql */ - apiKey?: string; + context: string; + /** @example v1.0 */ + version: string; + /** @example john.doe */ + readonly createdBy?: string; /** - * @description Optional reference ID for tracing purposes (from external platforms) - * @example ext-ref-12345 + * @description Only present in the detail response (GET /graphql-apis/{graphqlApiId}), omitted from list responses. + * @example john.doe */ - externalRefId?: string | null; + readonly updatedBy?: string; + /** @example default-project */ + projectId: string; /** * Format: date-time - * @description Optional expiration time in ISO 8601 format - * @example 2026-12-31T23:59:59Z + * @example 2026-08-11T10:00:00Z + */ + readonly createdAt?: string; + /** + * Format: date-time + * @example 2026-08-11T10:00:00Z + */ + readonly updatedAt?: string; + /** + * @description True if the artifact originated from a data-plane gateway (origin gateway_api) and is read-only in the control plane. + * @example false + */ + readonly readOnly?: boolean; + /** + * @description Reused unmodified from REST APIs. A GraphQL API has exactly one logical + * endpoint (no per-operation paths), so `upstream.main.url` is the single + * GraphQL endpoint — either the backend to proxy to (SDL-supplied case) or + * the endpoint introspected at creation time (see `sdl`/`introspectionMode` below). + */ + upstream: components["schemas"]["Upstream"]; + /** + * @description Kind of the API based on its communication protocol or architectural style + * @default GraphQLApi + * @example GraphQLApi + */ + kind: string; + /** + * @description Declares how the schema is being supplied, so the server validates + * against stated intent instead of guessing it from which fields happen + * to be populated. `inline` requires `sdl`; `url` requires `sdlUrl`; + * `file` requires the `sdlFile` multipart part (see + * GraphQLAPIMultipartRequest); `introspection` (the default) requires a + * literal `upstream.main.url` and derives the schema by querying it. + * Only the field matching the declared source may be present — a + * mismatch (wrong field populated, nothing populated, more than one + * populated) is a `400` (`VALIDATION_FAILED`), not a silent + * fall-through to a different resolution path. Schema *resolution* is + * separate and best-effort: a failure to actually resolve (bad SDL, + * unreachable URL, introspection failing) never fails the request — + * see `sdl` below. + * @default introspection + * @example introspection + * @enum {string} + */ + schemaSource: "inline" | "url" | "file" | "introspection"; + /** + * @description The GraphQL schema in SDL form — resolved per `schemaSource`, from a + * directly-supplied document (`inline`/`file`), fetched from `sdlUrl` + * (`url`), or derived from `upstream.main.url` (`introspection`). Always + * the *resolved* schema, never a document-supplied schema-location + * reference. Optional in practice: if resolution fails, the API is still + * created/updated and this is left empty (create) or unchanged from its + * previous value (update) rather than the request failing — see + * `schemaSource`. + * @example type Query { + * countries: [Country] + * country(code: ID!): Country + * } + * type Country { + * code: String + * name: String + * capital: String + * } + */ + sdl?: string; + /** + * Format: uri + * @description A URL to a raw SDL document to fetch and use as `sdl` when + * `schemaSource` is `url` — the write-side counterpart to how an OpenAPI + * document can be supplied by reference for other artifact kinds (see + * LlmProviderTemplate's `metadata.openapiSpecUrl`). Distinct from + * `upstream.main.url`: this is a plain HTTP(S) GET of a static schema + * file, not a live introspection query against a GraphQL server, and is + * fetched through the same shared SSRF-guarded HTTP client every other + * operator/tenant-supplied fetch in this API uses, under the operator- + * configured policy (default `netguard.PermitPrivateBlockMetadata()`): the + * host is resolved and every candidate IP — including each redirect hop — + * is checked at dial time, refusing link-local/metadata/unspecified/ + * multicast addresses while private and in-cluster addresses (a Kubernetes + * ClusterIP, a service-DNS name, localhost) remain reachable. Never stored + * or echoed back; only the fetched `sdl` text is persisted and returned. + * @example https://raw.githubusercontent.com/example/countries-api/main/schema.graphql + */ + sdlUrl?: string; + /** + * @description How `sdl` was obtained. SDL = supplied directly in the create/update + * request. ENDPOINT = derived by introspecting `upstream.main.url` at + * creation time. Informational only — storage and downstream behavior are + * identical either way. + * @example ENDPOINT + */ + readonly introspectionMode?: components["schemas"]["GraphQLIntrospectionMode"]; + /** + * @description List of policies to be applied on the API. Reused unmodified from + * REST APIs. A `cors` policy applies only to the API's single `POST` + * route — a GraphQL API has no per-operation list to add an + * `OPTIONS` entry to, so a browser preflight request is not routed + * at all and a `cors` policy will not run for it; cross-origin + * browser clients that trigger a preflight are not currently + * supported. + */ + policies?: components["schemas"]["Policy"][]; + /** + * @description List of subscription plan names enabled for this API. + * @example [ + * "Gold", + * "Silver" + * ] + */ + subscriptionPlans?: string[]; + }; + /** GraphQL API detail (without sdl) */ + GraphQLAPIDetail: { + /** + * @description Unique handle/identifier for the API. + * @example countries-graphql-api + */ + id?: string; + /** + * @description Human-readable name for the API + * @example Countries GraphQL API + */ + displayName: string; + /** @example Public GraphQL API for querying country/region reference data */ + description?: string; + /** + * @description Base path for the single GraphQL endpoint. Suggested (not enforced) + * convention: end the path with `/graphql`, matching how most standalone + * GraphQL servers name their single endpoint — this is not validated. + * @example /countries/graphql + */ + context: string; + /** @example v1.0 */ + version: string; + /** @example john.doe */ + readonly createdBy?: string; + /** @example john.doe */ + readonly updatedBy?: string; + /** @example default-project */ + projectId: string; + /** + * Format: date-time + * @example 2026-08-11T10:00:00Z + */ + readonly createdAt?: string; + /** + * Format: date-time + * @example 2026-08-11T10:00:00Z + */ + readonly updatedAt?: string; + /** + * @description True if the artifact originated from a data-plane gateway (origin gateway_api) and is read-only in the control plane. + * @example false + */ + readonly readOnly?: boolean; + /** + * @description Reused unmodified from REST APIs. A GraphQL API has exactly one logical + * endpoint (no per-operation paths), so `upstream.main.url` is the single + * GraphQL endpoint — either the backend to proxy to (SDL-supplied case) or + * the endpoint introspected at creation time (see `introspectionMode` below). + */ + upstream: components["schemas"]["Upstream"]; + /** + * @description Kind of the API based on its communication protocol or architectural style + * @default GraphQLApi + * @example GraphQLApi + */ + kind: string; + /** + * @description How the schema was obtained. SDL = supplied directly in the create/update + * request. ENDPOINT = derived by introspecting `upstream.main.url` at + * creation time. Informational only — storage and downstream behavior are + * identical either way. + * @example ENDPOINT + */ + readonly introspectionMode?: components["schemas"]["GraphQLIntrospectionMode"]; + /** + * @description List of policies to be applied on the API. Reused unmodified from + * REST APIs. A `cors` policy applies only to the API's single `POST` + * route — a GraphQL API has no per-operation list to add an + * `OPTIONS` entry to, so a browser preflight request is not routed + * at all and a `cors` policy will not run for it; cross-origin + * browser clients that trigger a preflight are not currently + * supported. + */ + policies?: components["schemas"]["Policy"][]; + /** + * @description List of subscription plan names enabled for this API. + * @example [ + * "Gold", + * "Silver" + * ] + */ + subscriptionPlans?: string[]; + }; + /** GraphQL API SDL */ + GraphQLAPISDLResponse: { + /** + * @description The GraphQL schema in SDL form, resolved at create/update time (either + * supplied directly or derived via upstream introspection) — see + * `GET /graphql-apis/{graphqlApiId}` for the rest of the API's metadata. + * @example type Query { + * countries: [Country] + * country(code: ID!): Country + * } + * type Country { + * code: String + * name: String + * capital: String + * } + */ + sdl: string; + }; + CreateGraphQLAPIRequest: components["schemas"]["GraphQLAPI"] & Record; + /** GraphQL API object with SDL file upload */ + GraphQLAPIMultipartRequest: { + /** + * @description JSON-encoded request body — CreateGraphQLAPIRequest fields for create, + * GraphQLAPI fields for update, including `schemaSource`. When + * `schemaSource` is `file`, the `sdlFile` part below is required and any + * `sdl`/`sdlUrl` in this metadata is a structural-validation error, not a + * silent override — every schema-source variant is expressed + * consistently through the `schemaSource` field rather than by which + * part happens to be present. + * @example {"displayName":"Countries GraphQL API","context":"/countries","version":"v1.0","projectId":"default-project","schemaSource":"introspection","upstream":{"main":{"url":"https://countries.trevorblades.com/graphql"}}} + */ + metadata: string; + /** + * Format: binary + * @description The GraphQL SDL document as a file upload (e.g. schema.graphql). + * Required when `schemaSource` is `file`; must be omitted otherwise. + */ + sdlFile?: string; + }; + /** GraphQL API list item */ + GraphQLAPIListItem: { + /** @example countries-graphql-api */ + id?: string; + /** @example Countries GraphQL API */ + displayName: string; + description?: string; + /** @example /countries/graphql */ + context: string; + /** @example v1.0 */ + version: string; + /** @example default-project */ + projectId: string; + upstream?: components["schemas"]["Upstream"]; + introspectionMode?: components["schemas"]["GraphQLIntrospectionMode"]; + /** + * @default GraphQLApi + * @example GraphQLApi + */ + kind: string; + /** @example false */ + readOnly?: boolean; + /** @example john.doe */ + readonly createdBy?: string; + /** Format: date-time */ + readonly createdAt?: string; + /** Format: date-time */ + readonly updatedAt?: string; + }; + GraphQLAPIListResponse: { + /** @example 1 */ + count: number; + list: components["schemas"]["GraphQLAPIListItem"][]; + pagination: components["schemas"]["Pagination"]; + }; + /** GraphQL schema validation request */ + ValidateGraphQLSchemaRequest: { + /** + * @description Same semantics as `GraphQLAPI.schemaSource` — declares which of + * `sdl`/`sdlUrl`/the `sdlFile` multipart part/`upstream.main.url` + * supplies the schema to resolve. + * @default introspection + * @example introspection + * @enum {string} + */ + schemaSource: "inline" | "url" | "file" | "introspection"; + /** @description The GraphQL schema in SDL form, when `schemaSource` is `inline` (or the uploaded file's content, when `file`). */ + sdl?: string; + /** + * Format: uri + * @description A URL to fetch the SDL from, when `schemaSource` is `url`. + */ + sdlUrl?: string; + /** + * @description Only relevant when `schemaSource` is `introspection` (explicit or + * inferred) — unlike `GraphQLAPI.upstream`, this is not required, + * since a validation request for `inline`/`url`/`file` has no use + * for it. + */ + upstream?: components["schemas"]["Upstream"]; + }; + /** GraphQL schema validation request with SDL file upload */ + ValidateGraphQLSchemaMultipartRequest: { + /** + * @description JSON-encoded ValidateGraphQLSchemaRequest. + * @example {"schemaSource":"introspection","upstream":{"main":{"url":"https://countries.trevorblades.com/graphql"}}} + */ + metadata: string; + /** + * Format: binary + * @description The GraphQL SDL document as a file upload. Required when + * `schemaSource` is `file`; must be omitted otherwise. + */ + sdlFile?: string; + }; + /** GraphQL schema validation result */ + ValidateGraphQLSchemaResponse: { + /** + * @description Whether the declared schemaSource actually resolved to a usable schema. + * @example true + */ + resolved: boolean; + /** @description The resolved SDL text when `resolved` is `true`; empty otherwise. */ + sdl: string; + /** @description Only set when `resolved` is `true`. */ + introspectionMode?: components["schemas"]["GraphQLIntrospectionMode"]; + /** + * @description A generic explanation, set only when `resolved` is `false`. Never + * the specific parser/fetch/introspection failure reason — reuses + * the same sterile message `GraphQLAPISchemaResolveFailed` uses + * elsewhere (`error-handling.md`). + * @example The provided endpoint could not be used to derive a GraphQL schema, or the supplied SDL could not be parsed. + */ + message?: string; + }; + /** + * @description Time unit for API key expiration duration + * @example days + * @enum {string} + */ + TimeUnit: "seconds" | "minutes" | "hours" | "days" | "weeks" | "months"; + ExpirationDuration: { + /** + * @description Duration value (must be positive) + * @example 30 + */ + duration: number; + unit: components["schemas"]["TimeUnit"]; + }; + CreateAPIKeyRequest: { + /** + * @description Unique identifier for this API key within the API (optional; if omitted, + * generated from displayName) + * @example production-key-01 + */ + id?: string; + /** + * @description Human-readable name for the API key + * @example Production API Key + */ + displayName: string; + /** + * @description Optional. A pre-minted plain text API key to inject (used by external platforms pushing a key to hybrid gateways). Omit it to have the server generate one, the generated value is returned once in the response and is never retrievable afterwards. + * @example sk_example_1234567890abcdef + */ + apiKey?: string; + /** + * @description Optional reference ID for tracing purposes (from external platforms) + * @example ext-ref-12345 + */ + externalRefId?: string | null; + /** + * Format: date-time + * @description Optional expiration time in ISO 8601 format + * @example 2026-12-31T23:59:59Z */ expiresAt?: string | null; /** @description Optional expiration duration */ @@ -3876,7 +4556,11 @@ export interface components { * @example prod-gateway-01 */ gatewayId: string; - /** @description Optional metadata for the deployment. Supported keys include `endpointUrl`, `vhostMain`, and `vhostSandbox`. */ + /** + * @description Optional metadata for the deployment. Supported keys are `endpointUrl`, `vhostMain` and `vhostSandbox` for REST APIs. An LLM provider deployment takes `endpointUrl` too, which replaces the backend it routes to, and `upstreamAuthValue` — the credential that deployment authenticates to the provider's upstream with, so one provider can run on several gateways against different accounts with the same vendor. It must be given as a `{{ secret "handle" }}` reference naming a secret of this organization, never the credential itself. Like the provider's own `auth.value` it is write-only: it is never returned by any read of a deployment, so replacing it means giving a new one rather than editing what came back. Omitting it leaves the provider's own credential in place. + * + * `upstreamAuthHeader` names the header that credential is sent in. It is read only alongside `upstreamAuthValue`, and only where the upstream authenticates with an api-key — basic and bearer send `Authorization` by definition. + */ metadata?: { [key: string]: unknown; }; @@ -3970,15 +4654,14 @@ export interface components { */ baseDeploymentId?: string | null; /** - * @description Build this deployment runs, such as `2026-01-31-2`. Every REST API deployment - * has one: `base: build` runs the build it names, and `base: current` stores what - * it renders as a build and runs that. + * @description Build this deployment runs, such as `2026-01-31-2`. REST API, LLM provider, + * LLM proxy and MCP proxy deployments all have one: `base: build` runs the build + * it names, and `base: current` stores what it renders as a build and runs that. * - * Null for artifact kinds that have no builds — MCP proxy, LLM and event API - * deployments — including one promoted from another deployment, which reuses that - * deployment's rendered artifact. Also null once the build it ran has been pruned. - * Null means only that no build can be named; the deployment keeps its own - * rendered artifact either way. + * Null for artifact kinds that have no builds, and for a deployment promoted from + * another, which reuses that deployment's rendered artifact. Also null once the + * build it ran has been pruned. Null means only that no build can be named; the + * deployment keeps its own rendered artifact either way. * @example 2026-01-31-2 */ buildId?: string | null; @@ -5855,7 +6538,7 @@ export interface components { "application/json": components["schemas"]["Error"]; }; }; - /** @description Conflict. code identifies which: PUBLICATION_STATE_CONFLICT when the action is not valid for the publication's current status (unpublish needs a published or deprecated listing, deprecate a published one), or PUBLICATION_PORTAL_CONFLICT when the API Portal refused the change — another API already holds this handle or display name and version, or the listing still has subscriptions or active API keys and so cannot be removed. A portal conflict does not clear on retry: the operator renames, removes the consumers, or deprecates instead. A state conflict clears once the publication is in a status that allows the action. No local state was changed. */ + /** @description Conflict. code identifies which: PUBLICATION_STATE_CONFLICT when the action is not valid for the publication's current status (unpublish needs a published or deprecated listing, deprecate a published one), PUBLICATION_DRAFT_CHANGED when the draft was saved while a publish of it was in flight (the API Portal may already hold the earlier copy while the local listing is unchanged; review the draft and publish again to bring them in line), or PUBLICATION_PORTAL_CONFLICT when the API Portal refused the change — another API already holds this handle or display name and version, or the listing still has subscriptions or active API keys and so cannot be removed. A portal conflict does not clear on retry: the operator renames, removes the consumers, or deprecates instead. A state conflict clears once the publication is in a status that allows the action. No local state was changed by any of these; only a draft-changed conflict can leave the API Portal ahead of it until the next publish. */ PublicationConflict: { headers: { [name: string]: unknown; @@ -7493,6 +8176,595 @@ export interface operations { 503: components["responses"]["PortalUnavailable"]; }; }; + ListGraphQLAPIs: { + parameters: { + query: { + /** @description **Project ID** consisting of the **handle** (unique slug identifier) of the Project whose resources should be returned. */ + projectId: components["parameters"]["projectId-Q"]; + /** @description Maximum number of items to return per page. */ + limit?: components["parameters"]["limit-Q"]; + /** @description Zero-based index of the first item to return. */ + offset?: components["parameters"]["offset-Q"]; + /** @description Field to sort the collection by. An unrecognized value falls back to the default sort (createdAt). */ + sortBy?: components["parameters"]["sortBy-Q"]; + /** @description Sort direction applied to `sortBy`. */ + sortOrder?: components["parameters"]["sortOrder-Q"]; + /** @description Case-insensitive substring filter matched against the resource display name and id (handle). */ + query?: components["parameters"]["query-Q"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description GraphQL APIs retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GraphQLAPIListResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + CreateGraphQLAPI: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * @description GraphQL API object that needs to be added, as `multipart/form-data` — see + * GraphQLAPIMultipartRequest. This is the only accepted content type, even + * when `schemaSource` is `inline`, `url`, or `introspection` and no file is + * being uploaded, so that every schema-source variant is expressed the + * same way. + */ + requestBody: { + content: { + "multipart/form-data": components["schemas"]["GraphQLAPIMultipartRequest"]; + }; + }; + responses: { + /** @description GraphQL API created successfully */ + 201: { + headers: { + Location: components["headers"]["Location"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GraphQLAPI"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + GetGraphQLAPI: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description GraphQL API retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GraphQLAPIDetail"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + UpdateGraphQLAPI: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + }; + cookie?: never; + }; + /** + * @description As `multipart/form-data` only — see GraphQLAPIMultipartRequest and the + * note on `POST /graphql-apis`. + */ + requestBody: { + content: { + "multipart/form-data": components["schemas"]["GraphQLAPIMultipartRequest"]; + }; + }; + responses: { + /** @description GraphQL API updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GraphQLAPI"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + DeleteGraphQLAPI: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description GraphQL API deleted successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + GetGraphQLAPISDL: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description SDL retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GraphQLAPISDLResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + ValidateGraphQLSchema: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * @description As `multipart/form-data` only, following the same convention as + * `POST /graphql-apis` — see `GraphQLAPIMultipartRequest`. + */ + requestBody: { + content: { + "multipart/form-data": components["schemas"]["ValidateGraphQLSchemaMultipartRequest"]; + }; + }; + responses: { + /** @description Schema resolution attempted — see `resolved` for the outcome. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ValidateGraphQLSchemaResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + GetGraphQLAPIGateways: { + parameters: { + query?: { + /** @description Maximum number of items to return per page. */ + limit?: components["parameters"]["limit-Q"]; + /** @description Zero-based index of the first item to return. */ + offset?: components["parameters"]["offset-Q"]; + }; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of gateways associated with the API, including deployment details */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RESTAPIGatewayListResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + AddGatewaysToGraphQLAPI: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + }; + cookie?: never; + }; + /** @description List of gateways to associate with the API */ + requestBody?: { + content: { + "application/json": components["schemas"]["AddGatewayToRESTAPIRequest"][]; + }; + }; + responses: { + /** @description List of all gateways associated with the API, including deployment details */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RESTAPIGatewayListResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + CreateGraphQLAPIKey: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + }; + cookie?: never; + }; + /** @description API key creation request */ + requestBody: { + content: { + "application/json": components["schemas"]["CreateAPIKeyRequest"]; + }; + }; + responses: { + /** @description API key created successfully */ + 201: { + headers: { + Location: components["headers"]["Location"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateAPIKeyResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + 503: components["responses"]["GatewayConnectionUnavailable"]; + }; + }; + UpdateGraphQLAPIKey: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + /** + * @description The unique name/identifier of the API key + * @example my-api-key + */ + apiKeyId: string; + }; + cookie?: never; + }; + /** @description API key update request */ + requestBody: { + content: { + "application/json": components["schemas"]["UpdateAPIKeyRequest"]; + }; + }; + responses: { + /** @description API key updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UpdateAPIKeyResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + 503: components["responses"]["GatewayConnectionUnavailable"]; + }; + }; + RevokeGraphQLAPIKey: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + /** + * @description The unique name/identifier of the API key to revoke + * @example my-api-key + */ + apiKeyId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description API key revoked successfully (no content) */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + 503: components["responses"]["GatewayConnectionUnavailable"]; + }; + }; + GetGraphQLAPIDeployments: { + parameters: { + query?: { + /** @description **Gateway ID** consisting of the **handle** (unique slug identifier) of the Gateway to filter status by. */ + gatewayId?: components["parameters"]["gatewayId-Q"]; + /** @description Filter deployments by status (DEPLOYED, UNDEPLOYED, DEPLOYING, UNDEPLOYING, FAILED, or ARCHIVED) */ + status?: components["parameters"]["deploymentStatus-Q"]; + /** @description Maximum number of items to return per page. */ + limit?: components["parameters"]["limit-Q"]; + /** @description Zero-based index of the first item to return. */ + offset?: components["parameters"]["offset-Q"]; + }; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deployments retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeploymentListResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + DeployGraphQLAPI: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + }; + cookie?: never; + }; + /** @description Deployment request with gateway ID, base reference, and metadata */ + requestBody: { + content: { + "application/json": components["schemas"]["DeployRequest"]; + }; + }; + responses: { + /** @description GraphQL API deployed successfully */ + 201: { + headers: { + Location: components["headers"]["Location"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeploymentResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + GetGraphQLAPIDeployment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + /** @description The UUID of the deployment */ + deploymentId: components["parameters"]["deploymentId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deployment metadata retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeploymentResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + DeleteGraphQLAPIDeployment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + /** @description The UUID of the deployment */ + deploymentId: components["parameters"]["deploymentId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deployment deleted successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["DeploymentActiveConflict"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + UndeployGraphQLAPIDeployment: { + parameters: { + query: { + /** @description Handle (URL-friendly slug) of the gateway (validated against deployment's bound gateway) */ + gatewayId: string; + }; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + /** @description UUID of the deployment to undeploy */ + deploymentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Undeploy initiated successfully. Returns the deployment with initial status UNDEPLOYING. Poll status for final result. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeploymentResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + RestoreGraphQLAPIDeployment: { + parameters: { + query: { + /** @description Handle (URL-friendly slug) of the gateway (validated against deployment's bound gateway) */ + gatewayId: string; + }; + header?: never; + path: { + /** @description **GraphQL API ID** consisting of the **handle** (unique identifier) of the API. */ + graphqlApiId: string; + /** @description UUID of the deployment to restore (must be ARCHIVED or UNDEPLOYED) */ + deploymentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Restore initiated successfully. Returns the deployment with initial status DEPLOYING. Poll status for final result. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeploymentResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 500: components["responses"]["InternalServerError"]; + }; + }; listLLMProviderTemplates: { parameters: { query?: { @@ -10719,7 +11991,7 @@ export interface operations { * If omitted, all types are returned. * @example LlmProxy,LlmProvider */ - type?: ("RestApi" | "LlmProvider" | "LlmProxy")[]; + type?: ("RestApi" | "LlmProvider" | "LlmProxy" | "GraphQLApi")[]; /** @description Maximum number of items to return per page. */ limit?: components["parameters"]["limit-Q"]; /** @description Zero-based index of the first item to return. */ From e8401d6b0e365ac1b91a179ce9d243f2e7ede66d Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Thu, 17 Sep 2026 12:21:54 +0530 Subject: [PATCH 34/38] Fix GraphQL API key create for the current CreateAPIKey signature --- platform-api/internal/handler/graphql_apikey.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform-api/internal/handler/graphql_apikey.go b/platform-api/internal/handler/graphql_apikey.go index 4ae5960736..c2715deba6 100644 --- a/platform-api/internal/handler/graphql_apikey.go +++ b/platform-api/internal/handler/graphql_apikey.go @@ -131,7 +131,7 @@ func (h *GraphQLAPIKeyHandler) CreateAPIKey(w http.ResponseWriter, r *http.Reque } h.slogger.Info("Successfully created GraphQL API key", "userId", userId, "apiHandle", apiHandle, "orgId", orgId, "keyName", keyName) - setLocation(w, "graphql-apis", apiHandle, "api-keys", keyName) + setLocation(w, "graphql-apis", apiHandle, "api-keys", name) httputil.WriteJSON(w, http.StatusCreated, resp) return nil } From a662a23019d12d2e928409251a32cc7410b84d66 Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Thu, 17 Sep 2026 14:41:49 +0530 Subject: [PATCH 35/38] Fix GraphQL deploy base contract, schema-preserve, and zip-bomb gap from review --- .../gateway-controller/pkg/utils/api_utils.go | 29 ++++++- gateway/it/features/graphql-api-keys.feature | 2 +- platform-api/internal/server/server.go | 3 + .../internal/service/artifact_definition.go | 39 +++++++++ .../service/artifact_import_graphql.go | 37 ++++---- .../internal/service/deployment_test.go | 5 ++ .../internal/service/graphql_api_test.go | 4 + .../internal/service/graphql_deployment.go | 87 ++++++++++--------- .../service/graphql_deployment_test.go | 20 +++++ 9 files changed, 168 insertions(+), 58 deletions(-) diff --git a/gateway/gateway-controller/pkg/utils/api_utils.go b/gateway/gateway-controller/pkg/utils/api_utils.go index 4ef77c83b2..9a98b03d2e 100644 --- a/gateway/gateway-controller/pkg/utils/api_utils.go +++ b/gateway/gateway-controller/pkg/utils/api_utils.go @@ -468,6 +468,17 @@ func (s *APIUtilsService) FetchSubscriptionPlans() ([]models.SubscriptionPlan, e return plans, nil } +// maxZipEntries caps how many entries an inbound API definition archive may +// contain, before any entry is opened — a bound independent of the +// decompressed-size guard below, since a zip bomb can also be built from many +// tiny entries rather than one large one. +const maxZipEntries = 1000 + +// maxZipDecompressionRatio bounds how much larger a single entry's +// decompressed content may be than its compressed size, so a small malicious +// archive can't expand into an unbounded read (file-access.md directive 7). +const maxZipDecompressionRatio = 100 + // ExtractYAMLFromZip extracts the API definition YAML from the zip file func (s *APIUtilsService) ExtractYAMLFromZip(zipData []byte) ([]byte, error) { // Create a reader from the zip data @@ -475,6 +486,9 @@ func (s *APIUtilsService) ExtractYAMLFromZip(zipData []byte) ([]byte, error) { if err != nil { return nil, fmt.Errorf("failed to create zip reader: %w", err) } + if len(zipReader.File) > maxZipEntries { + return nil, fmt.Errorf("archive contains too many entries") + } // Look for YAML files in the zip for _, file := range zipReader.File { @@ -491,11 +505,22 @@ func (s *APIUtilsService) ExtractYAMLFromZip(zipData []byte) ([]byte, error) { } defer rc.Close() - // Read the content - yamlData, err := io.ReadAll(rc) + // Bound the decompressed read by both the shared response-size ceiling + // and a ratio guard on this entry's own compressed size, so neither a + // single huge entry nor a small, highly-compressed one can force an + // unbounded read into memory. + maxDecompressed := s.config.MaxResponseBytes + if ratioCap := int64(file.CompressedSize64) * maxZipDecompressionRatio; ratioCap > 0 && ratioCap < maxDecompressed { + maxDecompressed = ratioCap + } + + yamlData, err := io.ReadAll(io.LimitReader(rc, maxDecompressed+1)) if err != nil { return nil, fmt.Errorf("failed to read file %s: %w", file.Name, err) } + if int64(len(yamlData)) > maxDecompressed { + return nil, fmt.Errorf("archive entry exceeds the maximum allowed size") + } return yamlData, nil } diff --git a/gateway/it/features/graphql-api-keys.feature b/gateway/it/features/graphql-api-keys.feature index 51c55804f6..d5dacc5cf1 100644 --- a/gateway/it/features/graphql-api-keys.feature +++ b/gateway/it/features/graphql-api-keys.feature @@ -224,7 +224,7 @@ Feature: GraphQL API Key Management Operations 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" + 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 diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index b9e34c1f05..2aac1369cb 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -269,6 +269,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, service.NewMCPProxyDefinition(mcpProxyRepo, &utils.MCPUtils{}), service.NewLLMProxyDefinition(llmProxyRepo), service.NewLLMProviderDefinition(llmProviderRepo, llmTemplateRepo), + service.NewGraphQLAPIDefinition(graphqlAPIRepo), ) deploymentService := service.NewDeploymentService(apiRepo, artifactRepo, deploymentRepo, gatewayRepo, orgRepo, apiKeyRepo, gatewayEventsService, auditRepo, apiUtil, artifactDefinitions, cfg, slogger) llmTemplateService := service.NewLLMProviderTemplateService(llmTemplateRepo, auditRepo, identityService) @@ -327,7 +328,9 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, gatewayRepo, orgRepo, apiKeyRepo, + artifactRepo, gatewayEventsService, + artifactDefinitions, cfg, slogger, ) diff --git a/platform-api/internal/service/artifact_definition.go b/platform-api/internal/service/artifact_definition.go index 44a2834734..845631dfb5 100644 --- a/platform-api/internal/service/artifact_definition.go +++ b/platform-api/internal/service/artifact_definition.go @@ -284,3 +284,42 @@ func (d *mcpProxyDefinition) Decode(content []byte) (any, error) { } return definition, nil } + +// graphqlAPIDefinition renders GraphQL APIs. +type graphqlAPIDefinition struct { + graphqlRepo repository.GraphQLAPIRepository +} + +// NewGraphQLAPIDefinition returns the ArtifactDefinition for GraphQL APIs. +func NewGraphQLAPIDefinition(graphqlRepo repository.GraphQLAPIRepository) ArtifactDefinition { + return &graphqlAPIDefinition{graphqlRepo: graphqlRepo} +} + +func (d *graphqlAPIDefinition) Kind() string { return constants.GraphQLApi } + +func (d *graphqlAPIDefinition) Current(artifact *model.Artifact) (*ArtifactSnapshot, error) { + apiModel, err := d.graphqlRepo.GetByUUID(artifact.UUID, artifact.OrganizationUUID) + if err != nil { + return nil, err + } + if apiModel == nil { + return nil, apperror.GraphQLAPINotFound.New() + } + definition, err := generateGraphQLAPIDeploymentYAML(apiModel) + if err != nil { + return nil, fmt.Errorf("failed to generate GraphQL API deployment YAML: %w", err) + } + return &ArtifactSnapshot{ + Definition: &definition, + DataVersion: apiModel.DataVersion, + Origin: apiModel.Origin, + }, nil +} + +func (d *graphqlAPIDefinition) Decode(content []byte) (any, error) { + definition := &dto.GraphQLAPIDeploymentYAML{} + if err := yaml.Unmarshal(content, definition); err != nil { + return nil, fmt.Errorf("failed to parse stored GraphQL API deployment YAML: %w", err) + } + return definition, nil +} diff --git a/platform-api/internal/service/artifact_import_graphql.go b/platform-api/internal/service/artifact_import_graphql.go index 0789991e6a..8eb4bf8008 100644 --- a/platform-api/internal/service/artifact_import_graphql.go +++ b/platform-api/internal/service/artifact_import_graphql.go @@ -54,7 +54,7 @@ func (i *graphqlAPIImporter) Import(ctx *ImportContext) (*ImportResult, error) { } if ctx.Existing == nil { - i.resolveSchema(&cfg) + cfg.SDL, cfg.IntrospectionMode, _ = i.resolveSchema(cfg.Upstream.Main) projectID := ctx.ProjectID graphqlAPI := &model.GraphQLAPI{ ID: ctx.ID, @@ -90,8 +90,16 @@ func (i *graphqlAPIImporter) Import(ctx *ImportContext) (*ImportResult, error) { existing.Version = version existing.ProjectID = ctx.ProjectID // Refresh the schema from the (possibly new) upstream alongside the rest of - // the configuration, the same as at create time. - i.resolveSchema(&cfg) + // the configuration, the same as at create time — but unlike create, a + // failed resolution here keeps the previously-stored schema instead of + // blanking it out (mirrors GraphQLAPIService.Update's same posture): a + // transient upstream issue during a metadata-only re-import must not + // destroy a schema that was working before this push. + if sdl, mode, ok := i.resolveSchema(cfg.Upstream.Main); ok { + cfg.SDL, cfg.IntrospectionMode = sdl, mode + } else { + cfg.SDL, cfg.IntrospectionMode = existing.Configuration.SDL, existing.Configuration.IntrospectionMode + } existing.Configuration = cfg case utils.WriteGatewaySpecificOnly: // CP-owned: only the upstream is gateway-specific data; SDL/name/etc. are not @@ -104,21 +112,20 @@ func (i *graphqlAPIImporter) Import(ctx *ImportContext) (*ImportResult, error) { return &ImportResult{ID: ctx.ID, DeployedVersion: version, Deployable: true}, nil } -// resolveSchema derives cfg.SDL/IntrospectionMode via the same introspection path -// CP-native create uses (fetchAndConvertGraphQLSchema, graphql_introspection.go), +// resolveSchema derives SDL/introspectionMode via the same introspection path +// CP-native create/update uses (fetchAndConvertGraphQLSchema, graphql_introspection.go), // since the gateway-pushed spec never carries the schema. Best-effort, mirroring // mcpProxyImporter.fetchCapabilities: an unreachable or misbehaving upstream must -// not fail the whole import, so a failure just leaves SDL empty rather than -// surfacing the specific reason (matches GraphQLAPIService.resolveSchema's own -// sterile-failure posture). -func (i *graphqlAPIImporter) resolveSchema(cfg *model.GraphQLAPIConfig) { - if cfg.Upstream.Main == nil || cfg.Upstream.Main.URL == "" { - return +// not fail the whole import — ok reports whether resolution actually succeeded, +// so a caller updating an existing artifact can keep its previously-stored +// schema instead of blanking it out, the same way GraphQLAPIService.Update does. +func (i *graphqlAPIImporter) resolveSchema(upstreamMain *model.UpstreamEndpoint) (sdl, introspectionMode string, ok bool) { + if upstreamMain == nil || upstreamMain.URL == "" { + return "", "", false } - derived, err := fetchAndConvertGraphQLSchema(cfg.Upstream.Main.URL) + derived, err := fetchAndConvertGraphQLSchema(upstreamMain.URL) if err != nil { - return + return "", "", false } - cfg.SDL = derived - cfg.IntrospectionMode = "ENDPOINT" + return derived, "ENDPOINT", true } diff --git a/platform-api/internal/service/deployment_test.go b/platform-api/internal/service/deployment_test.go index c06b202a29..eed1542ca1 100644 --- a/platform-api/internal/service/deployment_test.go +++ b/platform-api/internal/service/deployment_test.go @@ -344,6 +344,11 @@ func (m *mockDeploymentRepo) CreateFromBuildWithLimitEnforcement(deployment *mod return m.CreateWithLimitEnforcement(deployment, hardLimit) } +func (m *mockDeploymentRepo) CreateWithBuild(deployment *model.Deployment, _ *model.Build, + _, hardLimit int) error { + return m.CreateWithLimitEnforcement(deployment, hardLimit) +} + func (m *mockDeploymentRepo) CreateWithLimitEnforcement(deployment *model.Deployment, hardLimit int) error { m.createdDeployment = deployment return m.createWithLimitError diff --git a/platform-api/internal/service/graphql_api_test.go b/platform-api/internal/service/graphql_api_test.go index 047424127a..cba946630f 100644 --- a/platform-api/internal/service/graphql_api_test.go +++ b/platform-api/internal/service/graphql_api_test.go @@ -47,6 +47,7 @@ type mockGraphQLAPIRepo struct { createErr error getByHandleFunc func(handle, orgUUID string) (*model.GraphQLAPI, error) + getByUUIDFunc func(uuid, orgUUID string) (*model.GraphQLAPI, error) updated *model.GraphQLAPI updateErr error @@ -94,6 +95,9 @@ func (m *mockGraphQLAPIRepo) GetByHandle(handle, orgUUID string) (*model.GraphQL } func (m *mockGraphQLAPIRepo) GetByUUID(uuid, orgUUID string) (*model.GraphQLAPI, error) { + if m.getByUUIDFunc != nil { + return m.getByUUIDFunc(uuid, orgUUID) + } return nil, nil } diff --git a/platform-api/internal/service/graphql_deployment.go b/platform-api/internal/service/graphql_deployment.go index 075f52829d..425f3e026c 100644 --- a/platform-api/internal/service/graphql_deployment.go +++ b/platform-api/internal/service/graphql_deployment.go @@ -57,6 +57,7 @@ type GraphQLAPIDeploymentService struct { orgRepo repository.OrganizationRepository apiKeyRepo repository.APIKeyRepository gatewayEventsService *GatewayEventsService + builds *BuildService cfg *config.Server slogger *slog.Logger } @@ -68,7 +69,9 @@ func NewGraphQLAPIDeploymentService( gatewayRepo repository.GatewayRepository, orgRepo repository.OrganizationRepository, apiKeyRepo repository.APIKeyRepository, + artifactRepo repository.ArtifactRepository, gatewayEventsService *GatewayEventsService, + definitions ArtifactDefinitions, cfg *config.Server, slogger *slog.Logger, ) *GraphQLAPIDeploymentService { @@ -79,6 +82,7 @@ func NewGraphQLAPIDeploymentService( orgRepo: orgRepo, apiKeyRepo: apiKeyRepo, gatewayEventsService: gatewayEventsService, + builds: NewBuildService(artifactRepo, deploymentRepo, definitions, cfg, slogger), cfg: cfg, slogger: slogger, } @@ -158,8 +162,9 @@ func (s *GraphQLAPIDeploymentService) DeployGraphQLAPI(apiID string, req *api.De 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).") + base, requestedBuild, err := ValidateDeployBase(req.Base, req.BuildId, apperror.GraphQLAPIDeploymentValidationFailed) + if err != nil { + return nil, err } gatewayHandle := strings.TrimSpace(req.GatewayId) if gatewayHandle == "" { @@ -210,34 +215,25 @@ func (s *GraphQLAPIDeploymentService) DeployGraphQLAPI(apiID string, req *api.De 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 + // What this deploy ships: a build prepared earlier, or a snapshot of the + // API as it stands now. A snapshot comes back unstored so it commits with + // the deployment below. + source, err := s.builds.SourceForDeploy(apiModel.ID, orgUUID, constants.GraphQLApi, createdBy, base, requestedBuild) + if err != nil { + return nil, err + } + apiDeployment, ok := source.Definition.(*dto.GraphQLAPIDeploymentYAML) + if !ok { + return nil, fmt.Errorf("artifact %s did not render as a GraphQL API definition", apiModel.ID) + } + sourceDataVersion := gatewaytranslator.PlatformDataVersion(source.DataVersion) + targetDataVersion := gatewaytranslator.GatewayDataVersionForGateway(gateway.Version) + if err := gatewaytranslator.Translate(constants.GraphQLApi, sourceDataVersion, targetDataVersion, apiDeployment); err != nil { + return nil, fmt.Errorf("failed to transform GraphQL API deployment for gateway %s: %w", gateway.Version, err) + } + contentBytes, err := yaml.Marshal(apiDeployment) + if err != nil { + return nil, fmt.Errorf("failed to marshal GraphQL API deployment YAML: %w", err) } deploymentID, err := utils.GenerateUUID() @@ -247,22 +243,33 @@ func (s *GraphQLAPIDeploymentService) DeployGraphQLAPI(apiID string, req *api.De 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, + DeploymentID: deploymentID, + Name: req.Name, + ArtifactID: apiModel.ID, + OrganizationID: orgUUID, + GatewayID: gatewayID, + BuildUUID: source.BuildUUID, + BuildID: source.BuildID, + Content: contentBytes, + Metadata: metadata, + Status: &deployed, } if s.cfg.Deployments.MaxPerAPIGateway < 1 { return nil, fmt.Errorf("MaxPerAPIGateway limit config must be at least 1, got %d", s.cfg.Deployments.MaxPerAPIGateway) } hardLimit := s.cfg.Deployments.MaxPerAPIGateway + constants.DeploymentLimitBuffer - if err := s.deploymentRepo.CreateWithLimitEnforcement(deployment, hardLimit); err != nil { + // A build rendered for this deploy is stored with the deployment, in one + // transaction, so a recorded deployment always has the build it runs. + if source.NewBuild != nil { + err = s.deploymentRepo.CreateWithBuild(deployment, source.NewBuild, s.cfg.Deployments.MaxBuildsPerAPI, hardLimit) + } else { + err = s.deploymentRepo.CreateWithLimitEnforcement(deployment, hardLimit) + } + if err != nil { + if limitErr := s.builds.LimitError(err); limitErr != err { + return nil, limitErr + } return nil, fmt.Errorf("failed to create deployment: %w", err) } diff --git a/platform-api/internal/service/graphql_deployment_test.go b/platform-api/internal/service/graphql_deployment_test.go index 43aa8f6e10..3d1c80050e 100644 --- a/platform-api/internal/service/graphql_deployment_test.go +++ b/platform-api/internal/service/graphql_deployment_test.go @@ -27,8 +27,23 @@ import ( "github.com/wso2/api-platform/platform-api/internal/apperror" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" ) +// mockGraphQLArtifactRepo resolves any UUID/org pair to a GraphQLApi-kind +// artifact row — the only lookup BuildService.resolve does before handing off +// to graphqlAPIDefinition.Current, which performs the real, kind-specific +// lookup via the GraphQL repo itself. Every other method panics if invoked, +// via the embedded (nil) interface — mirrors gqlKeyArtifactRepo's approach in +// graphql_apikey_test.go. +type mockGraphQLArtifactRepo struct { + repository.ArtifactRepository +} + +func (m *mockGraphQLArtifactRepo) GetByUUID(uuid, orgUUID string) (*model.Artifact, error) { + return &model.Artifact{UUID: uuid, OrganizationUUID: orgUUID, Type: constants.GraphQLApi}, nil +} + // newGraphQLDeploymentTestService wires a GraphQLAPIDeploymentService for // tests, reusing the shared mockDeploymentRepo (deployment_test.go) and // mockGatewayRepository (gateway_properties_test.go) test doubles. @@ -42,7 +57,9 @@ func newGraphQLDeploymentTestService(repo *mockGraphQLAPIRepo, deploymentRepo *m gatewayRepo, &mockOrganizationRepo{}, nil, + &mockGraphQLArtifactRepo{}, nil, + NewArtifactDefinitions(NewGraphQLAPIDefinition(repo)), &config.Server{Deployments: config.Deployments{MaxPerAPIGateway: 20}}, newTestLogger(), ) @@ -73,6 +90,7 @@ func TestGraphQLDeployAPI_Current_Success(t *testing.T) { } repo := &mockGraphQLAPIRepo{ getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, + getByUUIDFunc: func(uuid, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, } gateway := graphQLDeploymentTestGateway() gatewayRepo := &mockGatewayRepository{getByNameResult: gateway, getByUUIDResult: gateway} @@ -127,6 +145,7 @@ func TestGraphQLDeployAPI_LegacyGateway_DownConvertsApiVersion(t *testing.T) { } repo := &mockGraphQLAPIRepo{ getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, + getByUUIDFunc: func(uuid, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, } // Below gatewaytranslator.MinGatewayV1Version ("1.2.0") — must down-convert. legacyGateway := &model.Gateway{ID: "gw-uuid-1", OrganizationID: "org-1", Handle: "prod-gateway", Name: "Prod Gateway", Version: "1.1.0"} @@ -299,6 +318,7 @@ func TestGenerateGraphQLAPIDeploymentYAML_CarriesSandboxUpstream(t *testing.T) { } repo := &mockGraphQLAPIRepo{ getByHandleFunc: func(handle, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, + getByUUIDFunc: func(uuid, orgUUID string) (*model.GraphQLAPI, error) { return stored, nil }, } gateway := graphQLDeploymentTestGateway() gatewayRepo := &mockGatewayRepository{getByNameResult: gateway, getByUUIDResult: gateway} From 006f7a0773a7749de1f2d7b27380a44ea4ab182d Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Thu, 17 Sep 2026 15:39:09 +0530 Subject: [PATCH 36/38] Add missing ap:graphql_api:* scopes to AI Workspace BFF default OIDC scope set --- portals/ai-workspace/bff/internal/config/config.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/portals/ai-workspace/bff/internal/config/config.go b/portals/ai-workspace/bff/internal/config/config.go index f6b306c54c..2b9e748a4f 100644 --- a/portals/ai-workspace/bff/internal/config/config.go +++ b/portals/ai-workspace/bff/internal/config/config.go @@ -289,6 +289,10 @@ const defaultOIDCScopes = "openid profile email offline_access" + " ap:llm_proxy:deployment:read ap:llm_proxy:deployment:create ap:llm_proxy:deployment:delete ap:llm_proxy:deployment:manage ap:llm_proxy:deployment:undeploy ap:llm_proxy:deployment:restore" + " ap:mcp_proxy:read ap:mcp_proxy:create ap:mcp_proxy:update ap:mcp_proxy:delete ap:mcp_proxy:manage" + " ap:mcp_proxy:deployment:read ap:mcp_proxy:deployment:create ap:mcp_proxy:deployment:delete ap:mcp_proxy:deployment:manage ap:mcp_proxy:deployment:undeploy ap:mcp_proxy:deployment:restore" + + " ap:graphql_api:read ap:graphql_api:create ap:graphql_api:update ap:graphql_api:delete ap:graphql_api:manage" + + " ap:graphql_api:gateway:read ap:graphql_api:gateway:create ap:graphql_api:gateway:manage" + + " ap:graphql_api:api_key:create ap:graphql_api:api_key:update ap:graphql_api:api_key:delete ap:graphql_api:api_key:manage" + + " ap:graphql_api:deployment:read ap:graphql_api:deployment:create ap:graphql_api:deployment:delete ap:graphql_api:deployment:manage ap:graphql_api:deployment:undeploy ap:graphql_api:deployment:restore" + " ap:api_portal:read ap:api_portal:create ap:api_portal:update ap:api_portal:delete ap:api_portal:manage" + " ap:api_portal:draft:read ap:api_portal:draft:update ap:api_portal:draft:manage" + " ap:api_portal:publication:read" + From 8480ad8ee6ac9604cc48ddac6f2acad600a0f4aa Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Thu, 24 Sep 2026 17:03:40 +0530 Subject: [PATCH 37/38] test: port GraphQL API integration tests to the v2 test framework Migrates gateway/it's GraphQL CRUD, routing, policy, and API-key Godog scenarios into tests/framework, following the same conventions REST/MCP already use there (unique naming, canonical resource template, polling instead of fixed sleeps, DB-dialect matrix). The jwt-auth scenario moves into the mcp-policies block, whose overlay registers the "mock-jwks" issuer that gateway-core lacks. Removes the now-superseded legacy files. Co-Authored-By: Claude Sonnet 5 --- gateway/it/features/graphql-api-keys.feature | 388 -------- gateway/it/features/graphql_deploy.feature | 862 ------------------ gateway/it/steps_graphql.go | 89 -- gateway/it/suite_test.go | 3 - tests/framework/core/cleanup/cleanup.go | 1 + .../it/features/graphql_api_keys.feature | 299 ++++++ .../suites/it/features/graphql_deploy.feature | 649 +++++++++++++ .../it/features/graphql_policies.feature | 63 ++ tests/framework/suites/it/it-suite.yaml | 12 + .../it/resources/templates/graphql-api.yaml | 5 + .../it/steps/platformgateway/gateway.go | 8 +- .../platformgateway/resource_template.go | 4 +- 12 files changed, 1037 insertions(+), 1346 deletions(-) delete mode 100644 gateway/it/features/graphql-api-keys.feature delete mode 100644 gateway/it/features/graphql_deploy.feature delete mode 100644 gateway/it/steps_graphql.go create mode 100644 tests/framework/suites/it/features/graphql_api_keys.feature create mode 100644 tests/framework/suites/it/features/graphql_deploy.feature create mode 100644 tests/framework/suites/it/features/graphql_policies.feature create mode 100644 tests/framework/suites/it/resources/templates/graphql-api.yaml diff --git a/gateway/it/features/graphql-api-keys.feature b/gateway/it/features/graphql-api-keys.feature deleted file mode 100644 index d5dacc5cf1..0000000000 --- a/gateway/it/features/graphql-api-keys.feature +++ /dev/null @@ -1,388 +0,0 @@ -# -------------------------------------------------------------------- -# 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. 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 deleted file mode 100644 index 26bf88fe72..0000000000 --- a/gateway/it/features/graphql_deploy.feature +++ /dev/null @@ -1,862 +0,0 @@ -# -------------------------------------------------------------------- -# 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: a mutation is - # just another POST body sent to the same single route a query uses, - # since GraphQL always resolves to exactly one route, never a - # per-operation list like REST's. This 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 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. - 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 deleted file mode 100644 index 2622bf1097..0000000000 --- a/gateway/it/steps_graphql.go +++ /dev/null @@ -1,89 +0,0 @@ -/* - * 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: a GraphQL API always resolves to exactly one POST -// route, unlike REST's operations[] list. -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 8fbed1f9aa..c33acbe516 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -135,8 +135,6 @@ 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", @@ -362,7 +360,6 @@ func InitializeScenario(ctx *godog.ScenarioContext) { RegisterMCPSteps(ctx, testState, httpSteps, jwtSteps) RegisterA2ASteps(ctx, testState, httpSteps) RegisterA2AClientSteps(ctx, testState, httpSteps) - RegisterGraphQLSteps(ctx, testState, httpSteps, jwtSteps) RegisterLLMSteps(ctx, testState, httpSteps) RegisterJWTSteps(ctx, testState, httpSteps, jwtSteps) RegisterPolicyEngineSteps(ctx, testState, httpSteps) diff --git a/tests/framework/core/cleanup/cleanup.go b/tests/framework/core/cleanup/cleanup.go index 8055bbca30..66da830133 100644 --- a/tests/framework/core/cleanup/cleanup.go +++ b/tests/framework/core/cleanup/cleanup.go @@ -60,6 +60,7 @@ var ( // gateway-controller's own Mcp resource (the "/mcp-proxies" collection), distinct from // KindMCPServer below (platform-api's separate MCP server registration). KindMCPProxy = Kind{Name: "mcp-proxy", Order: 52} + KindGraphQLAPI = Kind{Name: "graphql-api", Order: 53} KindMCPServer = Kind{Name: "mcp-server", Order: 55} KindPolicy = Kind{Name: "policy", Order: 60} KindSharedScope = Kind{Name: "shared-scope", Order: 70} diff --git a/tests/framework/suites/it/features/graphql_api_keys.feature b/tests/framework/suites/it/features/graphql_api_keys.feature new file mode 100644 index 0000000000..a4355a8c3a --- /dev/null +++ b/tests/framework/suites/it/features/graphql_api_keys.feature @@ -0,0 +1,299 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# -------------------------------------------------------------------- + +# Mirrors api_keys.feature (RestApi) scenario-for-scenario against the +# /graphql-apis/{id}/api-keys endpoints. The API key CRUD logic itself is +# shared, kind-agnostic service code (utils.APIKeyService), so this exists +# primarily to guard the gateway-controller wiring specific to the GraphQL +# path: the OpenAPI spec paths, the ServerInterface methods, and the +# relativeRoles auth-route map entries in cmd/controller/main.go. +@graphql-api-keys +Feature: GraphQL API key management + As an API administrator + I want to manage API keys for GraphQL APIs + So that I can control access through API key authentication + + Background: + Given the gateway services are running + And I authenticate using basic auth as "admin" + + Scenario: Complete API key lifecycle - generate, list, regenerate, and revoke + Given I generate a unique value from "test-key" and store it as "apiKeyName" + Given I generate a unique value from "graphql-apikey-lifecycle-api" and store it as "graphqlApiKeyName1_1" + Given I generate a unique API context from "/graphql-apikey-lifecycle" and store it as "graphqlApiKeyContext1_1" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlApiKeyName1_1} | + | spec.displayName | GraphQL-APIKey-Lifecycle-API | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlApiKeyContext1_1} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName1_1}/api-keys" with body: + """ + { + "name": "${CTX:apiKeyName}" + } + """ + Then the response status should be 201 + And the response should be valid JSON + And the JSON response field "status" should be "success" + And the JSON response should have field "apiKey" + And the JSON response should have field "apiKey.name" + And the JSON response should have field "apiKey.apiKey" + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName1_1}/api-keys" + Then the response status should be 200 + And the response should be valid JSON + And the JSON response field "status" should be "success" + And the response body should contain "${CTX:apiKeyName}" + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName1_1}/api-keys/${CTX:apiKeyName}/regenerate" with body: + """ + {} + """ + Then the response status should be 200 + And the response should be valid JSON + And the JSON response field "status" should be "success" + And the JSON response should have field "apiKey.apiKey" + When I send a "DELETE" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName1_1}/api-keys/${CTX:apiKeyName}" + Then the response status should be 200 + And the response should be valid JSON + And the JSON response field "status" should be "success" + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName1_1}/api-keys" + Then the response status should be 200 + And the response should be valid JSON + And the response body should not contain "${CTX:apiKeyName}" + When I delete the GraphQL API "${CTX:graphqlApiKeyName1_1}" + Then the response should be successful + + Scenario: Generate multiple API keys for same GraphQL API + Given I generate a unique value from "key-one" and store it as "firstKeyName" + And I generate a unique value from "key-two" and store it as "secondKeyName" + Given I generate a unique value from "graphql-multi-key-api" and store it as "graphqlApiKeyName2_1" + Given I generate a unique API context from "/graphql-multi-key" and store it as "graphqlApiKeyContext2_1" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlApiKeyName2_1} | + | spec.displayName | GraphQL-Multi-Key-API | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlApiKeyContext2_1} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName2_1}/api-keys" with body: + """ + { + "name": "${CTX:firstKeyName}" + } + """ + Then the response status should be 201 + And the response should be valid JSON + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName2_1}/api-keys" with body: + """ + { + "name": "${CTX:secondKeyName}" + } + """ + Then the response status should be 201 + And the response should be valid JSON + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName2_1}/api-keys" + Then the response status should be 200 + And the response should be valid JSON + And the response body should contain "${CTX:firstKeyName}" + And the response body should contain "${CTX:secondKeyName}" + When I delete the GraphQL API "${CTX:graphqlApiKeyName2_1}" + Then the response should be successful + + Scenario: List API keys for GraphQL API with no keys returns empty list + Given I generate a unique value from "graphql-no-keys-api" and store it as "graphqlApiKeyName3_1" + Given I generate a unique API context from "/graphql-no-keys" and store it as "graphqlApiKeyContext3_1" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlApiKeyName3_1} | + | spec.displayName | GraphQL-No-Keys-API | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlApiKeyContext3_1} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName3_1}/api-keys" + Then the response status should be 200 + And the response should be valid JSON + And the JSON response field "status" should be "success" + When I delete the GraphQL API "${CTX:graphqlApiKeyName3_1}" + Then the response should be successful + + Scenario: Generate API key for non-existent GraphQL API returns 404 + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/non-existent-api-id/api-keys" with body: + """ + { + "name": "test-key" + } + """ + Then the response status should be 404 + And the response should be valid JSON + And the JSON response field "status" should be "error" + + Scenario: Generate API key without name auto-generates name + Given I generate a unique value from "graphql-key-validation-api" and store it as "graphqlApiKeyName5_1" + Given I generate a unique API context from "/graphql-key-validation" and store it as "graphqlApiKeyContext5_1" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlApiKeyName5_1} | + | spec.displayName | GraphQL-Key-Validation-API | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlApiKeyContext5_1} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName5_1}/api-keys" with body: + """ + {} + """ + Then the response status should be 201 + And the response should be valid JSON + And the JSON response field "status" should be "success" + And the JSON response should have field "apiKey" + When I delete the GraphQL API "${CTX:graphqlApiKeyName5_1}" + Then the response should be successful + + Scenario: List API keys for non-existent GraphQL API returns 404 + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/non-existent-api-id/api-keys" + Then the response status should be 404 + And the response should be valid JSON + And the JSON response field "status" should be "error" + + Scenario: List API keys with invalid GraphQL API ID format returns 404 + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/invalid@api!id/api-keys" + Then the response status should be 404 + And the response should be valid JSON + + Scenario: Revoke API key with invalid formats returns 404 + When I send a "DELETE" request to the "gateway-controller" service at "/graphql-apis/invalid@api/api-keys/invalid@key" + Then the response status should be 404 + And the response should be valid JSON + + Scenario: Revoke non-existent API key returns success (idempotent) + Given I generate a unique value from "graphql-revoke-error-api" and store it as "graphqlApiKeyName9_1" + Given I generate a unique API context from "/graphql-revoke-error" and store it as "graphqlApiKeyContext9_1" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlApiKeyName9_1} | + | spec.displayName | GraphQL-Revoke-Error-API | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlApiKeyContext9_1} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + When I send a "DELETE" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName9_1}/api-keys/non-existent-key" + Then the response status should be 200 + And the response should be valid JSON + When I delete the GraphQL API "${CTX:graphqlApiKeyName9_1}" + Then the response should be successful + + Scenario: Regenerate API key for non-existent GraphQL API returns 404 + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/non-existent-api-id/api-keys/test-key/regenerate" with body: + """ + {} + """ + Then the response status should be 404 + And the response should be valid JSON + And the JSON response field "status" should be "error" + + Scenario: Regenerate non-existent API key returns 404 + Given I generate a unique value from "graphql-test-regenerate-api" and store it as "graphqlApiKeyName11_1" + Given I generate a unique API context from "/graphql-test-regen" and store it as "graphqlApiKeyContext11_1" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlApiKeyName11_1} | + | spec.displayName | GraphQL-Test-Regenerate-Api | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlApiKeyContext11_1} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName11_1}/api-keys/non-existent-key/regenerate" with body: + """ + {} + """ + Then the response status should be 404 + When I delete the GraphQL API "${CTX:graphqlApiKeyName11_1}" + Then the response should be successful + + Scenario: Regenerate API key with invalid ID formats returns 404 + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/invalid@api/api-keys/invalid@key/regenerate" with body: + """ + {} + """ + Then the response status should be 404 + And the response should be valid JSON + + Scenario: Generate API key with invalid JSON body returns error + Given I generate a unique value from "graphql-invalid-json-key-api" and store it as "graphqlApiKeyName13_1" + Given I generate a unique API context from "/graphql-invalid-json-key" and store it as "graphqlApiKeyContext13_1" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlApiKeyName13_1} | + | spec.displayName | GraphQL-Invalid-JSON-Key-API | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlApiKeyContext13_1} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName13_1}/api-keys" with body: + """ + { this is not valid json + """ + Then the response should be a client error + And the response should be valid JSON + When I delete the GraphQL API "${CTX:graphqlApiKeyName13_1}" + Then the response should be successful + + Scenario: API key with special characters in name + Given I generate a unique value from "graphql-special-char-key-api" and store it as "graphqlApiKeyName14_1" + Given I generate a unique API context from "/graphql-special-char-key" and store it as "graphqlApiKeyContext14_1" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlApiKeyName14_1} | + | spec.displayName | GraphQL-Special-Char-Key-API | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlApiKeyContext14_1} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName14_1}/api-keys" with body: + """ + { + "name": "my-api-key_v1" + } + """ + Then the response status should be 201 + And the response should be valid JSON + And the JSON response field "status" should be "success" + When I delete the GraphQL API "${CTX:graphqlApiKeyName14_1}" + Then the response should be successful + + Scenario: List API keys with pagination parameters + Given I generate a unique value from "graphql-paginated-keys-api" and store it as "graphqlApiKeyName15_1" + Given I generate a unique API context from "/graphql-paginated-keys" and store it as "graphqlApiKeyContext15_1" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlApiKeyName15_1} | + | spec.displayName | GraphQL-Paginated-Keys-API | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlApiKeyContext15_1} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/${CTX:graphqlApiKeyName15_1}/api-keys?limit=10&offset=0" + Then the response status should be 200 + And the response should be valid JSON + And the JSON response field "status" should be "success" + When I delete the GraphQL API "${CTX:graphqlApiKeyName15_1}" + Then the response should be successful diff --git a/tests/framework/suites/it/features/graphql_deploy.feature b/tests/framework/suites/it/features/graphql_deploy.feature new file mode 100644 index 0000000000..5a6570dd88 --- /dev/null +++ b/tests/framework/suites/it/features/graphql_deploy.feature @@ -0,0 +1,649 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# -------------------------------------------------------------------- + +@graphql-deploy +Feature: GraphQL API CRUD and connectivity + As a gateway operator + I want to deploy a GraphQL API configuration against the gateway-controller + So that I can verify routing, policy enforcement, and CRUD behavior + + Background: + Given the gateway services are running + And I authenticate using basic auth as "admin" + + Scenario: Deploy a GraphQL API and invoke it successfully + Given I generate a unique resource name from "graphql-e2e" and store it as "graphqlName" + And I generate a unique value from "graphql-e2e" and store it as "graphqlDisplayName" + And I generate a unique API version from "graphql-e2e" and store it as "graphqlVersion" + And I generate a unique API context from "/graphql-e2e" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | ${CTX:graphqlVersion} | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + And the response should be valid JSON + And the JSON response field "status.state" should be "deployed" + + And I send a "POST" request to "${CTX:graphqlContext}" until status 200 with body: + """ + {"query":"{ countries { code name } }"} + """ + + When I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ countries { code name } }"} + """ + Then the response should be successful + And the response should be valid JSON + And the response body should contain "{ countries { code name } }" + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "status" should be "success" + + Scenario: Update a deployed GraphQL API's upstream, and verify the change takes effect + Given I generate a unique resource name from "graphql-update" and store it as "graphqlName" + And I generate a unique value from "graphql-update" and store it as "graphqlDisplayName" + And I generate a unique API version from "graphql-update" and store it as "graphqlVersion" + And I generate a unique API context from "/graphql-update" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | ${CTX:graphqlVersion} | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + + When I update GraphQL API "${CTX:graphqlName}" from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} v2 | + | spec.version | ${CTX:graphqlVersion} | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql-v2 | + Then the response should be successful + And the response should be valid JSON + And the JSON response field "spec.displayName" should be "${CTX:graphqlDisplayName} v2" + + And I send a "POST" request to "${CTX:graphqlContext}" until status 200 with body: + """ + {"query":"{ countries { code } }"} + """ + + When I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ countries { code } }"} + """ + Then the response should be successful + And the response body should contain "/graphql-v2" + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + # There is no separate "mutation support" at the gateway-controller/Envoy layer, and the + # artifact carries no schema field at all: a mutation is just another POST body sent to + # the same single route a query uses, since GraphQL always resolves to exactly one route, + # never a per-operation list like REST's. This proves that pass-through directly, against + # an artifact byte-for-byte identical in shape to every query-only artifact in this file. + Scenario: A mutation query is proxied through the same single route as a query, unmodified + Given I generate a unique resource name from "graphql-mutation" and store it as "graphqlName" + And I generate a unique value from "graphql-mutation" and store it as "graphqlDisplayName" + And I generate a unique API version from "graphql-mutation" and store it as "graphqlVersion" + And I generate a unique API context from "/graphql-mutation" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | ${CTX:graphqlVersion} | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + + And I send a "POST" request to "${CTX:graphqlContext}" until status 200 with body: + """ + {"query":"mutation { createPost(input: { title: \"hi\", body: \"hi\" }) { post { id } } }"} + """ + + When I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"mutation { createPost(input: { title: \"hi\", body: \"hi\" }) { post { id } } }"} + """ + Then the response should be successful + And the response should be valid JSON + And the response body should contain "createPost(input:" + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + Scenario: Deploy a GraphQL API with labels and verify they are stored + Given I generate a unique resource name from "graphql-labeled" and store it as "graphqlName" + And I generate a unique value from "graphql-labeled" and store it as "graphqlDisplayName" + And I generate a unique API version from "graphql-labeled" and store it as "graphqlVersion" + And I generate a unique API context from "/graphql-labeled" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | ${CTX:graphqlVersion} | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + | metadata.labels | {"environment":"production","team":"graphql-team"} | + Then the response should be successful + And the response should be valid JSON + And the JSON response field "status.state" should be "deployed" + + When I get the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + And the JSON response field "metadata.labels.environment" should be "production" + And the JSON response field "metadata.labels.team" should be "graphql-team" + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + Scenario: Deploy a GraphQL API with invalid labels (spaces in keys) fails + Given I generate a unique resource name from "graphql-invalid-labels" and store it as "graphqlName" + And I generate a unique API version from "graphql-invalid-labels" and store it as "graphqlVersion" + And I generate a unique API context from "/graphql-invalid-labels" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | Invalid Labels GraphQL | + | spec.version | ${CTX:graphqlVersion} | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + | metadata.labels | {"Invalid Key":"value"} | + Then the response should be a client error + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Configuration validation failed" + + # ==================== LIST ==================== + + Scenario: List GraphQL APIs when none exist matching a filter + Given I generate a unique value from "no-such-graphql-api" and store it as "missingDisplayName" + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis?displayName=${CTX:missingDisplayName}" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "status" should be "success" + And the JSON response field "count" should be 0 + + Scenario: List GraphQL APIs with pagination parameters + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis?limit=10&offset=0" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "status" should be "success" + + Scenario: List GraphQL APIs filtered by displayName + Given I generate a unique resource name from "graphql-filter" and store it as "graphqlName" + And I generate a unique value from "UniqueGraphQLFilterTest" and store it as "graphqlDisplayName" + And I generate a unique API version from "graphql-filter" and store it as "graphqlVersion" + And I generate a unique API context from "/graphql-filter" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | ${CTX:graphqlVersion} | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis?displayName=${CTX:graphqlDisplayName}" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "status" should be "success" + And the response body should contain "${CTX:graphqlDisplayName}" + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + Scenario: List GraphQL APIs filtered by version + Given I generate a unique resource name from "graphql-version-filter" and store it as "graphqlName" + And I generate a unique value from "graphql-version-filter" and store it as "graphqlDisplayName" + And I generate a unique API version from "graphql-version-filter" and store it as "graphqlVersion" + And I generate a unique API context from "/graphql-version-filter" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | ${CTX:graphqlVersion} | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis?version=${CTX:graphqlVersion}" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "status" should be "success" + And the response body should contain "${CTX:graphqlDisplayName}" + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + Scenario: List GraphQL APIs filtered by context + Given I generate a unique resource name from "graphql-context-filter" and store it as "graphqlName" + And I generate a unique value from "graphql-context-filter" and store it as "graphqlDisplayName" + And I generate a unique API version from "graphql-context-filter" and store it as "graphqlVersion" + And I generate a unique API context from "/graphql-context-filter" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | ${CTX:graphqlVersion} | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis?context=${CTX:graphqlContext}" + Then the response should be successful + And the response should be valid JSON + And the JSON response field "status" should be "success" + And the response body should contain "${CTX:graphqlName}" + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + # ==================== GET / UPDATE / DELETE ERROR CASES ==================== + + Scenario: Get a non-existent GraphQL API returns 404 + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/non-existent-graphql-id" + Then the response status code should be 404 + And the response should be valid JSON + And the JSON response field "status" should be "error" + + Scenario: Get a GraphQL API with an invalid ID format returns 404 + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/invalid@graphql#id" + Then the response status code should be 404 + And the response should be valid JSON + + Scenario: Update a non-existent GraphQL API returns 404 + Given I generate a unique resource name from "graphql-nonexistent-update" and store it as "graphqlName" + When I update GraphQL API "${CTX:graphqlName}" from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | Nonexistent GraphQL Update | + | spec.version | v1.0 | + | spec.context | /nonexistent-graphql-update | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response status code should be 404 + And the response should be valid JSON + + Scenario: Update a GraphQL API with a metadata.name that does not match the path id returns 400 + Given I generate a unique resource name from "graphql-mismatch" and store it as "graphqlName" + And I generate a unique resource name from "graphql-mismatch-other" and store it as "otherGraphqlName" + And I generate a unique API context from "/graphql-mismatch" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | Mismatch GraphQL | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + + When I update GraphQL API "${CTX:graphqlName}" from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:otherGraphqlName} | + | spec.displayName | Mismatch GraphQL | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response status code should be 400 + And the response should be valid JSON + And the response body should contain "does not match path id" + + # A rejected mismatched update must not persist under either handle: the original + # resource must still exist unchanged under its own path handle... + When I get the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + And the JSON response field "spec.displayName" should be "Mismatch GraphQL" + + # ...and the rejected body's handle must never have been created. + When I send a "GET" request to the "gateway-controller" service at "/graphql-apis/${CTX:otherGraphqlName}" + Then the response status code should be 404 + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + Scenario: Update a GraphQL API with an invalid JSON body returns an error + When I send a "PUT" request to the "gateway-controller" service at "/graphql-apis/some-graphql" with body: + """ + { invalid json body + """ + Then the response should be a client error + And the response should be valid JSON + + Scenario: Delete a non-existent GraphQL API returns 404 + When I delete the GraphQL API "non-existent-graphql-delete" + Then the response status code should be 404 + And the response should be valid JSON + And the JSON response field "status" should be "error" + + # ==================== CREATE VALIDATION ERROR CASES ==================== + + Scenario: Deploy a GraphQL API with missing required fields returns an error + Given I generate a unique resource name from "graphql-incomplete" and store it as "graphqlName" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | Incomplete GraphQL | + Then the response should be a client error + And the response should be valid JSON + And the JSON response field "status" should be "error" + And the response body should contain "Configuration validation failed" + + Scenario: Deploy a GraphQL API with a context that does not start with '/' returns 400 + Given I generate a unique resource name from "graphql-bad-context" and store it as "graphqlName" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | Bad Context GraphQL | + | spec.version | v1.0 | + | spec.context | bad-context-no-slash | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be a client error + And the response should be valid JSON + And the response body should contain "context must start with" + + Scenario: Deploy a GraphQL API without an upstream returns 400 + Given I generate a unique resource name from "graphql-missing-upstream" and store it as "graphqlName" + And I generate a unique API context from "/graphql-missing-upstream" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | Missing Upstream GraphQL | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + Then the response should be a client error + And the response should be valid JSON + And the response body should contain "Upstream URL is required" + + Scenario: Deploy a GraphQL API with an invalid upstream URL scheme returns 400 + Given I generate a unique resource name from "graphql-bad-scheme" and store it as "graphqlName" + And I generate a unique API context from "/graphql-bad-scheme" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | Bad Scheme GraphQL | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | ftp://testbench:3000/graphql | + Then the response should be a client error + And the response should be valid JSON + And the response body should contain "must use http or https" + + Scenario: Deploy a GraphQL API with an upstream URL missing a host returns 400 + Given I generate a unique resource name from "graphql-no-host" and store it as "graphqlName" + And I generate a unique API context from "/graphql-no-host" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | No Host GraphQL | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http:///graphql | + Then the response should be a client error + And the response should be valid JSON + And the response body should contain "must include a host" + + Scenario: Deploy a GraphQL API with an unsupported kind value returns an error + Given I generate a unique resource name from "graphql-wrong-kind" and store it as "graphqlName" + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis" with body: + """ + { + "apiVersion": "gateway.api-platform.wso2.com/v1", + "kind": "NotAGraphQLApi", + "metadata": { "name": "${CTX:graphqlName}" }, + "spec": { + "displayName": "Wrong Kind GraphQL", + "version": "v1.0", + "context": "/wrong-kind-graphql", + "upstream": { "main": { "url": "http://testbench:3000/graphql" } } + } + } + """ + Then the response should be a client error + And the response should be valid JSON + + Scenario: Deploy a GraphQL API with an invalid JSON body returns an error + When I send a "POST" request to the "gateway-controller" service at "/graphql-apis" with body: + """ + { this is not valid json content + """ + Then the response should be a client error + And the response should be valid JSON + + Scenario: Deploying a duplicate GraphQL API returns a conflict + Given I generate a unique resource name from "graphql-duplicate" and store it as "graphqlName" + And I generate a unique value from "graphql-duplicate" and store it as "graphqlDisplayName" + And I generate a unique API context from "/graphql-duplicate" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response status code should be 409 + And the response should be valid JSON + And the JSON response field "status" should be "error" + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + # ==================== ROUTING CORRECTNESS: SINGLE POST ROUTE ONLY ==================== + + Scenario: A GraphQL API exposes exactly one POST route - other methods to the same context are not routed + Given I generate a unique resource name from "graphql-single-route" and store it as "graphqlName" + And I generate a unique value from "graphql-single-route" and store it as "graphqlDisplayName" + And I generate a unique API context from "/graphql-single-route" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + Then the response should be successful + + And I send a "POST" request to "${CTX:graphqlContext}" until status 200 with body: + """ + {"query":"{ ping }"} + """ + + When I send a "GET" request to "${CTX:graphqlContext}" + Then the response status code should be 404 + + When I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ ping }"} + """ + Then the response should be successful + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + # ==================== POLICY ENFORCEMENT ==================== + # jwt-auth against the "mock-jwks" issuer is covered separately in + # graphql_policies.feature, which runs in the mcp-policies block where the + # mcp-jwt-auth.toml overlay registers that issuer. This block's platform-gateway + # has no such overlay, so a jwt-auth scenario here would 500 on an unknown issuer. + + Scenario: A GraphQL API with set-headers correctly mutates the proxied response + Given I generate a unique resource name from "graphql-set-headers" and store it as "graphqlName" + And I generate a unique value from "graphql-set-headers" and store it as "graphqlDisplayName" + And I generate a unique API context from "/graphql-set-headers" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + | spec.policies | [{"name":"set-headers","version":"v1","params":{"response":{"headers":[{"name":"X-GraphQL-Test-Marker","value":"graphql-policy-works"}]}}}] | + Then the response should be successful + + And I send a "POST" request to "${CTX:graphqlContext}" until status 200 with body: + """ + {"query":"{ ping }"} + """ + + When I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ ping }"} + """ + Then the response should be successful + And the response header "X-GraphQL-Test-Marker" should be "graphql-policy-works" + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + # CONFIRMED via this test (not assumed): a GraphQL API resolves to exactly one POST route + # with an Exact path/method match, so an OPTIONS preflight never matches that route at + # all - Envoy 404s before the cors policy, or any policy, ever runs. REST's cors preflight + # support (which relies on an explicit "- method: OPTIONS" entry in operations[]) does not + # carry over to GraphQL; there is no operations[] to add one to. This is a genuine, current + # limitation, not yet supported. + Scenario: A GraphQL API with cors does not handle a preflight request - confirmed limitation + Given I generate a unique resource name from "graphql-cors" and store it as "graphqlName" + And I generate a unique value from "graphql-cors" and store it as "graphqlDisplayName" + And I generate a unique API context from "/graphql-cors" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + | spec.policies | [{"name":"cors","version":"v1","params":{"allowedOrigins":["http://example.com"],"allowedMethods":["POST"],"allowedHeaders":["Content-Type"]}}] | + Then the response should be successful + + And I send a "POST" request to "${CTX:graphqlContext}" until status 200 with body: + """ + {"query":"{ ping }"} + """ + + When I clear all headers + And I set header "Origin" to "http://example.com" + And I set header "Access-Control-Request-Method" to "POST" + And I send a "OPTIONS" request to "${CTX:graphqlContext}" + Then the response status code should be 404 + + When I clear all headers + And I authenticate using basic auth as "admin" + And I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + Scenario: A GraphQL API with basic-ratelimit enforces its configured limit + Given I generate a unique resource name from "graphql-ratelimit" and store it as "graphqlName" + And I generate a unique value from "graphql-ratelimit" and store it as "graphqlDisplayName" + And I generate a unique API context from "/graphql-ratelimit" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + | spec.policies | [{"name":"basic-ratelimit","version":"v1","params":{"limits":[{"requests":3,"duration":"1h"}]}}] | + Then the response should be successful + + # The readiness wait below itself counts as the 1st request against the 3-request + # limit - only 2 more successful requests remain before the limit trips. + And I send a "POST" request to "${CTX:graphqlContext}" until status 200 with body: + """ + {"query":"{ ping }"} + """ + + When I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ ping }"} + """ + Then the response should be successful + When I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ ping }"} + """ + Then the response should be successful + When I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ ping }"} + """ + Then the response status code should be 429 + + When I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful + + # ==================== SANDBOX ROUTING ==================== + # GraphQLAPIConfigData has no vhosts override field (unlike RestApi) - the transformer + # always resolves sandbox routing against the gateway's own default main/sandbox vhosts. + # The gateway's built-in default sandbox vhost is the wildcard pattern "sandbox-*", not a + # fixed literal like REST's per-API "sandbox.local" example, so the Host header used below + # must actually match "sandbox-*" (start with "sandbox-"). + Scenario: A GraphQL API with a sandbox upstream routes sandbox-host traffic to the sandbox cluster + Given I generate a unique resource name from "graphql-sandbox" and store it as "graphqlName" + And I generate a unique value from "graphql-sandbox" and store it as "graphqlDisplayName" + And I generate a unique API context from "/graphql-sandbox" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + | spec.upstream.sandbox.url | http://testbench:3000/sandbox/graphql | + Then the response should be successful + + And I send a "POST" request to "${CTX:graphqlContext}" until status 200 with body: + """ + {"query":"{ ping }"} + """ + + When I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ ping }"} + """ + Then the response should be successful + And the JSON response field "path" should be "/graphql" + + When I clear all headers + And I set request host to "sandbox-graphql-e2e" + And I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ ping }"} + """ + Then the response should be successful + And the JSON response field "path" should be "/sandbox/graphql" + + When I clear all headers + And I authenticate using basic auth as "admin" + And I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful diff --git a/tests/framework/suites/it/features/graphql_policies.feature b/tests/framework/suites/it/features/graphql_policies.feature new file mode 100644 index 0000000000..20cd263c5c --- /dev/null +++ b/tests/framework/suites/it/features/graphql_policies.feature @@ -0,0 +1,63 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# -------------------------------------------------------------------- + +# Runs in the mcp-policies block, whose platform-gateway carries the +# mcp-jwt-auth.toml overlay registering the "mock-jwks" issuer that jwt-auth +# validates against. graphql_deploy.feature's own policy scenarios (set-headers, +# cors, basic-ratelimit) need no such overlay and stay in gateway-core. +@graphql-policies +Feature: GraphQL API policy enforcement requiring a registered JWT issuer + As a gateway operator + I want to secure a GraphQL API with jwt-auth + So that only requests carrying a valid token reach the upstream + + Background: + Given the gateway services are running + And I authenticate using basic auth as "admin" + + Scenario: A GraphQL API with jwt-auth rejects requests without a token and accepts a valid one + Given I generate a unique resource name from "graphql-jwt-auth" and store it as "graphqlName" + And I generate a unique value from "graphql-jwt-auth" and store it as "graphqlDisplayName" + And I generate a unique API context from "/graphql-jwt-auth" and store it as "graphqlContext" + When I create GraphQL API from "resources/templates/graphql-api.yaml" with values: + | apiVersion | ${CTX:gatewaySpecVersion} | + | name | ${CTX:graphqlName} | + | spec.displayName | ${CTX:graphqlDisplayName} | + | spec.version | v1.0 | + | spec.context | ${CTX:graphqlContext} | + | spec.upstream.main.url | http://testbench:3000/graphql | + | spec.policies | [{"name":"jwt-auth","version":"v1","params":{"issuers":["mock-jwks"]}}] | + Then the response should be successful + + And I send a "POST" request to "${CTX:graphqlContext}" until status 401 with body: + """ + {"query":"{ ping }"} + """ + + When I get a JWT token from the mock JWKS server with issuer "http://testbench:3001/token" and store it as "graphqlToken" + And I set header "Authorization" to "Bearer ${CTX:graphqlToken}" + And I send a "POST" request to "${CTX:graphqlContext}" with body: + """ + {"query":"{ ping }"} + """ + Then the response status code should be 200 + + When I clear all headers + And I authenticate using basic auth as "admin" + And I delete the GraphQL API "${CTX:graphqlName}" + Then the response should be successful diff --git a/tests/framework/suites/it/it-suite.yaml b/tests/framework/suites/it/it-suite.yaml index ba959dc69f..8c78a70cae 100644 --- a/tests/framework/suites/it/it-suite.yaml +++ b/tests/framework/suites/it/it-suite.yaml @@ -383,6 +383,14 @@ blocks: - name: mcp-deploy features: - features/mcp_deploy.feature + - name: graphql-deploy + tags: "gateway-version>=1.2.0" + features: + - features/graphql_deploy.feature + - name: graphql-api-keys + tags: "gateway-version>=1.2.0" + features: + - features/graphql_api_keys.feature - name: oauth2-auth tags: "gateway-version>1.2.0" features: @@ -577,6 +585,10 @@ blocks: tags: "gateway-version>=1.2.0" features: - features/jwt_auth_scope_claim_rules.feature + - name: graphql-policies + tags: "gateway-version>=1.2.0" + features: + - features/graphql_policies.feature - name: mcp-auth-gateway-host parallel: 1 diff --git a/tests/framework/suites/it/resources/templates/graphql-api.yaml b/tests/framework/suites/it/resources/templates/graphql-api.yaml new file mode 100644 index 0000000000..c2f61ed8b8 --- /dev/null +++ b/tests/framework/suites/it/resources/templates/graphql-api.yaml @@ -0,0 +1,5 @@ +apiVersion: ${VALUE:apiVersion} +kind: GraphQLApi +metadata: + name: ${VALUE:name} +spec: {} diff --git a/tests/framework/suites/it/steps/platformgateway/gateway.go b/tests/framework/suites/it/steps/platformgateway/gateway.go index f60db9851e..d1d89617fe 100644 --- a/tests/framework/suites/it/steps/platformgateway/gateway.go +++ b/tests/framework/suites/it/steps/platformgateway/gateway.go @@ -972,12 +972,12 @@ func (g *Gateway) register(sc *godog.ScenarioContext) { g.createResource) sc.Step(`^I create API with JSON configuration:$`, g.createJSONAPI) g.registerResourceTemplateSteps(sc) - sc.Step(`^I get the (API|LLM provider|LLM provider template|MCP proxy|LLM proxy) "([^"]*)"$`, + sc.Step(`^I get the (API|LLM provider|LLM provider template|MCP proxy|LLM proxy|GraphQL API) "([^"]*)"$`, g.getResource) sc.Step(`^I list all (LLM providers|LLM provider templates|MCP proxies|LLM proxies)$`, g.listResources) sc.Step(`^I update the (API|LLM provider|LLM provider template|MCP proxy|LLM proxy) "([^"]*)" with configuration:$`, g.updateResource) - sc.Step(`^I delete the (API|LLM provider|LLM provider template|MCP proxy|LLM proxy) "([^"]*)"$`, g.deleteResource) + sc.Step(`^I delete the (API|LLM provider|LLM provider template|MCP proxy|LLM proxy|GraphQL API) "([^"]*)"$`, g.deleteResource) sc.Step(`^I send a "([^"]*)" request to the "([^"]*)" service at "([^"]*)"$`, g.serviceRequest) sc.Step(`^I send a "([^"]*)" request to the "([^"]*)" service at "([^"]*)" with body:$`, g.serviceRequestWithBody) sc.Step(`^I send a "([^"]*)" request to the "([^"]*)" service at "([^"]*)" until status (\d+)$`, @@ -1295,6 +1295,7 @@ var resourceKinds = map[string]struct{ declared, collection string }{ "LLM provider template": {"LlmProviderTemplate", collLLMTemplates}, "MCP proxy": {"Mcp", collMCPProxies}, "LLM proxy": {"LlmProxy", collLLMProxies}, + "GraphQL API": {"GraphQLApi", collGraphQLAPIs}, } // kindFromDefinition returns the top-level kind a definition declares. @@ -2145,6 +2146,7 @@ const ( collLLMTemplates = "/llm-provider-templates" collMCPProxies = "/mcp-proxies" collLLMProxies = "/llm-proxies" + collGraphQLAPIs = "/graphql-apis" ) // mutateResource creates, replaces or removes a controller resource and waits for the change @@ -2307,6 +2309,8 @@ func cleanupKindForCollection(collection string) (cleanup.Kind, bool) { return cleanup.KindLLMProviderTemplate, true case collMCPProxies: return cleanup.KindMCPProxy, true + case collGraphQLAPIs: + return cleanup.KindGraphQLAPI, true default: return cleanup.Kind{}, false } diff --git a/tests/framework/suites/it/steps/platformgateway/resource_template.go b/tests/framework/suites/it/steps/platformgateway/resource_template.go index 79c9bae766..6ec1bc3b8e 100644 --- a/tests/framework/suites/it/steps/platformgateway/resource_template.go +++ b/tests/framework/suites/it/steps/platformgateway/resource_template.go @@ -36,9 +36,9 @@ import ( ) func (g *Gateway) registerResourceTemplateSteps(sc *godog.ScenarioContext) { - sc.Step(`^I create (API|LLM provider|LLM provider template|MCP proxy|LLM proxy) from "([^"]*)" with values:$`, + sc.Step(`^I create (API|LLM provider|LLM provider template|MCP proxy|LLM proxy|GraphQL API) from "([^"]*)" with values:$`, g.createResourceFromTemplate) - sc.Step(`^I update (API|LLM provider|LLM provider template|MCP proxy|LLM proxy) "([^"]*)" from "([^"]*)" with values:$`, + sc.Step(`^I update (API|LLM provider|LLM provider template|MCP proxy|LLM proxy|GraphQL API) "([^"]*)" from "([^"]*)" with values:$`, g.updateResourceFromTemplate) sc.Step(`^the first attached LLM provider policy should be "([^"]*)" version "([^"]*)"$`, g.firstLLMProviderPolicyIs) From 2aa92d802ef9a342b7b9901d0293f21b3f58c96b Mon Sep 17 00:00:00 2001 From: Naduni Pamudika Date: Thu, 24 Sep 2026 17:42:05 +0530 Subject: [PATCH 38/38] test: register the new GraphQL template in TestCanonicalResourceTemplates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier GraphQL test-migration commit added resources/templates/graphql-api.yaml but missed both copies of this whitelist test, which assert an exact count/kind mapping over every canonical template file — go test ./... now fails on this branch. Co-Authored-By: Claude Sonnet 5 --- tests/framework/suites/it/steps/common/common_test.go | 1 + tests/framework/suites/it/steps/steps_test.go | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/framework/suites/it/steps/common/common_test.go b/tests/framework/suites/it/steps/common/common_test.go index e31b859b6b..88b0afd5c6 100644 --- a/tests/framework/suites/it/steps/common/common_test.go +++ b/tests/framework/suites/it/steps/common/common_test.go @@ -274,6 +274,7 @@ func TestCanonicalResourceTemplates(t *testing.T) { root := filepath.Join(filepath.Dir(source), "..", "..", "resources", "templates") want := map[string]string{ + "graphql-api.yaml": "GraphQLApi", "llm-provider-template.yaml": "LlmProviderTemplate", "llm-provider.yaml": "LlmProvider", "llm-proxy.yaml": "LlmProxy", diff --git a/tests/framework/suites/it/steps/steps_test.go b/tests/framework/suites/it/steps/steps_test.go index 5c0beabd74..f96a60dcb2 100644 --- a/tests/framework/suites/it/steps/steps_test.go +++ b/tests/framework/suites/it/steps/steps_test.go @@ -218,6 +218,7 @@ func TestCanonicalResourceTemplates(t *testing.T) { root := filepath.Join(filepath.Dir(source), "..", "resources", "templates") want := map[string]string{ + "graphql-api.yaml": "GraphQLApi", "llm-provider-template.yaml": "LlmProviderTemplate", "llm-provider.yaml": "LlmProvider", "llm-proxy.yaml": "LlmProxy",