diff --git a/cli/src/commands/feature-subgraph/commands/publish.ts b/cli/src/commands/feature-subgraph/commands/publish.ts index 2b61784218..d18792df1c 100644 --- a/cli/src/commands/feature-subgraph/commands/publish.ts +++ b/cli/src/commands/feature-subgraph/commands/publish.ts @@ -10,6 +10,7 @@ import { websocketSubprotocolDescription } from '../../../constants.js'; import { BaseCommandOptions } from '../../../core/types/types.js'; import { handleCompositionResult } from '../../../handle-composition-result.js'; import { validateSubscriptionProtocols } from '../../../utils.js'; +import { getBaseHeaders } from '../../../core/config.js'; export default (opts: BaseCommandOptions) => { const command = new Command('publish'); @@ -94,27 +95,32 @@ export default (opts: BaseCommandOptions) => { spinner.start(); } - const resp = await opts.client.platform.publishFederatedSubgraph({ - baseSubgraphName: options.subgraph, - disableResolvabilityValidation: options.disableResolvabilityValidation, - isFeatureSubgraph: true, - labels: [], - name, - namespace: options.namespace, - // Publish schema only - // Optional when feature subgraph does not exist yet - routingUrl: options.routingUrl, - schema, - subscriptionProtocol: options.subscriptionProtocol - ? parseGraphQLSubscriptionProtocol(options.subscriptionProtocol) - : undefined, - subscriptionUrl: options.subscriptionUrl, - websocketSubprotocol: options.websocketSubprotocol - ? parseGraphQLWebsocketSubprotocol(options.websocketSubprotocol) - : undefined, - // passing Standard type to the backend, because the users have to use the 'wgc router plugin publish' command to publish the plugin - type: SubgraphType.STANDARD, - }); + const resp = await opts.client.platform.publishFederatedSubgraph( + { + baseSubgraphName: options.subgraph, + disableResolvabilityValidation: options.disableResolvabilityValidation, + isFeatureSubgraph: true, + labels: [], + name, + namespace: options.namespace, + // Publish schema only + // Optional when feature subgraph does not exist yet + routingUrl: options.routingUrl, + schema, + subscriptionProtocol: options.subscriptionProtocol + ? parseGraphQLSubscriptionProtocol(options.subscriptionProtocol) + : undefined, + subscriptionUrl: options.subscriptionUrl, + websocketSubprotocol: options.websocketSubprotocol + ? parseGraphQLWebsocketSubprotocol(options.websocketSubprotocol) + : undefined, + // passing Standard type to the backend, because the users have to use the 'wgc router plugin publish' command to publish the plugin + type: SubgraphType.STANDARD, + }, + { + headers: getBaseHeaders(), + }, + ); try { handleCompositionResult({ diff --git a/cli/src/commands/grpc-service/commands/create.ts b/cli/src/commands/grpc-service/commands/create.ts new file mode 100644 index 0000000000..968f629972 --- /dev/null +++ b/cli/src/commands/grpc-service/commands/create.ts @@ -0,0 +1,73 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import { SubgraphType } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; +import { splitLabel } from '@wundergraph/cosmo-shared'; +import { Command, program } from 'commander'; +import ora from 'ora'; +import { resolve } from 'pathe'; +import pc from 'picocolors'; +import { getBaseHeaders } from '../../../core/config.js'; +import { BaseCommandOptions } from '../../../core/types/types.js'; + +export default (opts: BaseCommandOptions) => { + const command = new Command('create'); + command.description('Creates a federated grpc subgraph on the control plane.'); + command.argument( + '', + 'The name of the grpc subgraph to create. It is used to uniquely identify your grpc subgraph.', + ); + command.option('-n, --namespace [string]', 'The namespace of the grpc subgraph.'); + command.requiredOption( + '-r, --routing-url ', + 'The routing URL of your subgraph. This is the url at which the subgraph will be accessible.', + ); + command.option( + '--label [labels...]', + 'The labels to apply to the subgraph. The labels are passed in the format = =.', + ); + command.option('--readme ', 'The markdown file which describes the subgraph.'); + + command.action(async (name, options) => { + let readmeFile; + if (options.readme) { + readmeFile = resolve(options.readme); + if (!existsSync(readmeFile)) { + program.error( + pc.red( + pc.bold(`The readme file '${pc.bold(readmeFile)}' does not exist. Please check the path and try again.`), + ), + ); + } + } + + const spinner = ora('GRPC Subgraph is being created...').start(); + const resp = await opts.client.platform.createFederatedSubgraph( + { + name, + namespace: options.namespace, + labels: options.label ? options.label.map((label: string) => splitLabel(label)) : [], + routingUrl: options.routingUrl, + readme: readmeFile ? await readFile(readmeFile, 'utf8') : undefined, + type: SubgraphType.GRPC_SERVICE, + }, + { + headers: getBaseHeaders(), + }, + ); + + if (resp.response?.code === EnumStatusCode.OK) { + spinner.succeed('GRPC subgraph was created successfully.'); + } else { + spinner.fail('Failed to create grpc subgraph.'); + if (resp.response?.details) { + console.log(pc.red(pc.bold(resp.response?.details))); + } + process.exitCode = 1; + // eslint-disable-next-line no-useless-return + return; + } + }); + + return command; +}; diff --git a/cli/src/commands/grpc-service/commands/delete.ts b/cli/src/commands/grpc-service/commands/delete.ts new file mode 100644 index 0000000000..61d06aaed0 --- /dev/null +++ b/cli/src/commands/grpc-service/commands/delete.ts @@ -0,0 +1,156 @@ +import { Command } from 'commander'; +import pc from 'picocolors'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import inquirer from 'inquirer'; +import Table from 'cli-table3'; +import ora from 'ora'; +import { BaseCommandOptions } from '../../../core/types/types.js'; +import { getBaseHeaders } from '../../../core/config.js'; + +export default (opts: BaseCommandOptions) => { + const command = new Command('delete'); + command.description('Deletes a gRPC subgraph on the control plane.'); + command.argument('', 'The name of the gRPC subgraph to delete.'); + command.option('-n, --namespace [string]', 'The namespace of the gRPC subgraph.'); + command.option('-f, --force', 'Flag to force the deletion (skip confirmation).'); + command.option('--suppress-warnings', 'This flag suppresses any warnings produced by composition.'); + command.action(async (name, options) => { + if (!options.force) { + const deletionConfirmed = await inquirer.prompt({ + name: 'confirmDeletion', + type: 'confirm', + message: `Are you sure you want to delete the gRPC subgraph "${name}"?`, + }); + if (!deletionConfirmed.confirmDeletion) { + process.exitCode = 1; + return; + } + } + + const spinner = ora(`The gRPC subgraph "${name}" is being deleted...`).start(); + + const resp = await opts.client.platform.deleteFederatedSubgraph( + { + subgraphName: name, + namespace: options.namespace, + }, + { + headers: getBaseHeaders(), + }, + ); + + switch (resp.response?.code) { + case EnumStatusCode.OK: { + spinner.succeed(`The gRPC subgraph "${name}" was deleted successfully.`); + if (resp.proposalMatchMessage) { + console.log(pc.yellow(`Warning: Proposal match failed`)); + console.log(pc.yellow(resp.proposalMatchMessage)); + } + break; + } + case EnumStatusCode.ERR_SCHEMA_MISMATCH_WITH_APPROVED_PROPOSAL: { + spinner.fail(`Failed to delete gRPC subgraph "${name}".`); + console.log(pc.red(`Error: Proposal match failed`)); + console.log(pc.red(resp.proposalMatchMessage)); + break; + } + case EnumStatusCode.ERR_SUBGRAPH_COMPOSITION_FAILED: { + spinner.fail(`The gRPC subgraph "${name}" was deleted but with composition errors.`); + + const compositionErrorsTable = new Table({ + head: [ + pc.bold(pc.white('FEDERATED_GRAPH_NAME')), + pc.bold(pc.white('NAMESPACE')), + pc.bold(pc.white('FEATURE_FLAG')), + pc.bold(pc.white('ERROR_MESSAGE')), + ], + colWidths: [30, 30, 30, 120], + wordWrap: true, + }); + + console.log( + pc.red( + `There were composition errors when composing at least one federated graph related to the` + + ` gRPC subgraph "${name}".\nThe router will continue to work with the latest valid schema.` + + `\n${pc.bold('Please check the errors below:')}`, + ), + ); + for (const compositionError of resp.compositionErrors) { + compositionErrorsTable.push([ + compositionError.federatedGraphName, + compositionError.namespace, + compositionError.featureFlag || '-', + compositionError.message, + ]); + } + // Don't exit here with 1 because the change was still applied + console.log(compositionErrorsTable.toString()); + + break; + } + case EnumStatusCode.ERR_DEPLOYMENT_FAILED: { + spinner.warn( + `The gRPC subgraph "${name}" was deleted, but the updated composition could not be deployed.` + + `\nThis means the updated composition is not accessible to the router.` + + `\n${pc.bold('Please check the errors below:')}`, + ); + + const deploymentErrorsTable = new Table({ + head: [ + pc.bold(pc.white('FEDERATED_GRAPH_NAME')), + pc.bold(pc.white('NAMESPACE')), + pc.bold(pc.white('ERROR_MESSAGE')), + ], + colWidths: [30, 30, 120], + wordWrap: true, + }); + + for (const deploymentError of resp.deploymentErrors) { + deploymentErrorsTable.push([ + deploymentError.federatedGraphName, + deploymentError.namespace, + deploymentError.message, + ]); + } + // Don't exit here with 1 because the change was still applied + console.log(deploymentErrorsTable.toString()); + + break; + } + default: { + spinner.fail(`Failed to delete the gRPC subgraph "${name}".`); + if (resp.response?.details) { + console.log(pc.red(pc.bold(resp.response?.details))); + } + process.exitCode = 1; + return; + } + } + + if (!options.suppressWarnings && resp.compositionWarnings.length > 0) { + const compositionWarningsTable = new Table({ + head: [ + pc.bold(pc.white('FEDERATED_GRAPH_NAME')), + pc.bold(pc.white('NAMESPACE')), + pc.bold(pc.white('FEATURE_FLAG')), + pc.bold(pc.white('WARNING_MESSAGE')), + ], + colWidths: [30, 30, 30, 120], + wordWrap: true, + }); + + console.log(pc.yellow(`The following warnings were produced while composing the federated graph:`)); + for (const compositionWarning of resp.compositionWarnings) { + compositionWarningsTable.push([ + compositionWarning.federatedGraphName, + compositionWarning.namespace, + compositionWarning.featureFlag || '-', + compositionWarning.message, + ]); + } + console.log(compositionWarningsTable.toString()); + } + }); + + return command; +}; diff --git a/cli/src/commands/grpc-service/commands/publish.ts b/cli/src/commands/grpc-service/commands/publish.ts new file mode 100644 index 0000000000..8bb8585881 --- /dev/null +++ b/cli/src/commands/grpc-service/commands/publish.ts @@ -0,0 +1,273 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import { SubgraphType } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; +import { splitLabel } from '@wundergraph/cosmo-shared'; +import Table from 'cli-table3'; +import { Command, program } from 'commander'; +import ora from 'ora'; +import { resolve } from 'pathe'; +import pc from 'picocolors'; +import { getBaseHeaders } from '../../../core/config.js'; +import { BaseCommandOptions } from '../../../core/types/types.js'; + +export default (opts: BaseCommandOptions) => { + const command = new Command('publish'); + command.description( + "Publishes a gRPC subgraph on the control plane. If the gRPC subgraph doesn't exist, it will be created.\nIf the publication leads to composition errors, the errors will be visible in the Studio.\nThe router will continue to work with the latest valid schema.\nConsider using the 'wgc subgraph check' command to check for composition errors before publishing.", + ); + command.argument('', 'The name of the gRPC subgraph.'); + command.requiredOption('--schema ', 'The schema file to upload to the subgraph.'); + command.requiredOption( + '--generated ', + 'The path to the generated folder which contains the proto schema, mapping and lock files.', + ); + command.option('-n, --namespace [string]', 'The namespace of the gRPC subgraph.'); + command.option( + '-r, --routing-url ', + 'The routing URL of the gRPC subgraph. This is the URL at which the gRPC subgraph will be accessible.' + + ' This parameter is always ignored if the subgraph has already been created.', + ); + command.option( + '--label [labels...]', + 'The labels to apply to the gRPC subgraph. The labels are passed in the format = =.' + + ' This parameter is always ignored if the gRPC subgraph has already been created.', + [], + ); + command.option( + '--fail-on-composition-error', + 'If set, the command will fail if the composition of the federated graph fails.', + false, + ); + command.option( + '--fail-on-admission-webhook-error', + 'If set, the command will fail if the admission webhook fails.', + false, + ); + command.option('--suppress-warnings', 'This flag suppresses any warnings produced by composition.'); + + command.action(async (name, options) => { + const schemaFile = resolve(options.schema); + if (!existsSync(schemaFile)) { + program.error( + pc.red(pc.bold(`The schema file '${schemaFile}' does not exist. Please check the path and try again.`)), + ); + } + + const schemaBuffer = await readFile(schemaFile); + const schema = new TextDecoder().decode(schemaBuffer); + if (schema.trim().length === 0) { + program.error(pc.red(pc.bold(`The schema file '${schemaFile}' is empty. Please provide a valid schema.`))); + } + + const grpcSubgraphGeneratedDir = resolve(options.generated); + if (!existsSync(grpcSubgraphGeneratedDir)) { + program.error( + pc.red( + pc.bold( + `The gRPC subgraph generated directory '${grpcSubgraphGeneratedDir}' does not exist. Please check the path and try again.`, + ), + ), + ); + } + + const protoSchemaFile = resolve(grpcSubgraphGeneratedDir, 'service.proto'); + const protoMappingFile = resolve(grpcSubgraphGeneratedDir, 'mapping.json'); + const protoLockFile = resolve(grpcSubgraphGeneratedDir, 'service.proto.lock.json'); + + if (!existsSync(protoSchemaFile)) { + program.error( + pc.red( + pc.bold(`The proto schema file '${protoSchemaFile}' does not exist. Please check the path and try again.`), + ), + ); + } + const protoSchemaBuffer = await readFile(protoSchemaFile); + const protoSchema = new TextDecoder().decode(protoSchemaBuffer); + if (protoSchema.trim().length === 0) { + program.error( + pc.red(pc.bold(`The proto schema file '${protoSchemaFile}' is empty. Please provide a valid schema.`)), + ); + } + + if (!existsSync(protoMappingFile)) { + program.error( + pc.red( + pc.bold(`The proto mapping file '${protoMappingFile}' does not exist. Please check the path and try again.`), + ), + ); + } + const protoMappingBuffer = await readFile(protoMappingFile); + const protoMapping = new TextDecoder().decode(protoMappingBuffer); + if (protoMapping.trim().length === 0) { + program.error( + pc.red(pc.bold(`The proto mapping file '${protoMappingFile}' is empty. Please provide a valid mapping.`)), + ); + } + + if (!existsSync(protoLockFile)) { + program.error( + pc.red(pc.bold(`The proto lock file '${protoLockFile}' does not exist. Please check the path and try again.`)), + ); + } + const protoLockBuffer = await readFile(protoLockFile); + const protoLock = new TextDecoder().decode(protoLockBuffer); + if (protoLock.trim().length === 0) { + program.error(pc.red(pc.bold(`The proto lock file '${protoLockFile}' is empty. Please provide a valid lock.`))); + } + + const spinner = ora('GRPC Subgraph is being published...').start(); + + const resp = await opts.client.platform.publishFederatedSubgraph( + { + name, + namespace: options.namespace, + schema, + // Optional when subgraph does not exist yet + routingUrl: options.routingUrl, + labels: options.label.map((label: string) => splitLabel(label)), + type: SubgraphType.GRPC_SERVICE, + proto: { + schema: protoSchema, + mappings: protoMapping, + lock: protoLock, + }, + }, + { + headers: getBaseHeaders(), + }, + ); + + switch (resp.response?.code) { + case EnumStatusCode.OK: { + spinner.succeed( + resp?.hasChanged === false + ? 'No new changes to publish.' + : `The gRPC subgraph ${pc.bold(name)} published successfully.`, + ); + console.log(''); + console.log( + 'To apply any new changes after this publication, update your gRPC subgraph by modifying your schema (remember to generate), updating your implementation and then publishing again.', + ); + if (resp.proposalMatchMessage) { + console.log(pc.yellow(`Warning: Proposal match failed`)); + console.log(pc.yellow(resp.proposalMatchMessage)); + } + break; + } + case EnumStatusCode.ERR_SCHEMA_MISMATCH_WITH_APPROVED_PROPOSAL: { + spinner.fail(`Failed to publish gRPC subgraph "${name}".`); + console.log(pc.red(`Error: Proposal match failed`)); + console.log(pc.red(resp.proposalMatchMessage)); + break; + } + case EnumStatusCode.ERR_SUBGRAPH_COMPOSITION_FAILED: { + spinner.warn('The gRPC subgraph was published but with composition errors.'); + if (resp.proposalMatchMessage) { + console.log(pc.yellow(`Warning: Proposal match failed`)); + console.log(pc.yellow(resp.proposalMatchMessage)); + } + + const compositionErrorsTable = new Table({ + head: [ + pc.bold(pc.white('FEDERATED_GRAPH_NAME')), + pc.bold(pc.white('NAMESPACE')), + pc.bold(pc.white('FEATURE_FLAG')), + pc.bold(pc.white('ERROR_MESSAGE')), + ], + colWidths: [30, 30, 30, 120], + wordWrap: true, + }); + + console.log( + pc.red( + `We found composition errors, while composing the federated graph.\nThe router will continue to work with the latest valid schema.\n${pc.bold( + 'Please check the errors below:', + )}`, + ), + ); + for (const compositionError of resp.compositionErrors) { + compositionErrorsTable.push([ + compositionError.federatedGraphName, + compositionError.namespace, + compositionError.featureFlag || '-', + compositionError.message, + ]); + } + // Don't exit here with 1 because the change was still applied + console.log(compositionErrorsTable.toString()); + + if (options.failOnCompositionError) { + program.error(pc.red(pc.bold('The command failed due to composition errors.'))); + } + + break; + } + case EnumStatusCode.ERR_DEPLOYMENT_FAILED: { + spinner.warn( + "The gRPC subgraph was published, but the updated composition hasn't been deployed, so it's not accessible to the router. Check the errors listed below for details.", + ); + + const deploymentErrorsTable = new Table({ + head: [ + pc.bold(pc.white('FEDERATED_GRAPH_NAME')), + pc.bold(pc.white('NAMESPACE')), + pc.bold(pc.white('ERROR_MESSAGE')), + ], + colWidths: [30, 30, 120], + wordWrap: true, + }); + + for (const deploymentError of resp.deploymentErrors) { + deploymentErrorsTable.push([ + deploymentError.federatedGraphName, + deploymentError.namespace, + deploymentError.message, + ]); + } + // Don't exit here with 1 because the change was still applied + console.log(deploymentErrorsTable.toString()); + + if (options.failOnAdmissionWebhookError) { + program.error(pc.red(pc.bold('The command failed due to admission webhook errors.'))); + } + + break; + } + default: { + spinner.fail(`Failed to publish gRPC subgraph "${name}".`); + if (resp.response?.details) { + console.error(pc.red(pc.bold(resp.response?.details))); + } + process.exitCode = 1; + return; + } + } + + if (!options.suppressWarnings && resp.compositionWarnings.length > 0) { + const compositionWarningsTable = new Table({ + head: [ + pc.bold(pc.white('FEDERATED_GRAPH_NAME')), + pc.bold(pc.white('NAMESPACE')), + pc.bold(pc.white('FEATURE_FLAG')), + pc.bold(pc.white('WARNING_MESSAGE')), + ], + colWidths: [30, 30, 30, 120], + wordWrap: true, + }); + + console.log(pc.yellow(`The following warnings were produced while composing the federated graph:`)); + for (const compositionWarning of resp.compositionWarnings) { + compositionWarningsTable.push([ + compositionWarning.federatedGraphName, + compositionWarning.namespace, + compositionWarning.featureFlag || '-', + compositionWarning.message, + ]); + } + console.log(compositionWarningsTable.toString()); + } + }); + + return command; +}; diff --git a/cli/src/commands/grpc-service/index.ts b/cli/src/commands/grpc-service/index.ts index 61432afa2c..3ffcf6a43d 100644 --- a/cli/src/commands/grpc-service/index.ts +++ b/cli/src/commands/grpc-service/index.ts @@ -3,6 +3,9 @@ import { BaseCommandOptions } from '../../core/types/types.js'; import generateCommand from './commands/generate.js'; import initCommand from './commands/init.js'; import listTemplatesCommand from './commands/list-templates.js'; +import createCommand from './commands/create.js'; +import publishCommand from './commands/publish.js'; +import deleteCommand from './commands/delete.js'; export default (opts: BaseCommandOptions) => { const command = new Command('grpc-service'); @@ -10,6 +13,9 @@ export default (opts: BaseCommandOptions) => { command.addCommand(generateCommand(opts)); command.addCommand(initCommand(opts)); command.addCommand(listTemplatesCommand(opts)); + command.addCommand(createCommand(opts)); + command.addCommand(publishCommand(opts)); + command.addCommand(deleteCommand(opts)); return command; }; diff --git a/cli/src/commands/router/commands/plugin/commands/delete.ts b/cli/src/commands/router/commands/plugin/commands/delete.ts index dfa8908404..0567c7044c 100644 --- a/cli/src/commands/router/commands/plugin/commands/delete.ts +++ b/cli/src/commands/router/commands/plugin/commands/delete.ts @@ -12,7 +12,7 @@ export default (opts: BaseCommandOptions) => { command.description('Deletes a plugin subgraph on the control plane.'); command.argument('', 'The name of the plugin subgraph to delete.'); command.option('-n, --namespace [string]', 'The namespace of the plugin subgraph.'); - command.option('-f --force', 'Flag to force the deletion (skip confirmation).'); + command.option('-f, --force', 'Flag to force the deletion (skip confirmation).'); command.option('--suppress-warnings', 'This flag suppresses any warnings produced by composition.'); command.action(async (name, options) => { if (!options.force) { diff --git a/controlplane/src/core/bufservices/subgraph/publishFederatedSubgraph.ts b/controlplane/src/core/bufservices/subgraph/publishFederatedSubgraph.ts index 129822e1d3..5bf4c15c7f 100644 --- a/controlplane/src/core/bufservices/subgraph/publishFederatedSubgraph.ts +++ b/controlplane/src/core/bufservices/subgraph/publishFederatedSubgraph.ts @@ -183,13 +183,19 @@ export function publishFederatedSubgraph( }); if (req.type !== undefined && subgraph.type !== formatSubgraphType(req.type)) { + const subgraphTypeMessages: Record = { + grpc_plugin: `Subgraph ${subgraph.name} is a plugin. Please use the 'wgc router plugin publish' command to publish the plugin.`, + grpc_service: `Subgraph ${subgraph.name} is a grpc service. Please use the 'wgc grpc-service publish' command to publish the grpc service.`, + }; + + const errorMessage = + subgraphTypeMessages[subgraph.type] || + `Subgraph ${subgraph.name} is not of type ${formatSubgraphType(req.type)}.`; + return { response: { code: EnumStatusCode.ERR, - details: - subgraph.type === 'grpc_plugin' - ? `Subgraph ${subgraph.name} is a plugin. Please use the 'wgc router plugin publish' command to publish the plugin.` - : `Subgraph ${subgraph.name} is not of type ${formatSubgraphType(req.type)}.`, + details: errorMessage, }, compositionErrors: [], deploymentErrors: [], @@ -266,6 +272,19 @@ export function publishFederatedSubgraph( proposalMatchMessage, }; } + + if (baseSubgraph.type === 'grpc_service') { + return { + response: { + code: EnumStatusCode.ERR, + details: `Cannot create a feature subgraph with a grpc service base subgraph using this command. Since the base subgraph "${req.baseSubgraphName}" is a grpc service, please use the 'wgc feature-subgraph create' command to create the feature subgraph first, then publish it using the 'wgc grpc-service publish' command.`, + }, + compositionErrors: [], + deploymentErrors: [], + compositionWarnings: [], + proposalMatchMessage, + }; + } } else { return { response: { @@ -469,7 +488,7 @@ export function publishFederatedSubgraph( return { response: { code: EnumStatusCode.ERR, - details: `The proto is required for plugin subgraphs.`, + details: `The proto is required for plugin and grpc subgraphs.`, }, compositionErrors: [], deploymentErrors: [], @@ -510,7 +529,7 @@ export function publishFederatedSubgraph( return { response: { code: EnumStatusCode.ERR, - details: `The schema, mappings, and lock are required for plugin subgraphs.`, + details: `The schema, mappings, and lock are required for plugin and grpc subgraphs.`, }, compositionErrors: [], deploymentErrors: [], @@ -541,7 +560,13 @@ export function publishFederatedSubgraph( version: req.proto?.version || '', }, } - : undefined, + : subgraph.type === 'grpc_service' + ? { + schema: req.proto?.schema || '', + mappings: req.proto?.mappings || '', + lock: req.proto?.lock || '', + } + : undefined, }, opts.blobStorage, { diff --git a/controlplane/test/feature-flag/feature-flag-with-grpc-service-fs.test.ts b/controlplane/test/feature-flag/feature-flag-with-grpc-service-fs.test.ts new file mode 100644 index 0000000000..8ea7673bf5 --- /dev/null +++ b/controlplane/test/feature-flag/feature-flag-with-grpc-service-fs.test.ts @@ -0,0 +1,266 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import { SubgraphType } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; +import { joinLabel } from '@wundergraph/cosmo-shared'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { afterAllSetup, beforeAllSetup, genID, genUniqueLabel } from '../../src/core/test-util.js'; +import { + createFederatedGraph, + createThenPublishSubgraph, + DEFAULT_ROUTER_URL, + DEFAULT_SUBGRAPH_URL_ONE, + DEFAULT_SUBGRAPH_URL_TWO, + SetupTest, + toggleFeatureFlag, +} from '../test-util.js'; + +// Read the actual proto, mapping and lock files for gRPC service +const testDataPath = path.join(process.cwd(), 'test/test-data/grpc-service'); +const grpcServiceSchema = readFileSync(path.join(testDataPath, 'service.proto'), 'utf8'); +const grpcServiceMappings = readFileSync(path.join(testDataPath, 'mapping.json'), 'utf8'); +const grpcServiceLock = readFileSync(path.join(testDataPath, 'service.proto.lock.json'), 'utf8'); + +let dbname = ''; + +describe('Feature flag with gRPC service feature subgraph tests', () => { + beforeAll(async () => { + dbname = await beforeAllSetup(); + }); + + afterAll(async () => { + await afterAllSetup(dbname); + }); + + test('that a feature flag can be created with a feature subgraph based on a gRPC service subgraph', async () => { + const { client, server } = await SetupTest({ + dbname, + }); + + // Generate unique names and labels + const regularSubgraphName = genID('regular-subgraph'); + const grpcServiceSubgraphName = genID('grpc-service-subgraph'); + const featureSubgraphName = genID('feature-subgraph'); + const fedGraphName = genID('fed-graph'); + const featureFlagName = genID('feature-flag'); + const sharedLabel = genUniqueLabel('shared'); + + // Step 1: Create and publish a regular subgraph + const regularSubgraphSDL = ` + type Query { + products: [Product!]! + product(id: ID!): Product + } + + type Product { + id: ID! + name: String! + price: Float! + description: String + } + `; + + await createThenPublishSubgraph( + client, + regularSubgraphName, + 'default', + regularSubgraphSDL, + [sharedLabel], + DEFAULT_SUBGRAPH_URL_ONE, + ); + + // Verify regular subgraph was created successfully + const getRegularSubgraphResponse = await client.getSubgraphByName({ + name: regularSubgraphName, + }); + expect(getRegularSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); + expect(getRegularSubgraphResponse.graph?.type).toBe(SubgraphType.STANDARD); + + // Step 2: Create a gRPC service subgraph + const createGrpcServiceResponse = await client.createFederatedSubgraph({ + name: grpcServiceSubgraphName, + namespace: 'default', + type: SubgraphType.GRPC_SERVICE, + labels: [sharedLabel], + routingUrl: DEFAULT_SUBGRAPH_URL_TWO, // gRPC services need routing URLs + }); + expect(createGrpcServiceResponse.response?.code).toBe(EnumStatusCode.OK); + + // Step 3: Publish the gRPC service subgraph + const grpcServiceSDL = ` + type Query { + users: [User!]! + user(id: ID!): User + } + + type Mutation { + createUser(user: UserInput!): User + updateUser(id: ID!, user: UserInput!): User + } + + type User { + id: ID! + name: String! + email: String! + phone: String + status: UserStatus! + bio: String + tags: [String!]! + } + + input UserInput { + name: String! + email: String! + phone: String + status: UserStatus! + bio: String + } + + enum UserStatus { + ACTIVE + INACTIVE + SUSPENDED + } + `; + + const validProtoRequest = { + schema: grpcServiceSchema, + mappings: grpcServiceMappings, + lock: grpcServiceLock, + }; + + const publishGrpcServiceResponse = await client.publishFederatedSubgraph({ + name: grpcServiceSubgraphName, + namespace: 'default', + schema: grpcServiceSDL, + type: SubgraphType.GRPC_SERVICE, + proto: validProtoRequest, + }); + expect(publishGrpcServiceResponse.response?.code).toBe(EnumStatusCode.OK); + + // Verify gRPC service subgraph was published successfully + const getGrpcServiceSubgraphResponse = await client.getSubgraphByName({ + name: grpcServiceSubgraphName, + }); + expect(getGrpcServiceSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); + expect(getGrpcServiceSubgraphResponse.graph?.type).toBe(SubgraphType.GRPC_SERVICE); + expect(getGrpcServiceSubgraphResponse.graph?.routingURL).toBe(DEFAULT_SUBGRAPH_URL_TWO); + + // Step 4: Create federated graph with the same labels + await createFederatedGraph(client, fedGraphName, 'default', [joinLabel(sharedLabel)], DEFAULT_ROUTER_URL); + + // Verify federated graph was created and includes both subgraphs + const getFedGraphResponse = await client.getFederatedGraphByName({ + name: fedGraphName, + namespace: 'default', + }); + expect(getFedGraphResponse.response?.code).toBe(EnumStatusCode.OK); + expect(getFedGraphResponse.subgraphs.length).toBe(2); + + // Verify both subgraphs are included + const subgraphNames = getFedGraphResponse.subgraphs.map((sg) => sg.name); + expect(subgraphNames).toContain(regularSubgraphName); + expect(subgraphNames).toContain(grpcServiceSubgraphName); + + // Step 5: Create a feature subgraph with the gRPC service subgraph as the base + const createFeatureSubgraphResponse = await client.createFederatedSubgraph({ + name: featureSubgraphName, + isFeatureSubgraph: true, + baseSubgraphName: grpcServiceSubgraphName, + labels: [sharedLabel], + routingUrl: DEFAULT_SUBGRAPH_URL_TWO, // Feature subgraphs based on gRPC services need routing URLs + }); + expect(createFeatureSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); + + // Step 6: Publish the feature subgraph + const featureSubgraphSDL = ` + type Query { + users: [User!]! + user(id: ID!): User + usersByStatus(status: UserStatus!): [User!]! + } + + type Mutation { + createUser(user: UserInput!): User + updateUser(id: ID!, user: UserInput!): User + } + + type User { + id: ID! + name: String! + email: String! + phone: String + status: UserStatus! + bio: String + tags: [String!]! + createdAt: String + updatedAt: String + } + + input UserInput { + name: String! + email: String! + phone: String + status: UserStatus! + bio: String + } + + enum UserStatus { + ACTIVE + INACTIVE + SUSPENDED + } + `; + + const publishFeatureSubgraphResponse = await client.publishFederatedSubgraph({ + name: featureSubgraphName, + schema: featureSubgraphSDL, + type: SubgraphType.GRPC_SERVICE, + proto: validProtoRequest, // gRPC service feature subgraphs also need proto data + }); + expect(publishFeatureSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); + + // Verify feature subgraph was created and inherited gRPC service type + const getFeatureSubgraphResponse = await client.getSubgraphByName({ + name: featureSubgraphName, + }); + expect(getFeatureSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); + expect(getFeatureSubgraphResponse.graph?.name).toBe(featureSubgraphName); + expect(getFeatureSubgraphResponse.graph?.isFeatureSubgraph).toBe(true); + expect(getFeatureSubgraphResponse.graph?.type).toBe(SubgraphType.GRPC_SERVICE); + expect(getFeatureSubgraphResponse.graph?.routingURL).toBe(DEFAULT_SUBGRAPH_URL_TWO); + + // Step 7: Create a feature flag using the feature subgraph + const createFeatureFlagResponse = await client.createFeatureFlag({ + name: featureFlagName, + featureSubgraphNames: [featureSubgraphName], + labels: [sharedLabel], + isEnabled: true, + }); + expect(createFeatureFlagResponse.response?.code).toBe(EnumStatusCode.OK); + + // Verify feature flag was created successfully + const getFeatureFlagResponse = await client.getFeatureFlagByName({ + name: featureFlagName, + namespace: 'default', + }); + expect(getFeatureFlagResponse.response?.code).toBe(EnumStatusCode.OK); + expect(getFeatureFlagResponse.featureFlag?.name).toBe(featureFlagName); + expect(getFeatureFlagResponse.featureFlag?.isEnabled).toBe(true); + expect(getFeatureFlagResponse.featureSubgraphs?.length).toBe(1); + expect(getFeatureFlagResponse.featureSubgraphs?.[0]?.name).toBe(featureSubgraphName); + + // Step 8: Verify the complete setup by checking federated graph composition + const getFinalFedGraphResponse = await client.getFederatedGraphByName({ + name: fedGraphName, + namespace: 'default', + }); + expect(getFinalFedGraphResponse.response?.code).toBe(EnumStatusCode.OK); + + // The federated graph should still have the base subgraphs + // Feature subgraphs are not directly included in the federated graph + expect(getFinalFedGraphResponse.subgraphs.length).toBe(2); + + await server.close(); + }); +}); diff --git a/controlplane/test/feature-subgraph/create-feature-subgraph.test.ts b/controlplane/test/feature-subgraph/create-feature-subgraph.test.ts index 661e5fe12e..7b327ac7ea 100644 --- a/controlplane/test/feature-subgraph/create-feature-subgraph.test.ts +++ b/controlplane/test/feature-subgraph/create-feature-subgraph.test.ts @@ -565,6 +565,143 @@ describe('Create feature subgraph tests', () => { await server.close(); }); + test('that a feature subgraph inherits the GRPC_SERVICE type from its base subgraph', async () => { + const { client, server } = await SetupTest({ dbname }); + + const baseGrpcServiceName = genID('baseGrpcService'); + const featureSubgraphName = genID('featureGrpcService'); + const grpcServiceLabel = genUniqueLabel('grpc-service'); + + // Create a gRPC service base subgraph + const createBaseGrpcServiceResponse = await client.createFederatedSubgraph({ + name: baseGrpcServiceName, + type: SubgraphType.GRPC_SERVICE, + routingUrl: DEFAULT_SUBGRAPH_URL_ONE, + labels: [grpcServiceLabel], + }); + expect(createBaseGrpcServiceResponse.response?.code).toBe(EnumStatusCode.OK); + + // Verify the base subgraph is GRPC_SERVICE type + const getBaseSubgraphResponse = await client.getSubgraphByName({ + name: baseGrpcServiceName, + }); + expect(getBaseSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); + expect(getBaseSubgraphResponse.graph?.type).toBe(SubgraphType.GRPC_SERVICE); + + // Create a feature subgraph based on the gRPC service + const createFeatureSubgraphResponse = await client.createFederatedSubgraph({ + name: featureSubgraphName, + routingUrl: DEFAULT_SUBGRAPH_URL_TWO, + isFeatureSubgraph: true, + baseSubgraphName: baseGrpcServiceName, + }); + expect(createFeatureSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); + + // Verify the feature subgraph inherited the GRPC_SERVICE type + const getFeatureSubgraphResponse = await client.getSubgraphByName({ + name: featureSubgraphName, + }); + expect(getFeatureSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); + expect(getFeatureSubgraphResponse.graph?.name).toBe(featureSubgraphName); + expect(getFeatureSubgraphResponse.graph?.isFeatureSubgraph).toBe(true); + expect(getFeatureSubgraphResponse.graph?.type).toBe(SubgraphType.GRPC_SERVICE); + expect(getFeatureSubgraphResponse.graph?.routingURL).toBe(DEFAULT_SUBGRAPH_URL_TWO); + + await server.close(); + }); + + test('that a feature subgraph based on a gRPC service requires a routing URL', async () => { + const { client, server } = await SetupTest({ dbname }); + + const baseGrpcServiceName = genID('baseGrpcService'); + const featureSubgraphName = genID('featureGrpcService'); + const grpcServiceLabel = genUniqueLabel('grpc-service'); + + // Create a gRPC service base subgraph (requires routing URL) + const createBaseGrpcServiceResponse = await client.createFederatedSubgraph({ + name: baseGrpcServiceName, + type: SubgraphType.GRPC_SERVICE, + routingUrl: DEFAULT_SUBGRAPH_URL_ONE, + labels: [grpcServiceLabel], + }); + expect(createBaseGrpcServiceResponse.response?.code).toBe(EnumStatusCode.OK); + + // Verify the base gRPC service has a routing URL + const getBaseGrpcServiceResponse = await client.getSubgraphByName({ + name: baseGrpcServiceName, + }); + expect(getBaseGrpcServiceResponse.response?.code).toBe(EnumStatusCode.OK); + expect(getBaseGrpcServiceResponse.graph?.type).toBe(SubgraphType.GRPC_SERVICE); + expect(getBaseGrpcServiceResponse.graph?.routingURL).toBe(DEFAULT_SUBGRAPH_URL_ONE); + + // Try to create a feature subgraph based on the gRPC service without routing URL - should fail + const createFeatureSubgraphResponse = await client.createFederatedSubgraph({ + name: featureSubgraphName, + isFeatureSubgraph: true, + baseSubgraphName: baseGrpcServiceName, + // Note: No routingUrl provided - should fail for gRPC service-based feature subgraphs + }); + expect(createFeatureSubgraphResponse.response?.code).toBe(EnumStatusCode.ERR); + expect(createFeatureSubgraphResponse.response?.details).toBe('A non-Event-Driven Graph must define a routing URL'); + + await server.close(); + }); + + test('that multiple feature subgraphs can inherit the GRPC_SERVICE type from their base subgraph', async () => { + const { client, server } = await SetupTest({ dbname }); + + const baseGrpcServiceName = genID('baseGrpcService'); + const featureSubgraphName1 = genID('featureGrpcService1'); + const featureSubgraphName2 = genID('featureGrpcService2'); + const grpcServiceLabel = genUniqueLabel('grpc-service'); + + // Create a gRPC service base subgraph + const createBaseGrpcServiceResponse = await client.createFederatedSubgraph({ + name: baseGrpcServiceName, + type: SubgraphType.GRPC_SERVICE, + routingUrl: DEFAULT_SUBGRAPH_URL_ONE, + labels: [grpcServiceLabel], + }); + expect(createBaseGrpcServiceResponse.response?.code).toBe(EnumStatusCode.OK); + + // Create first feature subgraph + const createFeatureSubgraph1Response = await client.createFederatedSubgraph({ + name: featureSubgraphName1, + routingUrl: DEFAULT_SUBGRAPH_URL_TWO, + isFeatureSubgraph: true, + baseSubgraphName: baseGrpcServiceName, + }); + expect(createFeatureSubgraph1Response.response?.code).toBe(EnumStatusCode.OK); + + // Create second feature subgraph + const createFeatureSubgraph2Response = await client.createFederatedSubgraph({ + name: featureSubgraphName2, + routingUrl: 'http://localhost:4003', + isFeatureSubgraph: true, + baseSubgraphName: baseGrpcServiceName, + }); + expect(createFeatureSubgraph2Response.response?.code).toBe(EnumStatusCode.OK); + + // Verify both feature subgraphs inherited the GRPC_SERVICE type + const getFeatureSubgraph1Response = await client.getSubgraphByName({ + name: featureSubgraphName1, + }); + expect(getFeatureSubgraph1Response.response?.code).toBe(EnumStatusCode.OK); + expect(getFeatureSubgraph1Response.graph?.type).toBe(SubgraphType.GRPC_SERVICE); + expect(getFeatureSubgraph1Response.graph?.isFeatureSubgraph).toBe(true); + expect(getFeatureSubgraph1Response.graph?.routingURL).toBe(DEFAULT_SUBGRAPH_URL_TWO); + + const getFeatureSubgraph2Response = await client.getSubgraphByName({ + name: featureSubgraphName2, + }); + expect(getFeatureSubgraph2Response.response?.code).toBe(EnumStatusCode.OK); + expect(getFeatureSubgraph2Response.graph?.type).toBe(SubgraphType.GRPC_SERVICE); + expect(getFeatureSubgraph2Response.graph?.isFeatureSubgraph).toBe(true); + expect(getFeatureSubgraph2Response.graph?.routingURL).toBe('http://localhost:4003'); + + await server.close(); + }); + test.each(['organization-admin', 'organization-developer', 'subgraph-admin'])( '%s should be able to create feature subgraph', async (role) => { diff --git a/controlplane/test/feature-subgraph/publish-feature-subgraph.test.ts b/controlplane/test/feature-subgraph/publish-feature-subgraph.test.ts index 6e74e1c512..47ce8e768c 100644 --- a/controlplane/test/feature-subgraph/publish-feature-subgraph.test.ts +++ b/controlplane/test/feature-subgraph/publish-feature-subgraph.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; import { joinLabel } from '@wundergraph/cosmo-shared'; import { SubgraphType } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; @@ -34,6 +36,12 @@ async function enableProposalsForNamespace(client: any, namespace = 'default') { return enableResponse; } +// Read the test proto data for gRPC service tests +const testDataPath = path.join(process.cwd(), 'test/test-data/plugin'); +const grpcProtoSchema = readFileSync(path.join(testDataPath, 'service.proto'), 'utf8'); +const grpcProtoMappings = readFileSync(path.join(testDataPath, 'mapping.json'), 'utf8'); +const grpcProtoLock = readFileSync(path.join(testDataPath, 'service.proto.lock.json'), 'utf8'); + describe('Publish feature subgraph tests', () => { beforeAll(async () => { dbname = await beforeAllSetup(); @@ -451,41 +459,321 @@ describe('Publish feature subgraph tests', () => { await server.close(); }); - test('that publishFederatedSubgraph works with namespace parameter', async () => { + test('that publishFederatedSubgraph works with namespace parameter', async () => { + const { client, server } = await SetupTest({ dbname }); + + const baseSubgraphName = genID('baseSubgraph'); + const featureSubgraphName = genID('featureSubgraph'); + const namespace = genID('namespace').toLowerCase(); + + // Create namespace + await createNamespace(client, namespace); + + // Create base subgraph in the namespace + await createSubgraph(client, baseSubgraphName, DEFAULT_SUBGRAPH_URL_ONE, namespace); + + // Create feature subgraph in the same namespace (replicating CLI call) + const publishFeatureSubgraphResponse = await client.publishFederatedSubgraph({ + baseSubgraphName, + disableResolvabilityValidation: false, + isFeatureSubgraph: true, + labels: [], + name: featureSubgraphName, + namespace, + routingUrl: DEFAULT_SUBGRAPH_URL_TWO, + schema: 'type Query { hello: String }', + type: SubgraphType.STANDARD, + }); + + expect(publishFeatureSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); + + // Verify the feature subgraph was created in the correct namespace + const getFeatureSubgraphResponse = await client.getSubgraphByName({ + name: featureSubgraphName, + namespace, + }); + expect(getFeatureSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); + expect(getFeatureSubgraphResponse.graph?.namespace).toBe(namespace); + + await server.close(); + }); + + test('that creating and publishing a feature subgraph in one step fails when base subgraph is a grpc service - replicating fs publish command', async () => { const { client, server } = await SetupTest({ dbname }); - const baseSubgraphName = genID('baseSubgraph'); + const baseGrpcServiceName = genID('baseGrpcService'); const featureSubgraphName = genID('featureSubgraph'); - const namespace = genID('namespace').toLowerCase(); - - // Create namespace - await createNamespace(client, namespace); + const grpcServiceLabel = genUniqueLabel('grpc-service'); + + // Create a GRPC service base subgraph + const createBaseGrpcServiceResponse = await client.createFederatedSubgraph({ + name: baseGrpcServiceName, + type: SubgraphType.GRPC_SERVICE, + routingUrl: DEFAULT_SUBGRAPH_URL_ONE, + labels: [grpcServiceLabel], + }); + expect(createBaseGrpcServiceResponse.response?.code).toBe(EnumStatusCode.OK); - // Create base subgraph in the namespace - await createSubgraph(client, baseSubgraphName, DEFAULT_SUBGRAPH_URL_ONE, namespace); + // Verify the base subgraph is GRPC_SERVICE type + const getBaseSubgraphResponse = await client.getSubgraphByName({ + name: baseGrpcServiceName, + }); + expect(getBaseSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); + expect(getBaseSubgraphResponse.graph?.type).toBe(SubgraphType.GRPC_SERVICE); - // Create feature subgraph in the same namespace (replicating CLI call) + // Try to create and publish feature subgraph in one command - replicating CLI `wgc feature-subgraph publish` call + // This should fail because the base subgraph is a GRPC service and the feature subgraph doesn't exist yet const publishFeatureSubgraphResponse = await client.publishFederatedSubgraph({ - baseSubgraphName, + baseSubgraphName: baseGrpcServiceName, // This triggers creation of feature subgraph disableResolvabilityValidation: false, isFeatureSubgraph: true, labels: [], + name: featureSubgraphName, // Feature subgraph doesn't exist yet + routingUrl: DEFAULT_SUBGRAPH_URL_TWO, + schema: 'type Query { hello: String }', + type: SubgraphType.STANDARD, // This is what the CLI passes regardless of base type + }); + + // Should fail with specific error about GRPC services + expect(publishFeatureSubgraphResponse.response?.code).toBe(EnumStatusCode.ERR); + expect(publishFeatureSubgraphResponse.response?.details).toBe( + `Cannot create a feature subgraph with a grpc service base subgraph using this command. Since the base subgraph "${baseGrpcServiceName}" is a grpc service, please use the 'wgc feature-subgraph create' command to create the feature subgraph first, then publish it using the 'wgc grpc-service publish' command.`, + ); + + // Verify the feature subgraph was NOT created + const getFeatureSubgraphResponse = await client.getSubgraphByName({ + name: featureSubgraphName, + }); + expect(getFeatureSubgraphResponse.response?.code).toBe(EnumStatusCode.ERR_NOT_FOUND); + + await server.close(); + }); + + test('that a feature subgraph cannot be published with a GRPC service base subgraph using wgc fs publish after creation', async () => { + const { client, server } = await SetupTest({ dbname }); + + const baseGrpcServiceName = genID('baseGrpcService'); + const featureSubgraphName = genID('featureSubgraph'); + const grpcServiceLabel = genUniqueLabel('grpc-service'); + + // Create a GRPC service base subgraph + const createBaseGrpcServiceResponse = await client.createFederatedSubgraph({ + name: baseGrpcServiceName, + type: SubgraphType.GRPC_SERVICE, + routingUrl: DEFAULT_SUBGRAPH_URL_ONE, + labels: [grpcServiceLabel], + }); + expect(createBaseGrpcServiceResponse.response?.code).toBe(EnumStatusCode.OK); + + // Create feature subgraph based on GRPC service (replicating wgc feature-subgraph create) + const createFeatureSubgraphResponse = await client.createFederatedSubgraph({ name: featureSubgraphName, - namespace, routingUrl: DEFAULT_SUBGRAPH_URL_TWO, + labels: [], + isFeatureSubgraph: true, + baseSubgraphName: baseGrpcServiceName, + }); + expect(createFeatureSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); + + // Try to publish the feature subgraph using wgc fs publish - should fail + const publishFeatureSubgraphResponse = await client.publishFederatedSubgraph({ + disableResolvabilityValidation: false, + isFeatureSubgraph: true, + labels: [], + name: featureSubgraphName, schema: 'type Query { hello: String }', - type: SubgraphType.STANDARD, + type: SubgraphType.STANDARD, // This is what wgc fs publish uses + }); + + expect(publishFeatureSubgraphResponse.response?.code).toBe(EnumStatusCode.ERR); + expect(publishFeatureSubgraphResponse.response?.details).toBe( + `Subgraph ${featureSubgraphName} is a grpc service. Please use the 'wgc grpc-service publish' command to publish the grpc service.`, + ); + + await server.close(); + }); + + test('that a feature subgraph can be created and published inheriting GRPC_SERVICE type from base subgraph', async () => { + const { client, server } = await SetupTest({ dbname }); + + const baseGrpcServiceName = genID('baseGrpcService'); + const featureSubgraphName = genID('featureGrpcService'); + const grpcServiceLabel = genUniqueLabel('grpc-service'); + + // Create a gRPC service base subgraph + const createBaseGrpcServiceResponse = await client.createFederatedSubgraph({ + name: baseGrpcServiceName, + type: SubgraphType.GRPC_SERVICE, + routingUrl: DEFAULT_SUBGRAPH_URL_ONE, + labels: [grpcServiceLabel], + }); + expect(createBaseGrpcServiceResponse.response?.code).toBe(EnumStatusCode.OK); + + // Verify the base subgraph is GRPC_SERVICE type + const getBaseSubgraphResponse = await client.getSubgraphByName({ + name: baseGrpcServiceName, + }); + expect(getBaseSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); + expect(getBaseSubgraphResponse.graph?.type).toBe(SubgraphType.GRPC_SERVICE); + + // Create feature subgraph based on GRPC service (replicating wgc feature-subgraph create) + const createFeatureSubgraphResponse = await client.createFederatedSubgraph({ + name: featureSubgraphName, + routingUrl: DEFAULT_SUBGRAPH_URL_TWO, + labels: [], + isFeatureSubgraph: true, + baseSubgraphName: baseGrpcServiceName, }); + expect(createFeatureSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); + + // Create and publish feature subgraph in one command - replicating CLI call + const validGrpcProtoRequest = { + schema: grpcProtoSchema, + mappings: grpcProtoMappings, + lock: grpcProtoLock, + }; + // replicating wgc grpc-service publish + const publishFeatureSubgraphResponse = await client.publishFederatedSubgraph({ + name: featureSubgraphName, + schema: 'type Query { grpcServiceHello: String }', + proto: validGrpcProtoRequest, + type: SubgraphType.GRPC_SERVICE, + }); expect(publishFeatureSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); - // Verify the feature subgraph was created in the correct namespace + // Verify the feature subgraph was created and inherited the GRPC_SERVICE type const getFeatureSubgraphResponse = await client.getSubgraphByName({ name: featureSubgraphName, - namespace, }); expect(getFeatureSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); - expect(getFeatureSubgraphResponse.graph?.namespace).toBe(namespace); + expect(getFeatureSubgraphResponse.graph?.name).toBe(featureSubgraphName); + expect(getFeatureSubgraphResponse.graph?.isFeatureSubgraph).toBe(true); + expect(getFeatureSubgraphResponse.graph?.type).toBe(SubgraphType.GRPC_SERVICE); + expect(getFeatureSubgraphResponse.graph?.routingURL).toBe(DEFAULT_SUBGRAPH_URL_TWO); + + await server.close(); + }); + + test('that multiple feature subgraphs can be created and published from the same gRPC service base', async () => { + const { client, server } = await SetupTest({ dbname }); + + const baseGrpcServiceName = genID('baseGrpcService'); + const featureSubgraphName1 = genID('featureGrpcService1'); + const featureSubgraphName2 = genID('featureGrpcService2'); + const grpcServiceLabel = genUniqueLabel('grpc-service'); + + // Create a gRPC service base subgraph + const createBaseGrpcServiceResponse = await client.createFederatedSubgraph({ + name: baseGrpcServiceName, + type: SubgraphType.GRPC_SERVICE, + routingUrl: DEFAULT_SUBGRAPH_URL_ONE, + labels: [grpcServiceLabel], + }); + expect(createBaseGrpcServiceResponse.response?.code).toBe(EnumStatusCode.OK); + + const validGrpcProtoRequest = { + schema: grpcProtoSchema, + mappings: grpcProtoMappings, + lock: grpcProtoLock, + }; + + // Create feature subgraph based on GRPC service (replicating wgc feature-subgraph create) + const createFeatureSubgraph1Response = await client.createFederatedSubgraph({ + name: featureSubgraphName1, + routingUrl: DEFAULT_SUBGRAPH_URL_TWO, + labels: [], + isFeatureSubgraph: true, + baseSubgraphName: baseGrpcServiceName, + }); + expect(createFeatureSubgraph1Response.response?.code).toBe(EnumStatusCode.OK); + + // replicating wgc grpc-service publish + const publishFeatureSubgraph1Response = await client.publishFederatedSubgraph({ + name: featureSubgraphName1, + schema: 'type Query { hello1: String }', + proto: validGrpcProtoRequest, + type: SubgraphType.GRPC_SERVICE, + }); + expect(publishFeatureSubgraph1Response.response?.code).toBe(EnumStatusCode.OK); + + // Create feature subgraph based on GRPC service (replicating wgc feature-subgraph create) + const createFeatureSubgraph2Response = await client.createFederatedSubgraph({ + name: featureSubgraphName2, + routingUrl: 'http://localhost:4003', + labels: [], + isFeatureSubgraph: true, + baseSubgraphName: baseGrpcServiceName, + }); + expect(createFeatureSubgraph2Response.response?.code).toBe(EnumStatusCode.OK); + + // Create second feature subgraph (replicating CLI call) + const publishFeatureSubgraph2Response = await client.publishFederatedSubgraph({ + name: featureSubgraphName2, + schema: 'type Query { hello2: String }', + type: SubgraphType.GRPC_SERVICE, + proto: validGrpcProtoRequest, + }); + expect(publishFeatureSubgraph2Response.response?.code).toBe(EnumStatusCode.OK); + + // Verify both feature subgraphs were created with correct type + const getFeatureSubgraph1Response = await client.getSubgraphByName({ + name: featureSubgraphName1, + }); + expect(getFeatureSubgraph1Response.response?.code).toBe(EnumStatusCode.OK); + expect(getFeatureSubgraph1Response.graph?.type).toBe(SubgraphType.GRPC_SERVICE); + expect(getFeatureSubgraph1Response.graph?.isFeatureSubgraph).toBe(true); + expect(getFeatureSubgraph1Response.graph?.routingURL).toBe(DEFAULT_SUBGRAPH_URL_TWO); + + const getFeatureSubgraph2Response = await client.getSubgraphByName({ + name: featureSubgraphName2, + }); + expect(getFeatureSubgraph2Response.response?.code).toBe(EnumStatusCode.OK); + expect(getFeatureSubgraph2Response.graph?.type).toBe(SubgraphType.GRPC_SERVICE); + expect(getFeatureSubgraph2Response.graph?.isFeatureSubgraph).toBe(true); + expect(getFeatureSubgraph2Response.graph?.routingURL).toBe('http://localhost:4003'); + + await server.close(); + }); + + test('that publishFederatedSubgraph fails to publish gRPC service feature subgraph without required proto information', async () => { + const { client, server } = await SetupTest({ dbname }); + + const baseGrpcServiceName = genID('baseGrpcService'); + const featureSubgraphName = genID('featureGrpcService'); + const grpcServiceLabel = genUniqueLabel('grpc-service'); + + // Create a gRPC service base subgraph + const createBaseGrpcServiceResponse = await client.createFederatedSubgraph({ + name: baseGrpcServiceName, + type: SubgraphType.GRPC_SERVICE, + routingUrl: DEFAULT_SUBGRAPH_URL_ONE, + labels: [grpcServiceLabel], + }); + expect(createBaseGrpcServiceResponse.response?.code).toBe(EnumStatusCode.OK); + + // Create feature subgraph based on GRPC service (replicating wgc feature-subgraph create) + const createFeatureSubgraphResponse = await client.createFederatedSubgraph({ + name: featureSubgraphName, + routingUrl: DEFAULT_SUBGRAPH_URL_TWO, + labels: [], + isFeatureSubgraph: true, + baseSubgraphName: baseGrpcServiceName, + }); + expect(createFeatureSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); + + // Try to publish feature subgraph without proto information + const publishFeatureSubgraphResponse = await client.publishFederatedSubgraph({ + name: featureSubgraphName, + schema: 'type Query { hello: String }', + type: SubgraphType.GRPC_SERVICE, + // Note: proto is missing - should fail + }); + + expect(publishFeatureSubgraphResponse.response?.code).toBe(EnumStatusCode.ERR); + expect(publishFeatureSubgraphResponse.response?.details).toBe( + 'The proto is required for plugin and grpc subgraphs.', + ); await server.close(); }); diff --git a/controlplane/test/subgraph/create-subgraph.test.ts b/controlplane/test/subgraph/create-subgraph.test.ts index 86bdee355a..1297ecd137 100644 --- a/controlplane/test/subgraph/create-subgraph.test.ts +++ b/controlplane/test/subgraph/create-subgraph.test.ts @@ -875,4 +875,284 @@ describe('Create subgraph tests', () => { await server.close(); }); }); + + describe('GRPC Service subgraph creation tests', () => { + test('Should be able to create a GRPC service subgraph', async () => { + const { client, server } = await SetupTest({ + dbname, + }); + + const grpcServiceName = genID('grpc-service'); + const grpcServiceLabel = genUniqueLabel('service'); + + const createGrpcServiceSubgraphResp = await client.createFederatedSubgraph({ + name: grpcServiceName, + namespace: DEFAULT_NAMESPACE, + type: SubgraphType.GRPC_SERVICE, + routingUrl: DEFAULT_SUBGRAPH_URL_ONE, + labels: [grpcServiceLabel], + }); + + expect(createGrpcServiceSubgraphResp.response?.code).toBe(EnumStatusCode.OK); + + // Validate that the subgraph was created with the correct type + const getSubgraphResp = await client.getSubgraphByName({ + name: grpcServiceName, + namespace: DEFAULT_NAMESPACE, + }); + + expect(getSubgraphResp.response?.code).toBe(EnumStatusCode.OK); + expect(getSubgraphResp.graph).toBeDefined(); + expect(getSubgraphResp.graph?.name).toBe(grpcServiceName); + expect(getSubgraphResp.graph?.type).toBe(SubgraphType.GRPC_SERVICE); + expect(getSubgraphResp.graph?.routingURL).toBe(DEFAULT_SUBGRAPH_URL_ONE); + + await server.close(); + }); + + test('Should not allow creating a GRPC service subgraph without a routing URL', async () => { + const { client, server } = await SetupTest({ + dbname, + }); + + const grpcServiceName = genID('grpc-service'); + const grpcServiceLabel = genUniqueLabel('service'); + + const createGrpcServiceSubgraphResp = await client.createFederatedSubgraph({ + name: grpcServiceName, + namespace: DEFAULT_NAMESPACE, + type: SubgraphType.GRPC_SERVICE, + labels: [grpcServiceLabel], + }); + + expect(createGrpcServiceSubgraphResp.response?.code).toBe(EnumStatusCode.ERR); + expect(createGrpcServiceSubgraphResp.response?.details).toBe( + 'A non-Event-Driven Graph must define a routing URL', + ); + + await server.close(); + }); + + test('Should not allow creating a GRPC service subgraph with invalid routing URL', async () => { + const { client, server } = await SetupTest({ + dbname, + }); + + const grpcServiceName = genID('grpc-service'); + const grpcServiceLabel = genUniqueLabel('service'); + + const createGrpcServiceSubgraphResp = await client.createFederatedSubgraph({ + name: grpcServiceName, + namespace: DEFAULT_NAMESPACE, + type: SubgraphType.GRPC_SERVICE, + routingUrl: 'invalid-url', + labels: [grpcServiceLabel], + }); + + expect(createGrpcServiceSubgraphResp.response?.code).toBe(EnumStatusCode.ERR); + expect(createGrpcServiceSubgraphResp.response?.details).toBe('Routing URL "invalid-url" is not a valid URL'); + + await server.close(); + }); + + test('Should not allow creating a GRPC service with the same name as a regular subgraph', async () => { + const { client, server } = await SetupTest({ + dbname, + }); + + const sharedName = genID('shared-subgraph'); + const regularLabel = genUniqueLabel('backend'); + const grpcServiceLabel = genUniqueLabel('grpc-service'); + + // First create a regular subgraph + const createRegularSubgraphResp = await client.createFederatedSubgraph({ + name: sharedName, + namespace: DEFAULT_NAMESPACE, + routingUrl: DEFAULT_SUBGRAPH_URL_ONE, + labels: [regularLabel], + }); + + expect(createRegularSubgraphResp.response?.code).toBe(EnumStatusCode.OK); + + // Try to create a GRPC service with the same name - should fail + const createGrpcServiceSubgraphResp = await client.createFederatedSubgraph({ + name: sharedName, + namespace: DEFAULT_NAMESPACE, + type: SubgraphType.GRPC_SERVICE, + routingUrl: DEFAULT_SUBGRAPH_URL_TWO, + labels: [grpcServiceLabel], + }); + + expect(createGrpcServiceSubgraphResp.response?.code).toBe(EnumStatusCode.ERR_ALREADY_EXISTS); + expect(createGrpcServiceSubgraphResp.response?.details).toBe( + `A subgraph with the name "${sharedName}" already exists in the namespace "${DEFAULT_NAMESPACE}".`, + ); + + await server.close(); + }); + + test('Should not allow creating a regular subgraph with the same name as a GRPC service', async () => { + const { client, server } = await SetupTest({ + dbname, + }); + + const sharedName = genID('shared-grpc-service'); + const grpcServiceLabel = genUniqueLabel('grpc-service'); + const regularLabel = genUniqueLabel('api'); + + // First create a GRPC service subgraph + const createGrpcServiceSubgraphResp = await client.createFederatedSubgraph({ + name: sharedName, + namespace: DEFAULT_NAMESPACE, + type: SubgraphType.GRPC_SERVICE, + routingUrl: DEFAULT_SUBGRAPH_URL_ONE, + labels: [grpcServiceLabel], + }); + + expect(createGrpcServiceSubgraphResp.response?.code).toBe(EnumStatusCode.OK); + + // Try to create a regular subgraph with the same name - should fail + const createRegularSubgraphResp = await client.createFederatedSubgraph({ + name: sharedName, + namespace: DEFAULT_NAMESPACE, + routingUrl: DEFAULT_SUBGRAPH_URL_TWO, + labels: [regularLabel], + }); + + expect(createRegularSubgraphResp.response?.code).toBe(EnumStatusCode.ERR_ALREADY_EXISTS); + expect(createRegularSubgraphResp.response?.details).toBe( + `A subgraph with the name "${sharedName}" already exists in the namespace "${DEFAULT_NAMESPACE}".`, + ); + + await server.close(); + }); + + test('Should not allow creating a GRPC service with the same name as a plugin', async () => { + const { client, server } = await SetupTest({ + dbname, + setupBilling: { plan: 'launch@1' }, + }); + + const sharedName = genID('shared-plugin-grpc'); + const pluginLabel = genUniqueLabel('plugin'); + const grpcServiceLabel = genUniqueLabel('grpc-service'); + + // First create a plugin subgraph + const createPluginSubgraphResp = await client.createFederatedSubgraph({ + name: sharedName, + namespace: DEFAULT_NAMESPACE, + type: SubgraphType.GRPC_PLUGIN, + labels: [pluginLabel], + }); + + expect(createPluginSubgraphResp.response?.code).toBe(EnumStatusCode.OK); + + // Try to create a GRPC service with the same name - should fail + const createGrpcServiceSubgraphResp = await client.createFederatedSubgraph({ + name: sharedName, + namespace: DEFAULT_NAMESPACE, + type: SubgraphType.GRPC_SERVICE, + routingUrl: DEFAULT_SUBGRAPH_URL_ONE, + labels: [grpcServiceLabel], + }); + + expect(createGrpcServiceSubgraphResp.response?.code).toBe(EnumStatusCode.ERR_ALREADY_EXISTS); + expect(createGrpcServiceSubgraphResp.response?.details).toBe( + `A subgraph with the name "${sharedName}" already exists in the namespace "${DEFAULT_NAMESPACE}".`, + ); + + await server.close(); + }); + + test.each(['organization-admin', 'organization-developer', 'subgraph-admin'])( + '%s should be able to create GRPC service subgraphs', + async (role) => { + const { client, server, users, authenticator } = await SetupTest({ + dbname, + enableMultiUsers: true, + enabledFeatures: ['rbac'], + }); + + const grpcServiceName = genID('grpc-service'); + const grpcServiceLabel = genUniqueLabel('service'); + + authenticator.changeUserWithSuppliedContext({ + ...users[TestUser.adminAliceCompanyA], + rbac: createTestRBACEvaluator(createTestGroup({ role: role as OrganizationRole })), + }); + + const createGrpcServiceSubgraphResp = await client.createFederatedSubgraph({ + name: grpcServiceName, + namespace: DEFAULT_NAMESPACE, + type: SubgraphType.GRPC_SERVICE, + routingUrl: DEFAULT_SUBGRAPH_URL_ONE, + labels: [grpcServiceLabel], + }); + + expect(createGrpcServiceSubgraphResp.response?.code).toBe(EnumStatusCode.OK); + + await server.close(); + }, + ); + + test.each([ + 'organization-apikey-manager', + 'organization-viewer', + 'namespace-admin', + 'namespace-viewer', + 'graph-admin', + 'graph-viewer', + 'subgraph-publisher', + 'subgraph-viewer', + ])('%s should not be able to create GRPC service subgraphs', async (role) => { + const { client, server, users, authenticator } = await SetupTest({ + dbname, + enableMultiUsers: true, + enabledFeatures: ['rbac'], + }); + + const grpcServiceName = genID('grpc-service'); + const grpcServiceLabel = genUniqueLabel('service'); + + authenticator.changeUserWithSuppliedContext({ + ...users[TestUser.adminAliceCompanyA], + rbac: createTestRBACEvaluator(createTestGroup({ role: role as OrganizationRole })), + }); + + const createGrpcServiceSubgraphResp = await client.createFederatedSubgraph({ + name: grpcServiceName, + namespace: DEFAULT_NAMESPACE, + type: SubgraphType.GRPC_SERVICE, + routingUrl: DEFAULT_SUBGRAPH_URL_ONE, + labels: [grpcServiceLabel], + }); + + expect(createGrpcServiceSubgraphResp.response?.code).toBe(EnumStatusCode.ERROR_NOT_AUTHORIZED); + + await server.close(); + }); + + test('Should be able to create GRPC service subgraphs with multiple labels', async () => { + const { client, server } = await SetupTest({ + dbname, + }); + + const grpcServiceName = genID('multi-label-grpc-service'); + const envLabel = genUniqueLabel('env'); + const teamLabel = genUniqueLabel('team'); + const typeLabel = genUniqueLabel('type'); + + const createGrpcServiceSubgraphResp = await client.createFederatedSubgraph({ + name: grpcServiceName, + namespace: DEFAULT_NAMESPACE, + type: SubgraphType.GRPC_SERVICE, + routingUrl: DEFAULT_SUBGRAPH_URL_ONE, + labels: [envLabel, teamLabel, typeLabel], + }); + + expect(createGrpcServiceSubgraphResp.response?.code).toBe(EnumStatusCode.OK); + + await server.close(); + }); + }); }); diff --git a/controlplane/test/subgraph/publish-subgraph.test.ts b/controlplane/test/subgraph/publish-subgraph.test.ts index 0e868fd246..0f2c79342d 100644 --- a/controlplane/test/subgraph/publish-subgraph.test.ts +++ b/controlplane/test/subgraph/publish-subgraph.test.ts @@ -12,7 +12,14 @@ import { genID, genUniqueLabel, } from '../../src/core/test-util.js'; -import { createEventDrivenGraph, createSubgraph, DEFAULT_NAMESPACE, eventDrivenGraphSDL, SetupTest, subgraphSDL } from '../test-util.js'; +import { + createEventDrivenGraph, + createSubgraph, + DEFAULT_NAMESPACE, + eventDrivenGraphSDL, + SetupTest, + subgraphSDL, +} from '../test-util.js'; // Read the actual proto, mapping and lock files const testDataPath = path.join(process.cwd(), 'test/test-data/plugin'); @@ -34,6 +41,19 @@ async function createPluginSubgraph(client: any, name: string, namespace = 'defa return response; } +async function createGrpcServiceSubgraph(client: any, name: string, routingUrl: string, namespace = 'default') { + const grpcServiceLabel = genUniqueLabel('grpc-service'); + const response = await client.createFederatedSubgraph({ + name, + namespace, + type: SubgraphType.GRPC_SERVICE, + routingUrl, + labels: [grpcServiceLabel], + }); + expect(response.response?.code).toBe(EnumStatusCode.OK); + return response; +} + describe('Publish subgraph tests', () => { beforeAll(async () => { dbname = await beforeAllSetup(); @@ -583,7 +603,7 @@ describe('Publish subgraph tests', () => { }); expect(publishResponse.response?.code).toBe(EnumStatusCode.ERR); - expect(publishResponse.response?.details).toBe('The proto is required for plugin subgraphs.'); + expect(publishResponse.response?.details).toBe('The proto is required for plugin and grpc subgraphs.'); await server.close(); }); @@ -776,4 +796,341 @@ describe('Publish subgraph tests', () => { }, ); }); + + describe('GRPC Service subgraph publish tests', () => { + const grpcServiceSDL = ` + type Query { + grpcServiceHello: String! + } + `; + + const validGrpcProtoRequest = { + schema: pluginSchema, + mappings: pluginMappings, + lock: pluginLock, + }; + + test('Should be able to publish an existing GRPC service subgraph', async () => { + const { client, server } = await SetupTest({ + dbname, + }); + + const grpcServiceName = genID('grpc-service'); + const routingUrl = 'http://localhost:4001'; + + // First create the GRPC service subgraph + await createGrpcServiceSubgraph(client, grpcServiceName, routingUrl); + + // Then publish to it + const publishResponse = await client.publishFederatedSubgraph({ + name: grpcServiceName, + namespace: 'default', + schema: grpcServiceSDL, + type: SubgraphType.GRPC_SERVICE, + proto: validGrpcProtoRequest, + }); + + expect(publishResponse.response?.code).toBe(EnumStatusCode.OK); + + // Validate by fetching the subgraph and checking type + const getSubgraphResponse = await client.getSubgraphByName({ + name: grpcServiceName, + namespace: 'default', + }); + + expect(getSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); + expect(getSubgraphResponse.graph?.type).toBe(SubgraphType.GRPC_SERVICE); + expect(getSubgraphResponse.graph?.routingURL).toBe(routingUrl); + + await server.close(); + }); + + test('Should be able to create and publish a GRPC service subgraph in one step when service does not exist', async () => { + const { client, server } = await SetupTest({ + dbname, + }); + + const grpcServiceName = genID('grpc-service'); + const routingUrl = 'http://localhost:4001'; + + // Publish to a non-existent GRPC service subgraph (should create and publish) + const publishResponse = await client.publishFederatedSubgraph({ + name: grpcServiceName, + namespace: 'default', + schema: grpcServiceSDL, + type: SubgraphType.GRPC_SERVICE, + routingUrl, + proto: validGrpcProtoRequest, + labels: [genUniqueLabel('grpc-service')], + }); + + expect(publishResponse.response?.code).toBe(EnumStatusCode.OK); + + // Validate by fetching the subgraph and checking type and routing URL + const getSubgraphResponse = await client.getSubgraphByName({ + name: grpcServiceName, + namespace: 'default', + }); + + expect(getSubgraphResponse.response?.code).toBe(EnumStatusCode.OK); + expect(getSubgraphResponse.graph?.type).toBe(SubgraphType.GRPC_SERVICE); + expect(getSubgraphResponse.graph?.routingURL).toBe(routingUrl); + + await server.close(); + }); + + test('Should fail when trying to publish a GRPC service with same name as existing regular subgraph', async () => { + const { client, server } = await SetupTest({ + dbname, + }); + + const subgraphName = genID('subgraph'); + + // First create a regular subgraph + await createSubgraph(client, subgraphName, 'http://localhost:4001'); + + // Try to publish a GRPC service with the same name + const publishResponse = await client.publishFederatedSubgraph({ + name: subgraphName, + namespace: 'default', + schema: grpcServiceSDL, + type: SubgraphType.GRPC_SERVICE, + routingUrl: 'http://localhost:4002', + proto: validGrpcProtoRequest, + labels: [genUniqueLabel('grpc-service')], + }); + + expect(publishResponse.response?.code).toBe(EnumStatusCode.ERR); + expect(publishResponse.response?.details).toContain(`Subgraph ${subgraphName} is not of type grpc_service`); + + await server.close(); + }); + + test('Should fail when trying to publish a GRPC service with same name as existing plugin', async () => { + const { client, server } = await SetupTest({ + dbname, + setupBilling: { plan: 'launch@1' }, + }); + + const pluginName = genID('plugin'); + + // First create a plugin subgraph + await createPluginSubgraph(client, pluginName); + + // Try to publish a GRPC service with the same name + const publishResponse = await client.publishFederatedSubgraph({ + name: pluginName, + namespace: 'default', + schema: grpcServiceSDL, + type: SubgraphType.GRPC_SERVICE, + routingUrl: 'http://localhost:4001', + proto: validGrpcProtoRequest, + labels: [genUniqueLabel('grpc-service')], + }); + + expect(publishResponse.response?.code).toBe(EnumStatusCode.ERR); + expect(publishResponse.response?.details).toContain(`Subgraph ${pluginName} is a plugin. Please use the 'wgc router plugin publish' command to publish the plugin.`); + + await server.close(); + }); + + test('Should fail when trying to publish a GRPC service with STANDARD type', async () => { + const { client, server } = await SetupTest({ + dbname, + }); + + const grpcServiceName = genID('grpc-service'); + const routingUrl = 'http://localhost:4001'; + + // First create a GRPC service subgraph + await createGrpcServiceSubgraph(client, grpcServiceName, routingUrl); + + const publishResponse = await client.publishFederatedSubgraph({ + name: grpcServiceName, + namespace: 'default', + schema: subgraphSDL, + routingUrl, + type: SubgraphType.STANDARD, + }); + + expect(publishResponse.response?.code).toBe(EnumStatusCode.ERR); + expect(publishResponse.response?.details).toContain( + `Subgraph ${grpcServiceName} is a grpc service. Please use the 'wgc grpc-service publish' command to publish the grpc service.`, + ); + + await server.close(); + }); + + test('Should fail to publish GRPC service without required proto information', async () => { + const { client, server } = await SetupTest({ + dbname, + }); + + const grpcServiceName = genID('grpc-service'); + const routingUrl = 'http://localhost:4001'; + + // Try to publish without proto + const publishResponse = await client.publishFederatedSubgraph({ + name: grpcServiceName, + namespace: 'default', + schema: grpcServiceSDL, + type: SubgraphType.GRPC_SERVICE, + routingUrl, + }); + + expect(publishResponse.response?.code).toBe(EnumStatusCode.ERR); + expect(publishResponse.response?.details).toBe('The proto is required for plugin and grpc subgraphs.'); + + await server.close(); + }); + + test('Should fail to create and publish GRPC service without routing URL', async () => { + const { client, server } = await SetupTest({ + dbname, + }); + + const grpcServiceName = genID('grpc-service'); + + // Try to publish without routing URL + const publishResponse = await client.publishFederatedSubgraph({ + name: grpcServiceName, + namespace: 'default', + schema: grpcServiceSDL, + type: SubgraphType.GRPC_SERVICE, + proto: validGrpcProtoRequest, + labels: [genUniqueLabel('grpc-service')], + }); + + expect(publishResponse.response?.code).toBe(EnumStatusCode.ERR); + expect(publishResponse.response?.details).toBe( + 'A valid, non-empty routing URL is required to create and publish a non-Event-Driven subgraph.', + ); + + await server.close(); + }); + + test('Should fail to create and publish GRPC service with invalid routing URL', async () => { + const { client, server } = await SetupTest({ + dbname, + }); + + const grpcServiceName = genID('grpc-service'); + + // Try to publish with invalid routing URL + const publishResponse = await client.publishFederatedSubgraph({ + name: grpcServiceName, + namespace: 'default', + schema: grpcServiceSDL, + type: SubgraphType.GRPC_SERVICE, + routingUrl: 'invalid-url', + proto: validGrpcProtoRequest, + labels: [genUniqueLabel('grpc-service')], + }); + + expect(publishResponse.response?.code).toBe(EnumStatusCode.ERR); + expect(publishResponse.response?.details).toBe('Routing URL "invalid-url" is not a valid URL.'); + + await server.close(); + }); + + test.each(['organization-admin', 'organization-developer', 'subgraph-admin'])( + '%s should be able to create and publish GRPC service subgraph', + async (role) => { + const { client, server, authenticator, users } = await SetupTest({ + dbname, + }); + + const grpcServiceName = genID('grpc-service'); + const routingUrl = 'http://localhost:4001'; + + authenticator.changeUserWithSuppliedContext({ + ...users.adminAliceCompanyA, + rbac: createTestRBACEvaluator(createTestGroup({ role })), + }); + + const publishResponse = await client.publishFederatedSubgraph({ + name: grpcServiceName, + namespace: 'default', + schema: grpcServiceSDL, + type: SubgraphType.GRPC_SERVICE, + routingUrl, + proto: validGrpcProtoRequest, + labels: [genUniqueLabel('grpc-service')], + }); + + expect(publishResponse.response?.code).toBe(EnumStatusCode.OK); + + await server.close(); + }, + ); + + test.each([ + 'organization-apikey-manager', + 'organization-viewer', + 'namespace-admin', + 'namespace-viewer', + 'graph-admin', + 'graph-viewer', + 'subgraph-publisher', + 'subgraph-viewer', + ])('%s should not be able to create and publish GRPC service subgraph', async (role) => { + const { client, server, authenticator, users } = await SetupTest({ + dbname, + }); + + const grpcServiceName = genID('grpc-service'); + const routingUrl = 'http://localhost:4001'; + + authenticator.changeUserWithSuppliedContext({ + ...users.adminAliceCompanyA, + rbac: createTestRBACEvaluator(createTestGroup({ role })), + }); + + const publishResponse = await client.publishFederatedSubgraph({ + name: grpcServiceName, + namespace: 'default', + schema: grpcServiceSDL, + type: SubgraphType.GRPC_SERVICE, + routingUrl, + proto: validGrpcProtoRequest, + labels: [genUniqueLabel('grpc-service')], + }); + + expect(publishResponse.response?.code).toBe(EnumStatusCode.ERROR_NOT_AUTHORIZED); + + await server.close(); + }); + + test.each(['organization-admin', 'organization-developer', 'subgraph-admin', 'subgraph-publisher'])( + '%s should be able to publish to existing GRPC service subgraph', + async (role) => { + const { client, server, authenticator, users } = await SetupTest({ + dbname, + }); + + const grpcServiceName = genID('grpc-service'); + const routingUrl = 'http://localhost:4001'; + + // First create the GRPC service subgraph + await createGrpcServiceSubgraph(client, grpcServiceName, routingUrl); + + authenticator.changeUserWithSuppliedContext({ + ...users.adminAliceCompanyA, + rbac: createTestRBACEvaluator(createTestGroup({ role })), + }); + + const publishResponse = await client.publishFederatedSubgraph({ + name: grpcServiceName, + namespace: 'default', + schema: grpcServiceSDL, + type: SubgraphType.GRPC_SERVICE, + proto: validGrpcProtoRequest, + }); + + expect(publishResponse.response?.code).toBe(EnumStatusCode.OK); + + await server.close(); + }, + ); + }); }); diff --git a/controlplane/test/test-data/grpc-service/mapping.json b/controlplane/test/test-data/grpc-service/mapping.json new file mode 100644 index 0000000000..7a6b8fbc15 --- /dev/null +++ b/controlplane/test/test-data/grpc-service/mapping.json @@ -0,0 +1,184 @@ +{ + "version": 1, + "service": "UserService", + "operationMappings": [ + { + "type": "OPERATION_TYPE_QUERY", + "original": "users", + "mapped": "QueryUsers", + "request": "QueryUsersRequest", + "response": "QueryUsersResponse" + }, + { + "type": "OPERATION_TYPE_QUERY", + "original": "user", + "mapped": "QueryUser", + "request": "QueryUserRequest", + "response": "QueryUserResponse" + }, + { + "type": "OPERATION_TYPE_MUTATION", + "original": "createUser", + "mapped": "MutationCreateUser", + "request": "MutationCreateUserRequest", + "response": "MutationCreateUserResponse" + }, + { + "type": "OPERATION_TYPE_MUTATION", + "original": "updateUser", + "mapped": "MutationUpdateUser", + "request": "MutationUpdateUserRequest", + "response": "MutationUpdateUserResponse" + } + ], + "entityMappings": [ + { + "typeName": "User", + "kind": "entity", + "key": "id", + "rpc": "LookupUserById", + "request": "LookupUserByIdRequest", + "response": "LookupUserByIdResponse" + } + ], + "typeFieldMappings": [ + { + "type": "Query", + "fieldMappings": [ + { + "original": "users", + "mapped": "users", + "argumentMappings": [] + }, + { + "original": "user", + "mapped": "user", + "argumentMappings": [ + { + "original": "id", + "mapped": "id" + } + ] + } + ] + }, + { + "type": "Mutation", + "fieldMappings": [ + { + "original": "createUser", + "mapped": "create_user", + "argumentMappings": [ + { + "original": "user", + "mapped": "user" + } + ] + }, + { + "original": "updateUser", + "mapped": "update_user", + "argumentMappings": [ + { + "original": "id", + "mapped": "id" + }, + { + "original": "user", + "mapped": "user" + } + ] + } + ] + }, + { + "type": "User", + "fieldMappings": [ + { + "original": "id", + "mapped": "id", + "argumentMappings": [] + }, + { + "original": "name", + "mapped": "name", + "argumentMappings": [] + }, + { + "original": "email", + "mapped": "email", + "argumentMappings": [] + }, + { + "original": "phone", + "mapped": "phone", + "argumentMappings": [] + }, + { + "original": "status", + "mapped": "status", + "argumentMappings": [] + }, + { + "original": "bio", + "mapped": "bio", + "argumentMappings": [] + }, + { + "original": "tags", + "mapped": "tags", + "argumentMappings": [] + } + ] + }, + { + "type": "UserInput", + "fieldMappings": [ + { + "original": "name", + "mapped": "name", + "argumentMappings": [] + }, + { + "original": "email", + "mapped": "email", + "argumentMappings": [] + }, + { + "original": "phone", + "mapped": "phone", + "argumentMappings": [] + }, + { + "original": "status", + "mapped": "status", + "argumentMappings": [] + }, + { + "original": "bio", + "mapped": "bio", + "argumentMappings": [] + } + ] + } + ], + "enumMappings": [ + { + "type": "UserStatus", + "values": [ + { + "original": "ACTIVE", + "mapped": "USER_STATUS_ACTIVE" + }, + { + "original": "INACTIVE", + "mapped": "USER_STATUS_INACTIVE" + }, + { + "original": "SUSPENDED", + "mapped": "USER_STATUS_SUSPENDED" + } + ] + } + ] +} diff --git a/controlplane/test/test-data/grpc-service/service.proto b/controlplane/test/test-data/grpc-service/service.proto new file mode 100644 index 0000000000..3576cd0773 --- /dev/null +++ b/controlplane/test/test-data/grpc-service/service.proto @@ -0,0 +1,97 @@ +syntax = "proto3"; +package service; + +option go_package = "github.com/wundergraph/cosmo/test/grpc-service"; + +import "google/protobuf/wrappers.proto"; + +// Service definition for UserService +service UserService { + // Lookup User entity by id + rpc LookupUserById(LookupUserByIdRequest) returns (LookupUserByIdResponse) {} + rpc QueryUsers(QueryUsersRequest) returns (QueryUsersResponse) {} + rpc QueryUser(QueryUserRequest) returns (QueryUserResponse) {} + rpc MutationCreateUser(MutationCreateUserRequest) returns (MutationCreateUserResponse) {} + rpc MutationUpdateUser(MutationUpdateUserRequest) returns (MutationUpdateUserResponse) {} +} + +// Key message for User entity lookup +message LookupUserByIdRequestKey { + // Key field for User entity lookup. + string id = 1; +} + +// Request message for User entity lookup. +message LookupUserByIdRequest { + repeated LookupUserByIdRequestKey keys = 1; +} + +// Response message for User entity lookup. +message LookupUserByIdResponse { + repeated User result = 1; +} + +// Request message for users operation. +message QueryUsersRequest { +} + +// Response message for users operation. +message QueryUsersResponse { + repeated User users = 1; +} + +// Request message for user operation. +message QueryUserRequest { + string id = 1; +} + +// Response message for user operation. +message QueryUserResponse { + User user = 1; +} + +// Request message for createUser operation. +message MutationCreateUserRequest { + UserInput user = 1; +} + +// Response message for createUser operation. +message MutationCreateUserResponse { + User create_user = 1; +} + +// Request message for updateUser operation. +message MutationUpdateUserRequest { + string id = 1; + UserInput user = 2; +} + +// Response message for updateUser operation. +message MutationUpdateUserResponse { + User update_user = 1; +} + +message User { + string id = 1; + string name = 2; + string email = 3; + google.protobuf.StringValue phone = 4; + UserStatus status = 5; + google.protobuf.StringValue bio = 6; + repeated string tags = 7; +} + +message UserInput { + string name = 1; + string email = 2; + google.protobuf.StringValue phone = 3; + UserStatus status = 4; + google.protobuf.StringValue bio = 5; +} + +enum UserStatus { + USER_STATUS_UNSPECIFIED = 0; + USER_STATUS_ACTIVE = 1; + USER_STATUS_INACTIVE = 2; + USER_STATUS_SUSPENDED = 3; +} diff --git a/controlplane/test/test-data/grpc-service/service.proto.lock.json b/controlplane/test/test-data/grpc-service/service.proto.lock.json new file mode 100644 index 0000000000..42766377b2 --- /dev/null +++ b/controlplane/test/test-data/grpc-service/service.proto.lock.json @@ -0,0 +1,85 @@ +{ + "version": "1.0.0", + "messages": { + "LookupUserByIdRequestKey": { + "fields": { + "id": 1 + } + }, + "LookupUserByIdRequest": { + "fields": { + "keys": 1 + } + }, + "LookupUserByIdResponse": { + "fields": { + "result": 1 + } + }, + "QueryUsersResponse": { + "fields": { + "users": 1 + } + }, + "QueryUserRequest": { + "fields": { + "id": 1 + } + }, + "QueryUserResponse": { + "fields": { + "user": 1 + } + }, + "MutationCreateUserRequest": { + "fields": { + "user": 1 + } + }, + "MutationCreateUserResponse": { + "fields": { + "create_user": 1 + } + }, + "MutationUpdateUserRequest": { + "fields": { + "id": 1, + "user": 2 + } + }, + "MutationUpdateUserResponse": { + "fields": { + "update_user": 1 + } + }, + "User": { + "fields": { + "id": 1, + "name": 2, + "email": 3, + "phone": 4, + "status": 5, + "bio": 6, + "tags": 7 + } + }, + "UserInput": { + "fields": { + "name": 1, + "email": 2, + "phone": 3, + "status": 4, + "bio": 5 + } + } + }, + "enums": { + "UserStatus": { + "fields": { + "ACTIVE": 1, + "INACTIVE": 2, + "SUSPENDED": 3 + } + } + } +}