diff --git a/cli/e2e/smoke.test.ts b/cli/e2e/smoke.test.ts index 49aa371c3f..6158b710f4 100644 --- a/cli/e2e/smoke.test.ts +++ b/cli/e2e/smoke.test.ts @@ -27,7 +27,7 @@ describe('Bun CLI', () => { expect(exitCode, `exited with non-zero:\nstdout:\n${stdout.toString()}\n\nstderr:\n${stderr.toString()}`).toBe(0); - expect(stdout.toString()).toContain('Router config successfully written'); + expect(stdout.toString()).toContain('Router execution config successfully written to'); }); }); @@ -52,6 +52,6 @@ describe('Node CLI', () => { expect(exitCode, `exited with non-zero:\nstdout:\n${stdout.toString()}\n\nstderr:\n${stderr.toString()}`).toBe(0); - expect(stdout.toString()).toContain('Router config successfully written'); + expect(stdout.toString()).toContain('Router execution config successfully written to'); }); }); diff --git a/cli/src/commands/auth/utils.ts b/cli/src/commands/auth/utils.ts index a516e37f87..da4a8e9ff8 100644 --- a/cli/src/commands/auth/utils.ts +++ b/cli/src/commands/auth/utils.ts @@ -24,11 +24,14 @@ export interface KeycloakTokenResponse { refresh_expires_in: number; } +export type GraphTokenFeature = 'split-config-loading'; + export interface GraphToken { iss?: string; iat?: number; federated_graph_id: string; organization_id: string; + features?: GraphTokenFeature[]; } export interface DecodedAccessToken { diff --git a/cli/src/commands/graph/federated-graph/commands/fetch.ts b/cli/src/commands/graph/federated-graph/commands/fetch.ts index 2c08fa22ff..ba8f8a05eb 100644 --- a/cli/src/commands/graph/federated-graph/commands/fetch.ts +++ b/cli/src/commands/graph/federated-graph/commands/fetch.ts @@ -1,10 +1,21 @@ -import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; +import { mkdir, readdir, writeFile } from 'node:fs/promises'; import { Command } from 'commander'; import yaml from 'js-yaml'; import { join, resolve } from 'pathe'; import pc from 'picocolors'; import { BaseCommandOptions } from '../../../../core/types/types.js'; -import { fetchRouterConfig, getFederatedGraphSchemas, getSubgraphSDL, getSubgraphsOfFedGraph } from '../utils.js'; +import { getFederatedGraphSchemas, getSubgraphSDL, getSubgraphsOfFedGraph } from '../utils.js'; +import { featureFlagsDir, fetchRouterConfig, writeFeatureFlagConfigToFile } from '../../../router/utils.js'; + +const cosmoConfigFile = 'cosmoConfig.json'; +const cosmoMapperFile = 'cosmo-mapper.json'; +const cosmoSchemaFile = 'cosmoSchema.graphql'; +const cosmoClientSchemaFile = 'cosmoClientSchema.graphql'; +const cosmoCompositionFile = 'cosmo-composition.yaml'; +const roverCompositionFile = 'rover-composition.yaml'; +const apolloSchemaFile = 'apolloSchema.graphql'; +const apolloScriptFile = 'apollo.sh'; export default (opts: BaseCommandOptions) => { const cmd = new Command('fetch'); @@ -29,19 +40,31 @@ export default (opts: BaseCommandOptions) => { namespace: options.namespace, }); - const basePath = resolve(options.out, `${name}${options.namespace ? `-${options.namespace}` : ''}`); + let basePath = options.out ? resolve(options.out) : resolve(); + basePath = join(basePath, `${name}-${options.namespace || 'default'}`); + if (!existsSync(basePath)) { + await mkdir(basePath, { recursive: true }); + } + + const entries = await readdir(basePath); + if (entries.length > 0) { + console.log(pc.red(`Output directory "${basePath}" is not empty. Please provide an empty directory path.`)); + process.exitCode = 1; + return; + } + const superGraphPath = join(basePath, '/supergraph/'); const subgraphPath = join(basePath, '/subgraphs/'); const scriptsPath = join(basePath, '/scripts/'); if (!existsSync(superGraphPath)) { - mkdirSync(superGraphPath, { recursive: true }); + await mkdir(superGraphPath, { recursive: true }); } if (!existsSync(subgraphPath)) { - mkdirSync(subgraphPath, { recursive: true }); + await mkdir(subgraphPath, { recursive: true }); } if (!existsSync(scriptsPath) && options.apolloCompatibility) { - mkdirSync(scriptsPath, { recursive: true }); + await mkdir(scriptsPath, { recursive: true }); } const routerConfig = await fetchRouterConfig({ @@ -49,12 +72,27 @@ export default (opts: BaseCommandOptions) => { name, namespace: options.namespace, }); - writeFileSync(join(superGraphPath, `cosmoConfig.json`), routerConfig); + await writeFile(join(superGraphPath, cosmoConfigFile), routerConfig.routerConfig); + if (routerConfig.mapper) { + // The mapper file is only available when `split-config-loading` is enabled + await writeFile(join(basePath, cosmoMapperFile), JSON.stringify(routerConfig.mapper)); + } - writeFileSync(join(superGraphPath, `cosmoSchema.graphql`), fedGraphSchemas.sdl); + if (routerConfig.featureFlags?.size) { + // Same as the mapper, feature flags are only available when `split-config-loading` is enabled + const featureFlagsPath = join(basePath, featureFlagsDir); + if (!existsSync(featureFlagsPath)) { + await mkdir(featureFlagsPath); + } + for (const [featureFlagName, featureFlagConfig] of routerConfig.featureFlags) { + await writeFeatureFlagConfigToFile(featureFlagsPath, featureFlagName, featureFlagConfig); + } + } + + await writeFile(join(superGraphPath, cosmoSchemaFile), fedGraphSchemas.sdl); if (fedGraphSchemas.clientSchema) { - writeFileSync(join(superGraphPath, `cosmoClientSchema.graphql`), fedGraphSchemas.clientSchema); + await writeFile(join(superGraphPath, cosmoClientSchemaFile), fedGraphSchemas.clientSchema); } const subgraphs = await getSubgraphsOfFedGraph({ client: opts.client, name, namespace: options.namespace }); @@ -125,14 +163,14 @@ export default (opts: BaseCommandOptions) => { }; } } - writeFileSync(filePath, subgraphSDL); + await writeFile(filePath, subgraphSDL); } const cosmoCompositionConfig = yaml.dump({ version: 1, subgraphs: cosmoSubgraphsConfig, }); - writeFileSync(join(basePath, `cosmo-composition.yaml`), cosmoCompositionConfig); + await writeFile(join(basePath, cosmoCompositionFile), cosmoCompositionConfig); if (options.apolloCompatibility) { const roverCompositionConfig = yaml.dump({ @@ -156,15 +194,14 @@ export default (opts: BaseCommandOptions) => { }, }, }); - writeFileSync(join(basePath, `rover-composition.yaml`), roverCompositionConfig); + + const absRoverCompositionFile = join(basePath, roverCompositionFile); + await writeFile(absRoverCompositionFile, roverCompositionConfig); const apolloScript = `npm install -g @apollo/rover -rover supergraph compose --config '${join(basePath, `rover-composition.yaml`)}' --output '${join( - superGraphPath, - 'apolloSchema.graphql', - )}' +rover supergraph compose --config '${absRoverCompositionFile}' --output '${join(superGraphPath, apolloSchemaFile)}' `; - writeFileSync(join(scriptsPath, `apollo.sh`), apolloScript); + await writeFile(join(scriptsPath, apolloScriptFile), apolloScript); } console.log( diff --git a/cli/src/commands/graph/federated-graph/utils.ts b/cli/src/commands/graph/federated-graph/utils.ts index cfbb7f734d..a9f074d79c 100644 --- a/cli/src/commands/graph/federated-graph/utils.ts +++ b/cli/src/commands/graph/federated-graph/utils.ts @@ -1,71 +1,8 @@ import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; import { Subgraph as ProtoSubgraph } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; -import { program } from 'commander'; -import jwtDecode from 'jwt-decode'; import pc from 'picocolors'; import { Client } from '../../../core/client/client.js'; -import { config, getBaseHeaders } from '../../../core/config.js'; -import { GraphToken } from '../../auth/utils.js'; - -export const fetchRouterConfig = async ({ - client, - name, - namespace, -}: { - client: Client; - name: string; - namespace?: string; -}) => { - const resp = await client.platform.generateRouterToken( - { - fedGraphName: name, - namespace, - }, - { - headers: getBaseHeaders(), - }, - ); - - if (resp.response?.code !== EnumStatusCode.OK) { - throw new Error( - `${pc.red(`Could not fetch the router config for the graph ${pc.bold(name)}`)} \n${pc.red( - pc.bold(resp.response?.details || ''), - )}`, - ); - } - - let decoded: GraphToken; - - try { - decoded = jwtDecode(resp.token); - } catch { - program.error('Could not fetch the router config. Please try again'); - } - - const requestBody = JSON.stringify({ - Version: '', - }); - - const headers = new Headers(); - headers.append('Content-Type', 'application/json; charset=UTF-8'); - headers.append('Authorization', 'Bearer ' + resp.token); - headers.append('Accept-Encoding', 'gzip'); - - const url = new URL( - `/${decoded.organization_id}/${decoded.federated_graph_id}/routerconfigs/latest.json`, - config.cdnURL, - ); - - const response = await fetch(url, { - method: 'POST', - headers, - body: requestBody, - }); - - const routerConfig = await response.text(); - - return routerConfig; -}; +import { getBaseHeaders } from '../../../core/config.js'; export interface Subgraph { name: string; diff --git a/cli/src/commands/mcp/tools/federated-graph-tools.ts b/cli/src/commands/mcp/tools/federated-graph-tools.ts index 291062c22e..9db3b2f354 100644 --- a/cli/src/commands/mcp/tools/federated-graph-tools.ts +++ b/cli/src/commands/mcp/tools/federated-graph-tools.ts @@ -1,11 +1,8 @@ import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; import { z } from 'zod'; import { getBaseHeaders } from '../../../core/config.js'; -import { - fetchRouterConfig, - getFederatedGraphSchemas, - getSubgraphsOfFedGraph, -} from '../../graph/federated-graph/utils.js'; +import { getFederatedGraphSchemas, getSubgraphsOfFedGraph } from '../../graph/federated-graph/utils.js'; +import { fetchRouterConfig } from '../../router/utils.js'; import { ToolContext } from './types.js'; export const registerFederatedGraphTools = ({ server, opts }: ToolContext) => { @@ -121,14 +118,14 @@ export const registerFederatedGraphTools = ({ server, opts }: ToolContext) => { }, async ({ name, namespace }) => { try { - const routerConfig = await fetchRouterConfig({ + const result = await fetchRouterConfig({ client: opts.client, name, namespace, }); return { - content: [{ type: 'text', text: routerConfig }], + content: [{ type: 'text', text: result.routerConfig }], }; } catch (e: any) { throw new Error(`Failed to fetch router config: ${e.message}`); diff --git a/cli/src/commands/router/commands/compose.ts b/cli/src/commands/router/commands/compose.ts index 5f9790d2e6..418b8ffa38 100644 --- a/cli/src/commands/router/commands/compose.ts +++ b/cli/src/commands/router/commands/compose.ts @@ -1,5 +1,6 @@ import { existsSync } from 'node:fs'; -import { readFile, writeFile } from 'node:fs/promises'; +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; import { buildRouterConfig, type ComposedSubgraph, @@ -8,114 +9,54 @@ import { normalizeURL, type RouterSubgraph, SubgraphKind, - type SubscriptionProtocol, - type WebsocketSubprotocol, } from '@wundergraph/cosmo-shared'; import semver from 'semver'; import { Command, program } from 'commander'; import { parse, printSchema } from 'graphql'; import * as yaml from 'js-yaml'; -import { basename, dirname, resolve } from 'pathe'; +import { basename, dirname, resolve, join } from 'pathe'; import pc from 'picocolors'; import { printSchemaWithDirectives } from '@graphql-tools/utils'; import { FeatureFlagRouterExecutionConfig, FeatureFlagRouterExecutionConfigs, GRPCMapping, + RouterConfig, } from '@wundergraph/cosmo-connect/dist/node/v1/node_pb'; import Table from 'cli-table3'; import { FederationSuccess, ROUTER_COMPATIBILITY_VERSION_ONE } from '@wundergraph/composition'; import { BaseCommandOptions } from '../../../core/types/types.js'; import { composeSubgraphs, introspectSubgraph } from '../../../utils.js'; +import { + mapperFile, + routerConfigFile, + featureFlagsDir, + getRouterConfigOutputFile, + writeFeatureFlagConfigToFile, +} from '../utils.js'; +import { + Config, + ConfigSubgraph, + GRPCSubgraphConfig, + GRPCSubgraphMetadata, + StandardSubgraphConfig, + StandardSubgraphMetaData, + SubgraphMetaData, + SubgraphPluginConfig, + SubgraphPluginMetadata, +} from './types/types.js'; +import { HandleRouterConfigParams } from './types/params.js'; const STATIC_SCHEMA_VERSION_ID = '00000000-0000-0000-0000-000000000000'; -type ConfigSubgraph = StandardSubgraphConfig | SubgraphPluginConfig | GRPCSubgraphConfig; - -type StandardSubgraphConfig = { - name: string; - routing_url: string; - schema?: { - file: string; - }; - subscription?: { - url?: string; - protocol?: 'ws' | 'sse' | 'sse_post'; - websocketSubprotocol?: 'auto' | 'graphql-ws' | 'graphql-transport-ws'; - }; - introspection?: { - url: string; - headers?: { - [key: string]: string; - }; - raw?: boolean; - }; -}; - -type SubgraphPluginConfig = { - plugin: { - version: string; - path: string; - }; -}; - -type GRPCSubgraphConfig = { - name: string; - routing_url: string; - grpc: { - schema_file: string; - proto_file: string; - mapping_file: string; - }; -}; - -type SubgraphMetadata = StandardSubgraphMetaData | SubgraphPluginMetadata | GRPCSubgraphMetadata; - -type StandardSubgraphMetaData = { - kind: SubgraphKind.Standard; - name: string; - sdl: string; - routingUrl: string; - subscriptionUrl: string; - subscriptionProtocol: SubscriptionProtocol; - websocketSubprotocol: WebsocketSubprotocol; -}; - -type SubgraphPluginMetadata = { - kind: SubgraphKind.Plugin; - name: string; - sdl: string; - mapping: GRPCMapping; - protoSchema: string; - version: string; -}; - -type GRPCSubgraphMetadata = { - kind: SubgraphKind.GRPC; - name: string; - sdl: string; - routingUrl: string; - protoSchema: string; - mapping: GRPCMapping; -}; - -type Config = { - version: number; - feature_flags: { - name: string; - feature_graphs: (StandardSubgraphConfig & { subgraph_name: string })[]; - }[]; - subgraphs: ConfigSubgraph[]; -}; - -function constructRouterSubgraph(result: FederationSuccess, s: SubgraphMetadata, index: number): RouterSubgraph { +function constructRouterSubgraph(result: FederationSuccess, s: SubgraphMetaData, index: number): RouterSubgraph { const subgraphConfig = result.subgraphConfigBySubgraphName.get(s.name); const schema = subgraphConfig?.schema; const configurationDataByTypeName = subgraphConfig?.configurationDataByTypeName; const costs = subgraphConfig?.costs; if (s.kind === SubgraphKind.Standard) { - const composedSubgraph: ComposedSubgraph = { + return { kind: SubgraphKind.Standard, id: `${index}`, name: s.name, @@ -127,12 +68,11 @@ function constructRouterSubgraph(result: FederationSuccess, s: SubgraphMetadata, schema, configurationDataByTypeName, costs, - }; - return composedSubgraph; + } satisfies ComposedSubgraph; } if (s.kind === SubgraphKind.Plugin) { - const composedSubgraphPlugin: ComposedSubgraphPlugin = { + return { kind: SubgraphKind.Plugin, id: `${index}`, name: s.name, @@ -144,11 +84,10 @@ function constructRouterSubgraph(result: FederationSuccess, s: SubgraphMetadata, schema, configurationDataByTypeName, costs, - }; - return composedSubgraphPlugin; + } satisfies ComposedSubgraphPlugin; } - const composedSubgraphGRPC: ComposedSubgraphGRPC = { + return { kind: SubgraphKind.GRPC, id: `${index}`, name: s.name, @@ -159,11 +98,96 @@ function constructRouterSubgraph(result: FederationSuccess, s: SubgraphMetadata, schema, configurationDataByTypeName, costs, - }; - return composedSubgraphGRPC; + } satisfies ComposedSubgraphGRPC; } -export default (opts: BaseCommandOptions) => { +async function handleSplitRouterConfig({ + config, + inputFileLocation, + options, + routerConfig, + subgraphs, +}: HandleRouterConfigParams) { + let outputDir = options.out ? resolve(options.out) : options.out; + if (!outputDir) { + outputDir = resolve('router-compose-output'); + } + if (!existsSync(outputDir)) { + await mkdir(outputDir); + } + const entries = await readdir(outputDir); + if (entries.length > 0) { + console.log( + pc.red( + `Split-config flag enabled; output directory "${outputDir}" is not empty. Please provide an empty directory path.`, + ), + ); + process.exitCode = 1; + return; + } + + const routerConfigJSON = routerConfig.toJsonString(); + const mapper = new Map(); + mapper.set('', createHash('sha256').update(routerConfigJSON).digest('hex')); + await writeFile(join(outputDir, routerConfigFile), routerConfigJSON); + if (!config.feature_flags || config.feature_flags.length === 0) { + await writeFile(join(outputDir, mapperFile), JSON.stringify(Object.fromEntries(mapper))); + return; + } + + const ffConfigs = await buildFeatureFlagsConfig(config, inputFileLocation, subgraphs, options); + const ffDir = join(outputDir, featureFlagsDir); + try { + await mkdir(ffDir); + } catch { + console.log( + pc.red( + `Split-config flag enabled; output directory "${ffDir}" already exists. Please provide an empty root directory path.`, + ), + ); + process.exitCode = 1; + return; + } + + for (const [featureFlagName, featureFlagConfig] of Object.entries(ffConfigs.configByFeatureFlagName)) { + const ffRouterConfig = new RouterConfig({ + engineConfig: featureFlagConfig.engineConfig, + version: featureFlagConfig.version, + subgraphs: featureFlagConfig.subgraphs, + compatibilityVersion: routerConfig.compatibilityVersion, + }); + + const routerConfigJson = ffRouterConfig.toJsonString(); + await writeFeatureFlagConfigToFile(ffDir, featureFlagName, routerConfigJson); + mapper.set(featureFlagName, createHash('sha256').update(routerConfigJson).digest('hex')); + } + + await writeFile(join(outputDir, mapperFile), JSON.stringify(Object.fromEntries(mapper))); + console.log(pc.green(`Router execution manifest successfully written to "${pc.bold(outputDir)}".`)); +} + +async function handleEmbeddedRouterConfig({ + config, + inputFileLocation, + options, + routerConfig, + subgraphs, +}: HandleRouterConfigParams) { + if (!config.feature_flags || config.feature_flags.length > 0) { + routerConfig.featureFlagConfigs = await buildFeatureFlagsConfig(config, inputFileLocation, subgraphs, options); + } + + const routerConfigJson = routerConfig.toJsonString(); + if (options.out) { + const output = await getRouterConfigOutputFile(options.out); + await writeFile(output, routerConfigJson); + console.log(pc.green(`Router execution config successfully written to "${pc.bold(options.out)}".`)); + } else { + console.log(routerConfigJson); + } +} + +export default (_: BaseCommandOptions) => { const command = new Command('compose'); command.description( 'Generates a router config from a local composition file. This makes it easy to test your router without a control-plane connection. For production, please use the "router fetch" command', @@ -176,6 +200,10 @@ export default (opts: BaseCommandOptions) => { 'This flag will disable the validation for whether all nodes of the federated graph are resolvable. Do NOT use unless troubleshooting.', ); command.option('--ignore-external-keys', 'This flag ignores errors related to true external entity keys.'); + command.option( + '--split-configs-enabled', + 'This flag enables splitting the router config into multiple files. Router version 0.315.0 or higher is required.', + ); command.action(async (options) => { const inputFile = resolve(options.input); @@ -187,10 +215,17 @@ export default (opts: BaseCommandOptions) => { ); } + if (options.out) { + options.out = resolve(options.out); + if (options.splitConfigsEnabled) { + await mkdir(options.out, { recursive: true }); + } + } + const fileContent = (await readFile(inputFile)).toString(); const config = yaml.load(fileContent) as Config; - const subgraphs: SubgraphMetadata[] = []; + const subgraphs: SubgraphMetaData[] = []; for (const [index, subgraphConfig] of config.subgraphs.entries()) { const metadata = await toSubgraphMetadata(inputFileLocation, index, subgraphConfig, subgraphs); @@ -263,17 +298,9 @@ export default (opts: BaseCommandOptions) => { subgraphs: subgraphs.map((s, index) => constructRouterSubgraph(result, s, index)), }); - if (config.feature_flags && config.feature_flags.length > 0) { - const ffConfigs = await buildFeatureFlagsConfig(config, inputFileLocation, subgraphs, options); - routerConfig.featureFlagConfigs = ffConfigs; - } - - if (options.out) { - await writeFile(options.out, routerConfig.toJsonString()); - console.log(pc.green(`Router config successfully written to ${pc.bold(options.out)}`)); - } else { - console.log(routerConfig.toJsonString()); - } + await (options.splitConfigsEnabled + ? handleSplitRouterConfig({ config, inputFileLocation, options, routerConfig, subgraphs }) + : handleEmbeddedRouterConfig({ config, inputFileLocation, options, routerConfig, subgraphs })); }); return command; @@ -283,8 +310,8 @@ function toSubgraphMetadata( inputFileLocation: string, index: number, subgraphConfig: ConfigSubgraph, - subgraphs: SubgraphMetadata[], -): Promise { + subgraphs: SubgraphMetaData[], +): Promise { if ('plugin' in subgraphConfig) { return toSubgraphMetadataPlugin(inputFileLocation, subgraphConfig, subgraphs); } @@ -318,7 +345,7 @@ async function toSubgraphMetadataGRPC(inputFileLocation: string, s: GRPCSubgraph async function toSubgraphMetadataPlugin( inputFileLocation: string, s: SubgraphPluginConfig, - subgraphs: SubgraphMetadata[], + subgraphs: SubgraphMetaData[], ): Promise { const pluginName = basename(s.plugin.path); if (subgraphs.some((sg) => sg.kind === SubgraphKind.Plugin && sg.name === pluginName)) { @@ -353,7 +380,7 @@ async function toSubgraphMetadataStandard( inputFileLocation: string, index: number, s: StandardSubgraphConfig, - subgraphs: SubgraphMetadata[], + subgraphs: SubgraphMetaData[], ): Promise { // The subgraph name is required if (!s.name) { @@ -380,8 +407,7 @@ async function toSubgraphMetadataStandard( // The GraphQL schema is provided in the input file if (s.schema?.file) { const schemaFile = resolve(inputFileLocation, s.schema.file); - const sdl = (await readFile(schemaFile)).toString(); - schemaSDL = sdl; + schemaSDL = (await readFile(schemaFile)).toString(); } else { // The GraphQL schema is not provided in the input file, so we need to introspect it try { @@ -510,7 +536,7 @@ function validateSubgraphPlugin(inputFileLocation: string, s: SubgraphPluginConf async function buildFeatureFlagsConfig( config: Config, inputFileLocation: string, - subgraphs: SubgraphMetadata[], + subgraphs: SubgraphMetaData[], options: any, ): Promise { const ffConfigs: FeatureFlagRouterExecutionConfigs = new FeatureFlagRouterExecutionConfigs(); @@ -518,7 +544,7 @@ async function buildFeatureFlagsConfig( // @TODO This logic should exist only once in the shared package and reused across // control-plane and cli - for (const ff of config.feature_flags) { + for (const ff of config.feature_flags ?? []) { const featureSubgraphs: StandardSubgraphMetaData[] = []; const standardSubgraphs = config.subgraphs.filter( (ss) => !('plugin' in ss) && !('grpc' in ss), @@ -654,7 +680,7 @@ async function buildFeatureFlagsConfig( const configurationDataByTypeName = subgraphConfig?.configurationDataByTypeName; const costs = subgraphConfig?.costs; - const composedSubgraph: ComposedSubgraph = { + return { kind: SubgraphKind.Standard, id: `${index}`, name: s.name, @@ -666,8 +692,7 @@ async function buildFeatureFlagsConfig( schema, configurationDataByTypeName, costs, - }; - return composedSubgraph; + } satisfies ComposedSubgraph; }), }); diff --git a/cli/src/commands/router/commands/fetch.ts b/cli/src/commands/router/commands/fetch.ts index df6524adf6..f2cbc9043d 100644 --- a/cli/src/commands/router/commands/fetch.ts +++ b/cli/src/commands/router/commands/fetch.ts @@ -1,21 +1,18 @@ -import { writeFile } from 'node:fs/promises'; -import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; -import { Command, program } from 'commander'; -import jwtDecode from 'jwt-decode'; +import { writeFile, mkdir, readdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { Command } from 'commander'; import pc from 'picocolors'; -import { resolve } from 'pathe'; -import { getBaseHeaders, config } from '../../../core/config.js'; +import { resolve, join } from 'pathe'; import { BaseCommandOptions } from '../../../core/types/types.js'; -import { GraphToken } from '../../auth/utils.js'; -import { makeSignature, safeCompare } from '../../../core/signature.js'; - -export const handleOutput = async (out: string | undefined, config: string) => { - if (out) { - await writeFile(resolve(out), config ?? ''); - } else { - console.log(config); - } -}; +import { + fetchRouterConfig, + mapperFile, + featureFlagsDir, + getRouterConfigOutputFile, + latestFile, + writeFeatureFlagConfigToFile, +} from '../utils.js'; +import type { FetchRouterConfigResult } from '../types/types.js'; export default (opts: BaseCommandOptions) => { const command = new Command('fetch'); @@ -30,83 +27,103 @@ export default (opts: BaseCommandOptions) => { 'The signature key to verify the downloaded router config. If not provided, the router config will not be verified.', ); command.action(async (name, options) => { - const resp = await opts.client.platform.generateRouterToken( - { - fedGraphName: name, + try { + const result = await fetchRouterConfig({ + client: opts.client, + name, namespace: options.namespace, - }, - { - headers: getBaseHeaders(), - }, - ); + graphSignKey: options.graphSignKey, + }); - if (resp.response?.code !== EnumStatusCode.OK) { - console.log(`${pc.red(`Could not fetch the router config for the graph ${pc.bold(name)}`)}`); - if (resp.response?.details) { - console.log(pc.red(pc.bold(resp.response?.details))); + await handleOutput(options.out, options.graphSignKey, result); + } catch (err) { + if (err instanceof Error) { + console.error(err.message); } + process.exitCode = 1; - return; } + }); - let decoded: GraphToken; + return command; +}; - try { - decoded = jwtDecode(resp.token); - } catch { - program.error('Could not fetch the router config. Please try again'); - } +const handleOutput = (out: string | undefined, graphSignKey: string | undefined, config: FetchRouterConfigResult) => { + return config.splitConfigLoading + ? handleSplitRouterConfig(out, !!graphSignKey, config) + : handleEmbeddedRouterConfig(out, !!graphSignKey, config); +}; - const requestBody = JSON.stringify({ - Version: '', - }); +async function handleSplitRouterConfig( + out: string | undefined, + graphSignKey: boolean, + config: FetchRouterConfigResult, +) { + let outputDir = out ? resolve(out) : out; + if (!outputDir) { + outputDir = resolve('router-config-output'); + } - const headers = new Headers(); - headers.append('Content-Type', 'application/json; charset=UTF-8'); - headers.append('Authorization', 'Bearer ' + resp.token); - headers.append('Accept-Encoding', 'gzip'); + if (!existsSync(outputDir)) { + await mkdir(outputDir); + } - const url = new URL( - `/${decoded.organization_id}/${decoded.federated_graph_id}/routerconfigs/latest.json`, - config.cdnURL, + const entries = await readdir(outputDir); + if (entries.length > 0) { + console.log( + pc.red( + `Split-config flag enabled; output directory "${outputDir}" is not empty. Please provide an empty directory path.`, + ), ); + process.exitCode = 1; + return; + } - const response = await fetch(url, { - method: 'POST', - headers, - body: requestBody, - }); - - const body = await response.text(); - - if (options.graphSignKey) { - const signature = response.headers.get('X-Signature-SHA256'); - if (!signature) { - console.log(pc.red('You provided a signature key, but the router config does not have a signature header.')); - process.exitCode = 1; - return; - } + await writeFile(join(outputDir, latestFile), config.routerConfig); + if (config.mapper) { + await writeFile(join(outputDir, mapperFile), JSON.stringify(config.mapper)); + } - const hash = await makeSignature(body, options.graphSignKey); + if (config.featureFlags && config.featureFlags.size > 0) { + const ffDir = join(outputDir, featureFlagsDir); + try { + await mkdir(ffDir); + } catch { + console.log( + pc.red( + `Split-config flag enabled; output directory "${ffDir}" already exists. Please provide an empty root directory path.`, + ), + ); + process.exitCode = 1; + return; + } - if (!safeCompare(hash, signature)) { - console.log(pc.red('The signature of the router config does not match the provided signature key.')); - process.exitCode = 1; - return; - } + for (const [featureFlagName, featureFlagRouterConfig] of config.featureFlags) { + await writeFeatureFlagConfigToFile(ffDir, featureFlagName, featureFlagRouterConfig); + } + } - if (options.out) { - await handleOutput(options.out, body); + if (graphSignKey) { + console.log(pc.green('The signature of the router config matches the local computed signature.')); + } - console.log(pc.green('The signature of the router config matches the local computed signature.')); - console.log(pc.green(`The router config has been written to ${pc.bold(options.out)}`)); + console.log(pc.green(`The router configs has been written to ${pc.bold(outputDir)}`)); +} - return; - } +async function handleEmbeddedRouterConfig( + out: string | undefined, + graphSignKey: boolean, + config: FetchRouterConfigResult, +) { + if (out) { + const output = await getRouterConfigOutputFile(out); + await writeFile(output, config.routerConfig); + if (graphSignKey) { + console.log(pc.green('The signature of the router config matches the local computed signature.')); } - await handleOutput(options.out, body); - }); - - return command; -}; + console.log(pc.green(`The router config has been written to ${pc.bold(out)}`)); + } else { + console.log(config.routerConfig); + } +} diff --git a/cli/src/commands/router/commands/types/params.ts b/cli/src/commands/router/commands/types/params.ts new file mode 100644 index 0000000000..306424cb9a --- /dev/null +++ b/cli/src/commands/router/commands/types/params.ts @@ -0,0 +1,10 @@ +import { RouterConfig } from '@wundergraph/cosmo-connect/dist/node/v1/node_pb'; +import { Config, SubgraphMetaData } from './types.js'; + +export type HandleRouterConfigParams = { + config: Config; + inputFileLocation: string; + options: any; + routerConfig: RouterConfig; + subgraphs: SubgraphMetaData[]; +}; diff --git a/cli/src/commands/router/commands/types/types.ts b/cli/src/commands/router/commands/types/types.ts new file mode 100644 index 0000000000..bf8bc220e5 --- /dev/null +++ b/cli/src/commands/router/commands/types/types.ts @@ -0,0 +1,80 @@ +import { GRPCMapping } from '@wundergraph/cosmo-connect/dist/node/v1/node_pb'; +import { SubgraphKind, type SubscriptionProtocol, type WebsocketSubprotocol } from '@wundergraph/cosmo-shared'; + +export type ConfigSubgraph = StandardSubgraphConfig | SubgraphPluginConfig | GRPCSubgraphConfig; + +export type StandardSubgraphConfig = { + name: string; + routing_url: string; + schema?: { + file: string; + }; + subscription?: { + url?: string; + protocol?: 'ws' | 'sse' | 'sse_post'; + websocketSubprotocol?: 'auto' | 'graphql-ws' | 'graphql-transport-ws'; + }; + introspection?: { + url: string; + headers?: { + [key: string]: string; + }; + raw?: boolean; + }; +}; + +export type SubgraphPluginConfig = { + plugin: { + version: string; + path: string; + }; +}; + +export type GRPCSubgraphConfig = { + name: string; + routing_url: string; + grpc: { + schema_file: string; + proto_file: string; + mapping_file: string; + }; +}; + +export type SubgraphMetaData = StandardSubgraphMetaData | SubgraphPluginMetadata | GRPCSubgraphMetadata; + +export type StandardSubgraphMetaData = { + kind: SubgraphKind.Standard; + name: string; + sdl: string; + routingUrl: string; + subscriptionUrl: string; + subscriptionProtocol: SubscriptionProtocol; + websocketSubprotocol: WebsocketSubprotocol; +}; + +export type SubgraphPluginMetadata = { + kind: SubgraphKind.Plugin; + name: string; + sdl: string; + mapping: GRPCMapping; + protoSchema: string; + version: string; +}; + +export type GRPCSubgraphMetadata = { + kind: SubgraphKind.GRPC; + name: string; + sdl: string; + routingUrl: string; + protoSchema: string; + mapping: GRPCMapping; +}; + +export type Config = { + version: number; + subgraphs: ConfigSubgraph[]; + feature_flags?: { + name: string; + feature_graphs: (StandardSubgraphConfig & { subgraph_name: string })[]; + }[]; +}; diff --git a/cli/src/commands/router/types/params.ts b/cli/src/commands/router/types/params.ts new file mode 100644 index 0000000000..5e67f539c2 --- /dev/null +++ b/cli/src/commands/router/types/params.ts @@ -0,0 +1,8 @@ +import type { Client } from '../../../core/client/client.js'; + +export type FetchRouterConfigParams = { + client: Client; + name: string; + namespace?: string; + graphSignKey?: string; +}; diff --git a/cli/src/commands/router/types/types.ts b/cli/src/commands/router/types/types.ts new file mode 100644 index 0000000000..91b3d4a111 --- /dev/null +++ b/cli/src/commands/router/types/types.ts @@ -0,0 +1,6 @@ +export interface FetchRouterConfigResult { + splitConfigLoading: boolean; + routerConfig: string; + featureFlags?: Map; + mapper?: Record; +} diff --git a/cli/src/commands/router/utils.ts b/cli/src/commands/router/utils.ts new file mode 100644 index 0000000000..97697b4090 --- /dev/null +++ b/cli/src/commands/router/utils.ts @@ -0,0 +1,174 @@ +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import jwtDecode from 'jwt-decode'; +import pc from 'picocolors'; +import { dirname, join } from 'pathe'; +import { config, getBaseHeaders } from '../../core/config.js'; +import { GraphToken } from '../auth/utils.js'; +import { makeSignature, safeCompare } from '../../core/signature.js'; +import type { FetchRouterConfigResult } from './types/types.js'; +import type { FetchRouterConfigParams } from './types/params.js'; + +export const featureFlagsDir = 'feature-flags'; +export const latestFile = 'latest.json'; +export const mapperFile = 'mapper.json'; +export const routerConfigFile = 'router-config.json'; + +const invalidCharacters = /[./]/; + +export async function getRouterConfigOutputFile(out: string): Promise { + let output: string = out; + + /** + * If the provided output doesn't end with `.json`, assume it's a directory and append the filename; otherwise, + * if the directory doesn't exist, we need to create before writing the file + */ + if (output.toLowerCase().endsWith('.json')) { + const dir = dirname(output); + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } + } else { + if (!existsSync(output)) { + await mkdir(output, { recursive: true }); + } + + output = join(out, routerConfigFile); + } + + return output; +} + +export const fetchRouterConfig = async ({ + client, + name, + namespace, + graphSignKey, +}: FetchRouterConfigParams): Promise => { + const resp = await client.platform.generateRouterToken( + { + fedGraphName: name, + namespace, + }, + { + headers: getBaseHeaders(), + }, + ); + + if (resp.response?.code !== EnumStatusCode.OK) { + throw new Error( + `${pc.red(`Could not fetch the router config for the graph ${pc.bold(name)}`)} \n${pc.red( + pc.bold(resp.response?.details || ''), + )}`, + ); + } + + // Try to decode the generated token + let decoded: GraphToken; + try { + decoded = jwtDecode(resp.token); + } catch { + throw new Error(pc.red('Could not fetch the router config. Please try again')); + } + + const baseUrl = new URL(`/${decoded.organization_id}/${decoded.federated_graph_id}/`, config.cdnURL); + if (!decoded.features?.includes('split-config-loading')) { + // Legacy router config fetching + return { + splitConfigLoading: false, + routerConfig: await fetchFileContentFromCdn( + new URL('routerconfigs/latest.json', baseUrl), + resp.token, + graphSignKey, + ), + }; + } + + // Retrieve the latest router configuration + const result: FetchRouterConfigResult = { + splitConfigLoading: true, + routerConfig: await fetchFileContentFromCdn(new URL('manifest/latest.json', baseUrl), resp.token, graphSignKey), + }; + + // Retrieve the `mapper.json` file and convert the content to a `Map` for validation + const mapperTextContent = await fetchFileContentFromCdn(new URL('manifest/mapper.json', baseUrl), resp.token); + + const mapperRecord = JSON.parse(mapperTextContent); + const mapper = + typeof mapperRecord === 'object' && !Array.isArray(mapperRecord) + ? new Map(Object.entries(mapperRecord)) + : new Map(); + + result.mapper = Object.fromEntries(mapper); + mapper.delete(''); // Delete the federated graph hash + + if (mapper.size === 0) { + return result; + } + + // Fetch the latest router configuration for each feature flag + result.featureFlags = new Map(); + for (const [featureFlagName] of mapper) { + result.featureFlags.set( + featureFlagName, + await fetchFileContentFromCdn( + new URL(`manifest/feature-flags/${featureFlagName}.json`, baseUrl), + resp.token, + graphSignKey, + ), + ); + } + + return result; +}; + +export async function writeFeatureFlagConfigToFile( + basePath: string, + featureFlagName: string, + featureFlagConfig: string, +) { + if (invalidCharacters.test(featureFlagName)) { + throw new Error(`The feature flag name "${featureFlagName}" contains invalid characters.`); + } + + await writeFile(join(basePath, `${featureFlagName}.json`), featureFlagConfig); +} + +async function fetchFileContentFromCdn(url: URL, token: string, graphSignKey?: string): Promise { + const headers = new Headers(); + headers.append('Content-Type', 'application/json; charset=UTF-8'); + headers.append('Authorization', 'Bearer ' + token); + headers.append('Accept-Encoding', 'gzip'); + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ Version: '' }), + }); + + if (!response.ok) { + // The fetch failed the file from CDN + throw new Error(pc.red(`Failed to fetch file "${url}": ${response.status} ${response.statusText}`)); + } + + const body = await response.text(); + if (!graphSignKey) { + // No signature key was provided, we don't need to validate the signature header + return body; + } + + // Ensure that we got a signature header and that signing the body using the provided signature key matches + // the header value + const signature = response.headers.get('X-Signature-SHA256'); + if (!signature) { + throw new Error(pc.red('You provided a signature key, but the router config does not have a signature header.')); + } + + const hash = await makeSignature(body, graphSignKey); + if (!safeCompare(hash, signature)) { + throw new Error(pc.red('The signature of the router config does not match the provided signature key.')); + } + + return body; +} diff --git a/cli/test/fixtures/router-compose/router-config.json.snap b/cli/test/fixtures/router-compose/router-config.json.snap new file mode 100644 index 0000000000..f5cd24b56d --- /dev/null +++ b/cli/test/fixtures/router-compose/router-config.json.snap @@ -0,0 +1 @@ +{"engineConfig":{"defaultFlushInterval":"500","datasourceConfigurations":[{"kind":"GRAPHQL","rootNodes":[{"typeName":"Query","fieldNames":["projects","project","projectStatuses","projectsByStatus","killService","panic"]},{"typeName":"Mutation","fieldNames":["addProject"]},{"typeName":"Project","fieldNames":["id","name","description","startDate","endDate","status","teamMembers","relatedProducts","milestoneIds"]},{"typeName":"Employee","fieldNames":["id","projects"]},{"typeName":"Product","fieldNames":["upc","projects"]}],"overrideFieldPathFromAlias":true,"customGraphql":{"fetch":{"url":{"staticVariableContent":"http://localhost:4001/graphql"},"method":"POST","body":{},"baseUrl":{},"path":{}},"subscription":{"enabled":true,"url":{"staticVariableContent":"http://localhost:4001/graphql"},"protocol":"GRAPHQL_SUBSCRIPTION_PROTOCOL_WS","websocketSubprotocol":"GRAPHQL_WEBSOCKET_SUBPROTOCOL_AUTO"},"federation":{"enabled":true,"serviceSdl":"extend schema\n @link(\n url: \"https://specs.apollo.dev/federation/v2.5\"\n import: [\n \"@authenticated\"\n \"@composeDirective\"\n \"@external\"\n \"@extends\"\n \"@inaccessible\"\n \"@interfaceObject\"\n \"@override\"\n \"@provides\"\n \"@key\"\n \"@requires\"\n \"@requiresScopes\"\n \"@shareable\"\n \"@tag\"\n ]\n )\n\nschema {\n query: Query\n mutation: Mutation\n}\n\ntype Query {\n projects: [Project!]!\n project(id: ID!): Project\n projectStatuses: [ProjectStatus!]!\n projectsByStatus(status: ProjectStatus!): [Project!]!\n\n # query to simulate that the service goes down\n killService: Boolean!\n panic: Boolean!\n}\n\ntype Mutation {\n addProject(project: ProjectInput!): Project!\n}\n\ninput ProjectInput {\n name: String!\n description: String\n startDate: String # ISO date\n endDate: String # ISO date\n status: ProjectStatus!\n}\n\ntype Project @key(fields: \"id\") {\n id: ID!\n name: String!\n description: String\n startDate: String # ISO date\n endDate: String # ISO date\n status: ProjectStatus!\n # Federated references:\n teamMembers: [Employee!]!\n relatedProducts: [Product!]! # from products subgraph\n # Project milestones or checkpoints\n milestoneIds: [String!] # Array of milestone identifiers\n}\n\nenum ProjectStatus {\n PLANNING\n ACTIVE\n COMPLETED\n ON_HOLD\n}\n\ntype Employee @key(fields: \"id\") {\n id: Int!\n # New field resolved by this subgraph:\n projects: [Project!]\n}\n\ntype Product @key(fields: \"upc\") {\n upc: String!\n # Projects contributing to this product:\n projects: [Project!]\n}\n"},"upstreamSchema":{"key":"d4e29ce3470092d20fc0b57cbff3c538e51bd6fd"}},"requestTimeoutSeconds":"10","id":"0","keys":[{"typeName":"Project","selectionSet":"id"},{"typeName":"Employee","selectionSet":"id"},{"typeName":"Product","selectionSet":"upc"}]}],"fieldConfigurations":[{"typeName":"Query","fieldName":"project","argumentsConfiguration":[{"name":"id","sourceType":"FIELD_ARGUMENT"}]},{"typeName":"Query","fieldName":"projectsByStatus","argumentsConfiguration":[{"name":"status","sourceType":"FIELD_ARGUMENT"}]},{"typeName":"Mutation","fieldName":"addProject","argumentsConfiguration":[{"name":"project","sourceType":"FIELD_ARGUMENT"}]}],"graphqlSchema":"schema {\n query: Query\n mutation: Mutation\n}\n\ntype Query {\n projects: [Project!]!\n project(id: ID!): Project\n projectStatuses: [ProjectStatus!]!\n projectsByStatus(status: ProjectStatus!): [Project!]!\n killService: Boolean!\n panic: Boolean!\n}\n\ntype Mutation {\n addProject(project: ProjectInput!): Project!\n}\n\ninput ProjectInput {\n name: String!\n description: String\n startDate: String\n endDate: String\n status: ProjectStatus!\n}\n\ntype Project {\n id: ID!\n name: String!\n description: String\n startDate: String\n endDate: String\n status: ProjectStatus!\n teamMembers: [Employee!]!\n relatedProducts: [Product!]!\n milestoneIds: [String!]\n}\n\nenum ProjectStatus {\n PLANNING\n ACTIVE\n COMPLETED\n ON_HOLD\n}\n\ntype Employee {\n id: Int!\n projects: [Project!]\n}\n\ntype Product {\n upc: String!\n projects: [Project!]\n}","stringStorage":{"d4e29ce3470092d20fc0b57cbff3c538e51bd6fd":"schema @link(url: \"https://specs.apollo.dev/federation/v2.5\", import: [\"@authenticated\", \"@composeDirective\", \"@external\", \"@extends\", \"@inaccessible\", \"@interfaceObject\", \"@override\", \"@provides\", \"@key\", \"@requires\", \"@requiresScopes\", \"@shareable\", \"@tag\"]) {\n query: Query\n mutation: Mutation\n}\n\ndirective @key(fields: openfed__FieldSet!, resolvable: Boolean = true) repeatable on INTERFACE | OBJECT\n\ndirective @link(as: String, for: link__Purpose, import: [link__Import], url: String!) repeatable on SCHEMA\n\ntype Employee @key(fields: \"id\") {\n id: Int!\n projects: [Project!]\n}\n\ntype Mutation {\n addProject(project: ProjectInput!): Project!\n}\n\ntype Product @key(fields: \"upc\") {\n projects: [Project!]\n upc: String!\n}\n\ntype Project @key(fields: \"id\") {\n description: String\n endDate: String\n id: ID!\n milestoneIds: [String!]\n name: String!\n relatedProducts: [Product!]!\n startDate: String\n status: ProjectStatus!\n teamMembers: [Employee!]!\n}\n\ninput ProjectInput {\n description: String\n endDate: String\n name: String!\n startDate: String\n status: ProjectStatus!\n}\n\nenum ProjectStatus {\n ACTIVE\n COMPLETED\n ON_HOLD\n PLANNING\n}\n\ntype Query {\n killService: Boolean!\n panic: Boolean!\n project(id: ID!): Project\n projectStatuses: [ProjectStatus!]!\n projects: [Project!]!\n projectsByStatus(status: ProjectStatus!): [Project!]!\n}\n\nscalar link__Import\n\nenum link__Purpose {\n EXECUTION\n SECURITY\n}\n\nscalar openfed__FieldSet"}},"version":"00000000-0000-0000-0000-000000000000","subgraphs":[{"id":"0","name":"schema","routingUrl":"http://localhost:4001/graphql"}],"featureFlagConfigs":{"configByFeatureFlagName":{"my-feature-flag":{"engineConfig":{"defaultFlushInterval":"500","datasourceConfigurations":[{"kind":"GRAPHQL","rootNodes":[{"typeName":"Query","fieldNames":["projects","project","projectStatuses","projectsByStatus","killService","panic"]},{"typeName":"Mutation","fieldNames":["addProject"]},{"typeName":"Project","fieldNames":["id","name","description","startDate","endDate","status","teamMembers","relatedProducts","milestoneIds"]},{"typeName":"Employee","fieldNames":["id","projects"]},{"typeName":"Product","fieldNames":["upc","projects"]}],"overrideFieldPathFromAlias":true,"customGraphql":{"fetch":{"url":{"staticVariableContent":"http://localhost:4001/graphql-feature-flag"},"method":"POST","body":{},"baseUrl":{},"path":{}},"subscription":{"enabled":true,"url":{"staticVariableContent":"http://localhost:4001/graphql-feature-flag"},"protocol":"GRAPHQL_SUBSCRIPTION_PROTOCOL_WS","websocketSubprotocol":"GRAPHQL_WEBSOCKET_SUBPROTOCOL_AUTO"},"federation":{"enabled":true,"serviceSdl":"extend schema\n @link(\n url: \"https://specs.apollo.dev/federation/v2.5\"\n import: [\n \"@authenticated\"\n \"@composeDirective\"\n \"@external\"\n \"@extends\"\n \"@inaccessible\"\n \"@interfaceObject\"\n \"@override\"\n \"@provides\"\n \"@key\"\n \"@requires\"\n \"@requiresScopes\"\n \"@shareable\"\n \"@tag\"\n ]\n )\n\nschema {\n query: Query\n mutation: Mutation\n}\n\ntype Query {\n projects: [Project!]!\n project(id: ID!): Project\n projectStatuses: [ProjectStatus!]!\n projectsByStatus(status: ProjectStatus!): [Project!]!\n\n # query to simulate that the service goes down\n killService: Boolean!\n panic: Boolean!\n}\n\ntype Mutation {\n addProject(project: ProjectInput!): Project!\n}\n\ninput ProjectInput {\n name: String!\n description: String\n startDate: String # ISO date\n endDate: String # ISO date\n status: ProjectStatus!\n}\n\ntype Project @key(fields: \"id\") {\n id: ID!\n name: String!\n description: String\n startDate: String # ISO date\n endDate: String # ISO date\n status: ProjectStatus!\n # Federated references:\n teamMembers: [Employee!]!\n relatedProducts: [Product!]! # from products subgraph\n # Project milestones or checkpoints\n milestoneIds: [String!] # Array of milestone identifiers\n}\n\nenum ProjectStatus {\n PLANNING\n ACTIVE\n COMPLETED\n ON_HOLD\n}\n\ntype Employee @key(fields: \"id\") {\n id: Int!\n # New field resolved by this subgraph:\n projects: [Project!]\n}\n\ntype Product @key(fields: \"upc\") {\n upc: String!\n # Projects contributing to this product:\n projects: [Project!]\n}\n"},"upstreamSchema":{"key":"d4e29ce3470092d20fc0b57cbff3c538e51bd6fd"}},"requestTimeoutSeconds":"10","id":"0","keys":[{"typeName":"Project","selectionSet":"id"},{"typeName":"Employee","selectionSet":"id"},{"typeName":"Product","selectionSet":"upc"}]}],"fieldConfigurations":[{"typeName":"Query","fieldName":"project","argumentsConfiguration":[{"name":"id","sourceType":"FIELD_ARGUMENT"}]},{"typeName":"Query","fieldName":"projectsByStatus","argumentsConfiguration":[{"name":"status","sourceType":"FIELD_ARGUMENT"}]},{"typeName":"Mutation","fieldName":"addProject","argumentsConfiguration":[{"name":"project","sourceType":"FIELD_ARGUMENT"}]}],"graphqlSchema":"schema {\n query: Query\n mutation: Mutation\n}\n\ntype Query {\n projects: [Project!]!\n project(id: ID!): Project\n projectStatuses: [ProjectStatus!]!\n projectsByStatus(status: ProjectStatus!): [Project!]!\n killService: Boolean!\n panic: Boolean!\n}\n\ntype Mutation {\n addProject(project: ProjectInput!): Project!\n}\n\ninput ProjectInput {\n name: String!\n description: String\n startDate: String\n endDate: String\n status: ProjectStatus!\n}\n\ntype Project {\n id: ID!\n name: String!\n description: String\n startDate: String\n endDate: String\n status: ProjectStatus!\n teamMembers: [Employee!]!\n relatedProducts: [Product!]!\n milestoneIds: [String!]\n}\n\nenum ProjectStatus {\n PLANNING\n ACTIVE\n COMPLETED\n ON_HOLD\n}\n\ntype Employee {\n id: Int!\n projects: [Project!]\n}\n\ntype Product {\n upc: String!\n projects: [Project!]\n}","stringStorage":{"d4e29ce3470092d20fc0b57cbff3c538e51bd6fd":"schema @link(url: \"https://specs.apollo.dev/federation/v2.5\", import: [\"@authenticated\", \"@composeDirective\", \"@external\", \"@extends\", \"@inaccessible\", \"@interfaceObject\", \"@override\", \"@provides\", \"@key\", \"@requires\", \"@requiresScopes\", \"@shareable\", \"@tag\"]) {\n query: Query\n mutation: Mutation\n}\n\ndirective @key(fields: openfed__FieldSet!, resolvable: Boolean = true) repeatable on INTERFACE | OBJECT\n\ndirective @link(as: String, for: link__Purpose, import: [link__Import], url: String!) repeatable on SCHEMA\n\ntype Employee @key(fields: \"id\") {\n id: Int!\n projects: [Project!]\n}\n\ntype Mutation {\n addProject(project: ProjectInput!): Project!\n}\n\ntype Product @key(fields: \"upc\") {\n projects: [Project!]\n upc: String!\n}\n\ntype Project @key(fields: \"id\") {\n description: String\n endDate: String\n id: ID!\n milestoneIds: [String!]\n name: String!\n relatedProducts: [Product!]!\n startDate: String\n status: ProjectStatus!\n teamMembers: [Employee!]!\n}\n\ninput ProjectInput {\n description: String\n endDate: String\n name: String!\n startDate: String\n status: ProjectStatus!\n}\n\nenum ProjectStatus {\n ACTIVE\n COMPLETED\n ON_HOLD\n PLANNING\n}\n\ntype Query {\n killService: Boolean!\n panic: Boolean!\n project(id: ID!): Project\n projectStatuses: [ProjectStatus!]!\n projects: [Project!]!\n projectsByStatus(status: ProjectStatus!): [Project!]!\n}\n\nscalar link__Import\n\nenum link__Purpose {\n EXECUTION\n SECURITY\n}\n\nscalar openfed__FieldSet"}},"version":"00000000-0000-0000-0000-000000000000","subgraphs":[{"id":"0","name":"feature-schema","routingUrl":"http://localhost:4001/graphql-feature-flag"}]}}},"compatibilityVersion":"1:{{$COMPOSITION__VERSION}}"} \ No newline at end of file diff --git a/cli/test/fixtures/router-compose/split-config/feature-flags/my-feature-flag.json.snap b/cli/test/fixtures/router-compose/split-config/feature-flags/my-feature-flag.json.snap new file mode 100644 index 0000000000..e155ae3897 --- /dev/null +++ b/cli/test/fixtures/router-compose/split-config/feature-flags/my-feature-flag.json.snap @@ -0,0 +1 @@ +{"engineConfig":{"defaultFlushInterval":"500","datasourceConfigurations":[{"kind":"GRAPHQL","rootNodes":[{"typeName":"Query","fieldNames":["projects","project","projectStatuses","projectsByStatus","killService","panic"]},{"typeName":"Mutation","fieldNames":["addProject"]},{"typeName":"Project","fieldNames":["id","name","description","startDate","endDate","status","teamMembers","relatedProducts","milestoneIds"]},{"typeName":"Employee","fieldNames":["id","projects"]},{"typeName":"Product","fieldNames":["upc","projects"]}],"overrideFieldPathFromAlias":true,"customGraphql":{"fetch":{"url":{"staticVariableContent":"http://localhost:4001/graphql-feature-flag"},"method":"POST","body":{},"baseUrl":{},"path":{}},"subscription":{"enabled":true,"url":{"staticVariableContent":"http://localhost:4001/graphql-feature-flag"},"protocol":"GRAPHQL_SUBSCRIPTION_PROTOCOL_WS","websocketSubprotocol":"GRAPHQL_WEBSOCKET_SUBPROTOCOL_AUTO"},"federation":{"enabled":true,"serviceSdl":"extend schema\n @link(\n url: \"https://specs.apollo.dev/federation/v2.5\"\n import: [\n \"@authenticated\"\n \"@composeDirective\"\n \"@external\"\n \"@extends\"\n \"@inaccessible\"\n \"@interfaceObject\"\n \"@override\"\n \"@provides\"\n \"@key\"\n \"@requires\"\n \"@requiresScopes\"\n \"@shareable\"\n \"@tag\"\n ]\n )\n\nschema {\n query: Query\n mutation: Mutation\n}\n\ntype Query {\n projects: [Project!]!\n project(id: ID!): Project\n projectStatuses: [ProjectStatus!]!\n projectsByStatus(status: ProjectStatus!): [Project!]!\n\n # query to simulate that the service goes down\n killService: Boolean!\n panic: Boolean!\n}\n\ntype Mutation {\n addProject(project: ProjectInput!): Project!\n}\n\ninput ProjectInput {\n name: String!\n description: String\n startDate: String # ISO date\n endDate: String # ISO date\n status: ProjectStatus!\n}\n\ntype Project @key(fields: \"id\") {\n id: ID!\n name: String!\n description: String\n startDate: String # ISO date\n endDate: String # ISO date\n status: ProjectStatus!\n # Federated references:\n teamMembers: [Employee!]!\n relatedProducts: [Product!]! # from products subgraph\n # Project milestones or checkpoints\n milestoneIds: [String!] # Array of milestone identifiers\n}\n\nenum ProjectStatus {\n PLANNING\n ACTIVE\n COMPLETED\n ON_HOLD\n}\n\ntype Employee @key(fields: \"id\") {\n id: Int!\n # New field resolved by this subgraph:\n projects: [Project!]\n}\n\ntype Product @key(fields: \"upc\") {\n upc: String!\n # Projects contributing to this product:\n projects: [Project!]\n}\n"},"upstreamSchema":{"key":"d4e29ce3470092d20fc0b57cbff3c538e51bd6fd"}},"requestTimeoutSeconds":"10","id":"0","keys":[{"typeName":"Project","selectionSet":"id"},{"typeName":"Employee","selectionSet":"id"},{"typeName":"Product","selectionSet":"upc"}]}],"fieldConfigurations":[{"typeName":"Query","fieldName":"project","argumentsConfiguration":[{"name":"id","sourceType":"FIELD_ARGUMENT"}]},{"typeName":"Query","fieldName":"projectsByStatus","argumentsConfiguration":[{"name":"status","sourceType":"FIELD_ARGUMENT"}]},{"typeName":"Mutation","fieldName":"addProject","argumentsConfiguration":[{"name":"project","sourceType":"FIELD_ARGUMENT"}]}],"graphqlSchema":"schema {\n query: Query\n mutation: Mutation\n}\n\ntype Query {\n projects: [Project!]!\n project(id: ID!): Project\n projectStatuses: [ProjectStatus!]!\n projectsByStatus(status: ProjectStatus!): [Project!]!\n killService: Boolean!\n panic: Boolean!\n}\n\ntype Mutation {\n addProject(project: ProjectInput!): Project!\n}\n\ninput ProjectInput {\n name: String!\n description: String\n startDate: String\n endDate: String\n status: ProjectStatus!\n}\n\ntype Project {\n id: ID!\n name: String!\n description: String\n startDate: String\n endDate: String\n status: ProjectStatus!\n teamMembers: [Employee!]!\n relatedProducts: [Product!]!\n milestoneIds: [String!]\n}\n\nenum ProjectStatus {\n PLANNING\n ACTIVE\n COMPLETED\n ON_HOLD\n}\n\ntype Employee {\n id: Int!\n projects: [Project!]\n}\n\ntype Product {\n upc: String!\n projects: [Project!]\n}","stringStorage":{"d4e29ce3470092d20fc0b57cbff3c538e51bd6fd":"schema @link(url: \"https://specs.apollo.dev/federation/v2.5\", import: [\"@authenticated\", \"@composeDirective\", \"@external\", \"@extends\", \"@inaccessible\", \"@interfaceObject\", \"@override\", \"@provides\", \"@key\", \"@requires\", \"@requiresScopes\", \"@shareable\", \"@tag\"]) {\n query: Query\n mutation: Mutation\n}\n\ndirective @key(fields: openfed__FieldSet!, resolvable: Boolean = true) repeatable on INTERFACE | OBJECT\n\ndirective @link(as: String, for: link__Purpose, import: [link__Import], url: String!) repeatable on SCHEMA\n\ntype Employee @key(fields: \"id\") {\n id: Int!\n projects: [Project!]\n}\n\ntype Mutation {\n addProject(project: ProjectInput!): Project!\n}\n\ntype Product @key(fields: \"upc\") {\n projects: [Project!]\n upc: String!\n}\n\ntype Project @key(fields: \"id\") {\n description: String\n endDate: String\n id: ID!\n milestoneIds: [String!]\n name: String!\n relatedProducts: [Product!]!\n startDate: String\n status: ProjectStatus!\n teamMembers: [Employee!]!\n}\n\ninput ProjectInput {\n description: String\n endDate: String\n name: String!\n startDate: String\n status: ProjectStatus!\n}\n\nenum ProjectStatus {\n ACTIVE\n COMPLETED\n ON_HOLD\n PLANNING\n}\n\ntype Query {\n killService: Boolean!\n panic: Boolean!\n project(id: ID!): Project\n projectStatuses: [ProjectStatus!]!\n projects: [Project!]!\n projectsByStatus(status: ProjectStatus!): [Project!]!\n}\n\nscalar link__Import\n\nenum link__Purpose {\n EXECUTION\n SECURITY\n}\n\nscalar openfed__FieldSet"}},"version":"00000000-0000-0000-0000-000000000000","subgraphs":[{"id":"0","name":"feature-schema","routingUrl":"http://localhost:4001/graphql-feature-flag"}],"compatibilityVersion":"1:{{$COMPOSITION__VERSION}}"} \ No newline at end of file diff --git a/cli/test/fixtures/router-compose/split-config/mapper.json.snap b/cli/test/fixtures/router-compose/split-config/mapper.json.snap new file mode 100644 index 0000000000..16da9a5310 --- /dev/null +++ b/cli/test/fixtures/router-compose/split-config/mapper.json.snap @@ -0,0 +1 @@ +{"":"35804fc99e2ef80bd729926fb56eb6bd95e47d001a090202f27ff66105285c14","my-feature-flag":"3006518c3f4d8e85e5f0b94badf17f1847e4cf918ae4a71dd24eaa99a88968de"} \ No newline at end of file diff --git a/cli/test/fixtures/router-compose/split-config/router-config.json.snap b/cli/test/fixtures/router-compose/split-config/router-config.json.snap new file mode 100644 index 0000000000..899815deaf --- /dev/null +++ b/cli/test/fixtures/router-compose/split-config/router-config.json.snap @@ -0,0 +1 @@ +{"engineConfig":{"defaultFlushInterval":"500","datasourceConfigurations":[{"kind":"GRAPHQL","rootNodes":[{"typeName":"Query","fieldNames":["projects","project","projectStatuses","projectsByStatus","killService","panic"]},{"typeName":"Mutation","fieldNames":["addProject"]},{"typeName":"Project","fieldNames":["id","name","description","startDate","endDate","status","teamMembers","relatedProducts","milestoneIds"]},{"typeName":"Employee","fieldNames":["id","projects"]},{"typeName":"Product","fieldNames":["upc","projects"]}],"overrideFieldPathFromAlias":true,"customGraphql":{"fetch":{"url":{"staticVariableContent":"http://localhost:4001/graphql"},"method":"POST","body":{},"baseUrl":{},"path":{}},"subscription":{"enabled":true,"url":{"staticVariableContent":"http://localhost:4001/graphql"},"protocol":"GRAPHQL_SUBSCRIPTION_PROTOCOL_WS","websocketSubprotocol":"GRAPHQL_WEBSOCKET_SUBPROTOCOL_AUTO"},"federation":{"enabled":true,"serviceSdl":"extend schema\n @link(\n url: \"https://specs.apollo.dev/federation/v2.5\"\n import: [\n \"@authenticated\"\n \"@composeDirective\"\n \"@external\"\n \"@extends\"\n \"@inaccessible\"\n \"@interfaceObject\"\n \"@override\"\n \"@provides\"\n \"@key\"\n \"@requires\"\n \"@requiresScopes\"\n \"@shareable\"\n \"@tag\"\n ]\n )\n\nschema {\n query: Query\n mutation: Mutation\n}\n\ntype Query {\n projects: [Project!]!\n project(id: ID!): Project\n projectStatuses: [ProjectStatus!]!\n projectsByStatus(status: ProjectStatus!): [Project!]!\n\n # query to simulate that the service goes down\n killService: Boolean!\n panic: Boolean!\n}\n\ntype Mutation {\n addProject(project: ProjectInput!): Project!\n}\n\ninput ProjectInput {\n name: String!\n description: String\n startDate: String # ISO date\n endDate: String # ISO date\n status: ProjectStatus!\n}\n\ntype Project @key(fields: \"id\") {\n id: ID!\n name: String!\n description: String\n startDate: String # ISO date\n endDate: String # ISO date\n status: ProjectStatus!\n # Federated references:\n teamMembers: [Employee!]!\n relatedProducts: [Product!]! # from products subgraph\n # Project milestones or checkpoints\n milestoneIds: [String!] # Array of milestone identifiers\n}\n\nenum ProjectStatus {\n PLANNING\n ACTIVE\n COMPLETED\n ON_HOLD\n}\n\ntype Employee @key(fields: \"id\") {\n id: Int!\n # New field resolved by this subgraph:\n projects: [Project!]\n}\n\ntype Product @key(fields: \"upc\") {\n upc: String!\n # Projects contributing to this product:\n projects: [Project!]\n}\n"},"upstreamSchema":{"key":"d4e29ce3470092d20fc0b57cbff3c538e51bd6fd"}},"requestTimeoutSeconds":"10","id":"0","keys":[{"typeName":"Project","selectionSet":"id"},{"typeName":"Employee","selectionSet":"id"},{"typeName":"Product","selectionSet":"upc"}]}],"fieldConfigurations":[{"typeName":"Query","fieldName":"project","argumentsConfiguration":[{"name":"id","sourceType":"FIELD_ARGUMENT"}]},{"typeName":"Query","fieldName":"projectsByStatus","argumentsConfiguration":[{"name":"status","sourceType":"FIELD_ARGUMENT"}]},{"typeName":"Mutation","fieldName":"addProject","argumentsConfiguration":[{"name":"project","sourceType":"FIELD_ARGUMENT"}]}],"graphqlSchema":"schema {\n query: Query\n mutation: Mutation\n}\n\ntype Query {\n projects: [Project!]!\n project(id: ID!): Project\n projectStatuses: [ProjectStatus!]!\n projectsByStatus(status: ProjectStatus!): [Project!]!\n killService: Boolean!\n panic: Boolean!\n}\n\ntype Mutation {\n addProject(project: ProjectInput!): Project!\n}\n\ninput ProjectInput {\n name: String!\n description: String\n startDate: String\n endDate: String\n status: ProjectStatus!\n}\n\ntype Project {\n id: ID!\n name: String!\n description: String\n startDate: String\n endDate: String\n status: ProjectStatus!\n teamMembers: [Employee!]!\n relatedProducts: [Product!]!\n milestoneIds: [String!]\n}\n\nenum ProjectStatus {\n PLANNING\n ACTIVE\n COMPLETED\n ON_HOLD\n}\n\ntype Employee {\n id: Int!\n projects: [Project!]\n}\n\ntype Product {\n upc: String!\n projects: [Project!]\n}","stringStorage":{"d4e29ce3470092d20fc0b57cbff3c538e51bd6fd":"schema @link(url: \"https://specs.apollo.dev/federation/v2.5\", import: [\"@authenticated\", \"@composeDirective\", \"@external\", \"@extends\", \"@inaccessible\", \"@interfaceObject\", \"@override\", \"@provides\", \"@key\", \"@requires\", \"@requiresScopes\", \"@shareable\", \"@tag\"]) {\n query: Query\n mutation: Mutation\n}\n\ndirective @key(fields: openfed__FieldSet!, resolvable: Boolean = true) repeatable on INTERFACE | OBJECT\n\ndirective @link(as: String, for: link__Purpose, import: [link__Import], url: String!) repeatable on SCHEMA\n\ntype Employee @key(fields: \"id\") {\n id: Int!\n projects: [Project!]\n}\n\ntype Mutation {\n addProject(project: ProjectInput!): Project!\n}\n\ntype Product @key(fields: \"upc\") {\n projects: [Project!]\n upc: String!\n}\n\ntype Project @key(fields: \"id\") {\n description: String\n endDate: String\n id: ID!\n milestoneIds: [String!]\n name: String!\n relatedProducts: [Product!]!\n startDate: String\n status: ProjectStatus!\n teamMembers: [Employee!]!\n}\n\ninput ProjectInput {\n description: String\n endDate: String\n name: String!\n startDate: String\n status: ProjectStatus!\n}\n\nenum ProjectStatus {\n ACTIVE\n COMPLETED\n ON_HOLD\n PLANNING\n}\n\ntype Query {\n killService: Boolean!\n panic: Boolean!\n project(id: ID!): Project\n projectStatuses: [ProjectStatus!]!\n projects: [Project!]!\n projectsByStatus(status: ProjectStatus!): [Project!]!\n}\n\nscalar link__Import\n\nenum link__Purpose {\n EXECUTION\n SECURITY\n}\n\nscalar openfed__FieldSet"}},"version":"00000000-0000-0000-0000-000000000000","subgraphs":[{"id":"0","name":"schema","routingUrl":"http://localhost:4001/graphql"}],"compatibilityVersion":"1:{{$COMPOSITION__VERSION}}"} \ No newline at end of file diff --git a/cli/test/graph/federated-graph/fetch.test.ts b/cli/test/graph/federated-graph/fetch.test.ts new file mode 100644 index 0000000000..b3eaecca2f --- /dev/null +++ b/cli/test/graph/federated-graph/fetch.test.ts @@ -0,0 +1,134 @@ +import { readFile, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import { Command } from 'commander'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { createPromiseClient, createRouterTransport } from '@connectrpc/connect'; +import { PlatformService } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_connect'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import FetchCommand from '../../../src/commands/graph/federated-graph/commands/fetch.js'; +import { Client } from '../../../src/core/client/client.js'; +import { + FIXTURES_DIR_PATH, + ROUTER_SDL, + CLIENT_SDL, + mockFetchRouterConfig, + mockGenerateRouterToken, +} from '../../router/utils.js'; + +export const mockPlatformTransport = (splitConfigsEnabled: boolean) => + createRouterTransport(({ service }) => { + service(PlatformService, { + getFederatedGraphSDLByName(_) { + return { + response: { + code: EnumStatusCode.OK, + }, + sdl: ROUTER_SDL, + clientSchema: CLIENT_SDL, + }; + }, + getSubgraphSDLFromLatestComposition(_) { + return { + response: { + code: EnumStatusCode.OK, + }, + }; + }, + getFederatedGraphByName(_) { + return { + response: { + code: EnumStatusCode.OK, + }, + federatedGraph: { + id: 'id', + name: 'name', + composition: { + compositionId: 'compositionId', + compositionVersionId: 'compositionVersionId', + }, + }, + }; + }, + generateRouterToken(ctx) { + return mockGenerateRouterToken(splitConfigsEnabled, ctx); + }, + }); + }); + +describe('federated-graph fetch command tests', () => { + afterEach(vi.clearAllMocks); + + test('that router config matches expected snapshot when config splitting is disabled', async () => { + const client: Client = { + platform: createPromiseClient(PlatformService, mockPlatformTransport(false)), + }; + + global.fetch = vi.fn(mockFetchRouterConfig); + + let outputDir = join(tmpdir(), 'federated-graph-fetch'); + if (existsSync(outputDir)) { + await rm(outputDir, { recursive: true }); + } + + const program = new Command(); + + program.addCommand(FetchCommand({ client })); + await program.parseAsync(['fetch', 'fake-graph', '-o', outputDir], { + from: 'user', + }); + + outputDir = join(outputDir, 'fake-graph-default'); + expect(existsSync(outputDir)).toBe(true); + expect(existsSync(join(outputDir, 'cosmo-composition.yaml'))).toBe(true); + expect(existsSync(join(outputDir, 'supergraph'))).toBe(true); + expect(existsSync(join(outputDir, 'supergraph', 'cosmoConfig.json'))).toBe(true); + + // The output file must match the expected snapshot + const content = await readFile(join(outputDir, 'supergraph', 'cosmoConfig.json'), 'utf8'); + await expect(content).toMatchFileSnapshot(join(FIXTURES_DIR_PATH, 'router-compose', `router-config.json.snap`)); + }); + + test('that router config matches expected snapshot when config splitting is enabled ', async () => { + const client: Client = { + platform: createPromiseClient(PlatformService, mockPlatformTransport(true)), + }; + + global.fetch = vi.fn(mockFetchRouterConfig); + + let outputDir = join(tmpdir(), 'federated-graph-fetch-split'); + if (existsSync(outputDir)) { + await rm(outputDir, { recursive: true }); + } + + const program = new Command(); + + program.addCommand(FetchCommand({ client })); + await program.parseAsync(['fetch', 'fake-graph', '-o', outputDir], { + from: 'user', + }); + + outputDir = join(outputDir, 'fake-graph-default'); + expect(existsSync(outputDir)).toBe(true); + expect(existsSync(join(outputDir, 'cosmo-composition.yaml'))).toBe(true); + expect(existsSync(join(outputDir, 'cosmo-mapper.json'))).toBe(true); + expect(existsSync(join(outputDir, 'supergraph'))).toBe(true); + expect(existsSync(join(outputDir, 'supergraph', 'cosmoConfig.json'))).toBe(true); + expect(existsSync(join(outputDir, 'feature-flags'))).toBe(true); + expect(existsSync(join(outputDir, 'feature-flags', 'my-feature-flag.json'))).toBe(true); + + // All output files should match the snapshots + await expectSplitOutputMatchSnapshot(join(outputDir, 'supergraph', 'cosmoConfig.json'), 'router-config.json'); + await expectSplitOutputMatchSnapshot(join(outputDir, 'cosmo-mapper.json'), 'mapper.json'); + await expectSplitOutputMatchSnapshot( + join(outputDir, 'feature-flags', 'my-feature-flag.json'), + join('feature-flags', 'my-feature-flag.json'), + ); + }); +}); + +async function expectSplitOutputMatchSnapshot(file: string, name: string) { + const content = await readFile(file, 'utf8'); + await expect(content).toMatchFileSnapshot(join(FIXTURES_DIR_PATH, 'router-compose', 'split-config', `${name}.snap`)); +} diff --git a/cli/test/graph/federated-graph/recompose.test.ts b/cli/test/graph/federated-graph/recompose.test.ts index e9bb4ae429..d542dd6e8d 100644 --- a/cli/test/graph/federated-graph/recompose.test.ts +++ b/cli/test/graph/federated-graph/recompose.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, type MockInstance, test, vi } import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; import { runRecompose } from '../utils.js'; -describe('federated-graph recompose', () => { +describe('federated-graph recompose command tests', () => { let logSpy: MockInstance; beforeEach(() => { diff --git a/cli/test/router/compose.test.ts b/cli/test/router/compose.test.ts new file mode 100644 index 0000000000..ee8be6f6fd --- /dev/null +++ b/cli/test/router/compose.test.ts @@ -0,0 +1,74 @@ +import { readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { Command } from 'commander'; +import { describe, expect, test } from 'vitest'; +import { createPromiseClient, createRouterTransport } from '@connectrpc/connect'; +import { PlatformService } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_connect'; +import { resolve } from 'pathe'; +import ComposeCommand from '../../src/commands/router/commands/compose.js'; +import { Client } from '../../src/core/client/client.js'; +import { FIXTURES_DIR_PATH } from './utils.js'; + +export const mockPlatformTransport = () => + createRouterTransport(({ service }) => { + service(PlatformService, {}); + }); + +describe('router compose command tests', () => { + test('that generated router config matches expected snapshot when config splitting is disabled', async () => { + const client: Client = { + platform: createPromiseClient(PlatformService, mockPlatformTransport()), + }; + + const outputDir = join(tmpdir(), 'router-compose', randomUUID()); + const outputFile = join(outputDir, 'router-config.json'); + const program = new Command(); + + program.addCommand(ComposeCommand({ client })); + await program.parseAsync(['compose', '-i', resolve('./test/testdata/compose.yaml'), '-o', outputFile], { + from: 'user', + }); + + expect(existsSync(outputFile)).toBe(true); + + // The output file must match the expected snapshot + const content = await readFile(outputFile, 'utf8'); + await expect(content).toMatchFileSnapshot(join(FIXTURES_DIR_PATH, 'router-compose', `router-config.json.snap`)); + }); + + test('that generated router config matches expected snapshot when config splitting is enabled ', async () => { + const client: Client = { + platform: createPromiseClient(PlatformService, mockPlatformTransport()), + }; + + const outputDir = join(tmpdir(), 'router-compose-split', randomUUID()); + const program = new Command(); + + program.addCommand(ComposeCommand({ client })); + await program.parseAsync( + ['compose', '-i', resolve('./test/testdata/compose.yaml'), '-o', outputDir, '--split-configs-enabled'], + { + from: 'user', + }, + ); + + expect(existsSync(outputDir)).toBe(true); + expect(existsSync(join(outputDir, 'router-config.json'))).toBe(true); + expect(existsSync(join(outputDir, 'mapper.json'))).toBe(true); + expect(existsSync(join(outputDir, 'feature-flags'))).toBe(true); + expect(existsSync(join(outputDir, 'feature-flags', 'my-feature-flag.json'))).toBe(true); + + // All output files should match the snapshots + await expectSplitOutputMatchSnapshot(outputDir, 'router-config.json'); + await expectSplitOutputMatchSnapshot(outputDir, 'mapper.json'); + await expectSplitOutputMatchSnapshot(outputDir, join('feature-flags', 'my-feature-flag.json')); + }); +}); + +async function expectSplitOutputMatchSnapshot(outputDir: string, name: string) { + const content = await readFile(join(outputDir, name), 'utf8'); + await expect(content).toMatchFileSnapshot(join(FIXTURES_DIR_PATH, 'router-compose', 'split-config', `${name}.snap`)); +} diff --git a/cli/test/router/fetch.test.ts b/cli/test/router/fetch.test.ts new file mode 100644 index 0000000000..8d870e994f --- /dev/null +++ b/cli/test/router/fetch.test.ts @@ -0,0 +1,122 @@ +import { readFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import { Command } from 'commander'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { createPromiseClient, createRouterTransport } from '@connectrpc/connect'; +import { PlatformService } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_connect'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import FetchCommand from '../../src/commands/router/commands/fetch.js'; +import { Client } from '../../src/core/client/client.js'; +import { FIXTURES_DIR_PATH, ROUTER_SDL, CLIENT_SDL, mockFetchRouterConfig, mockGenerateRouterToken } from './utils.js'; + +export const mockPlatformTransport = (splitConfigsEnabled: boolean) => + createRouterTransport(({ service }) => { + service(PlatformService, { + getFederatedGraphSDLByName(_) { + return { + response: { + code: EnumStatusCode.OK, + }, + sdl: ROUTER_SDL, + clientSchema: CLIENT_SDL, + }; + }, + getSubgraphSDLFromLatestComposition(_) { + return { + response: { + code: EnumStatusCode.OK, + }, + }; + }, + getFederatedGraphByName(_) { + return { + response: { + code: EnumStatusCode.OK, + }, + federatedGraph: { + id: 'id', + name: 'name', + composition: { + compositionId: 'compositionId', + compositionVersionId: 'compositionVersionId', + }, + }, + }; + }, + generateRouterToken(ctx) { + return mockGenerateRouterToken(splitConfigsEnabled, ctx); + }, + }); + }); + +describe('router fetch command tests', () => { + afterEach(vi.clearAllMocks); + + test('that router config matches expected snapshot when config splitting is disabled', async () => { + const client: Client = { + platform: createPromiseClient(PlatformService, mockPlatformTransport(false)), + }; + + global.fetch = vi.fn(mockFetchRouterConfig); + + const outputDir = join(tmpdir(), 'router-fetch'); + const outputFile = join(outputDir, 'latest.json'); + if (!existsSync(outputDir)) { + await mkdir(outputDir, { recursive: true }); + } + + const program = new Command(); + + program.addCommand(FetchCommand({ client })); + await program.parseAsync(['fetch', 'fake-graph', '-o', outputFile], { + from: 'user', + }); + + expect(existsSync(outputFile)).toBe(true); + + // The output file must match the expected snapshot + const content = await readFile(outputFile, 'utf8'); + await expect(content).toMatchFileSnapshot(join(FIXTURES_DIR_PATH, 'router-compose', `router-config.json.snap`)); + }); + + test('that router config matches expected snapshot when config splitting is enabled ', async () => { + const client: Client = { + platform: createPromiseClient(PlatformService, mockPlatformTransport(true)), + }; + + global.fetch = vi.fn(mockFetchRouterConfig); + + const outputDir = join(tmpdir(), 'router-fetch-split'); + if (!existsSync(outputDir)) { + await mkdir(outputDir); + } + + const program = new Command(); + + program.addCommand(FetchCommand({ client })); + await program.parseAsync(['fetch', 'fake-graph', '-o', outputDir], { + from: 'user', + }); + + expect(existsSync(outputDir)).toBe(true); + expect(existsSync(join(outputDir, 'mapper.json'))).toBe(true); + expect(existsSync(join(outputDir, 'latest.json'))).toBe(true); + expect(existsSync(join(outputDir, 'feature-flags'))).toBe(true); + expect(existsSync(join(outputDir, 'feature-flags', 'my-feature-flag.json'))).toBe(true); + + // All output files should match the snapshots + await expectSplitOutputMatchSnapshot(join(outputDir, 'latest.json'), 'router-config.json'); + await expectSplitOutputMatchSnapshot(join(outputDir, 'mapper.json'), 'mapper.json'); + await expectSplitOutputMatchSnapshot( + join(outputDir, 'feature-flags', 'my-feature-flag.json'), + join('feature-flags', 'my-feature-flag.json'), + ); + }); +}); + +async function expectSplitOutputMatchSnapshot(file: string, name: string) { + const content = await readFile(file, 'utf8'); + await expect(content).toMatchFileSnapshot(join(FIXTURES_DIR_PATH, 'router-compose', 'split-config', `${name}.snap`)); +} diff --git a/cli/test/router/utils.test.ts b/cli/test/router/utils.test.ts new file mode 100644 index 0000000000..22896c7561 --- /dev/null +++ b/cli/test/router/utils.test.ts @@ -0,0 +1,37 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import { mkdir, rm } from 'node:fs/promises'; +import { beforeAll, describe, expect, test } from 'vitest'; +import { writeFeatureFlagConfigToFile, featureFlagsDir } from '../../src/commands/router/utils.js'; + +describe('writeFeatureFlagConfigToFile', () => { + const basePath = join(tmpdir(), featureFlagsDir); + beforeAll(async () => { + if (existsSync(basePath)) { + // Cleanup existing data, if any + await rm(basePath, { recursive: true }); + } + + await mkdir(basePath, { recursive: true }); + }); + + test('that when no special characters are found, it is written to the base path', async () => { + const name = 'feature-flag'; + + expect(existsSync(join(basePath, `${name}.json`))).toBe(false); + await writeFeatureFlagConfigToFile(basePath, name, ''); + expect(existsSync(join(basePath, `${name}.json`))).toBe(true); + }); + + test.each(['feature/name', 'feature.name', 'feature/../name', '../name'])( + 'that it throws when name contains invalid characters', + async (name) => { + expect(existsSync(join(basePath, `${name}.json`))).toBe(false); + await expect(async () => await writeFeatureFlagConfigToFile(basePath, name, '')).rejects.toThrowError( + `The feature flag name "${name}" contains invalid characters.`, + ); + expect(existsSync(join(basePath, `${name}.json`))).toBe(false); + }, + ); +}); diff --git a/cli/test/router/utils.ts b/cli/test/router/utils.ts new file mode 100644 index 0000000000..005cc90721 --- /dev/null +++ b/cli/test/router/utils.ts @@ -0,0 +1,77 @@ +import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import type { + GenerateRouterTokenRequest, + GenerateRouterTokenResponse, +} from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; +import { PartialMessage } from '@bufbuild/protobuf'; +import { join, resolve } from 'pathe'; + +export const FIXTURES_DIR_PATH = resolve('./test/fixtures'); + +export const ROUTER_SDL = 'type Query {\n users: [User]!\n}\n\ntype User {\n id: String @authenticated\n}'; +export const CLIENT_SDL = 'type Query {\n users: [User]!\n}\n\ntype User {\n id: String\n}'; + +export function mockGenerateRouterToken( + splitConfigsEnabled: boolean, + _: GenerateRouterTokenRequest, +): PartialMessage { + return { + response: { + code: EnumStatusCode.OK, + }, + /** + * This token was generated by jwt.io with the following claims: + * iss: 019e668c-b0f1-745c-82ac-d9ff15ab4c48 + * organization_id: 019e668d-680c-755a-9e36-e3d498480718 + * federated_graph_id: 019e668c-ef82-70be-a867-2af36b362b7d + * + * if `splitConfigsEnabled` is `true`, then the claim `features` is populated with `split-config-loading`; + * otherwise, the claim is not present in the token + */ + token: splitConfigsEnabled + ? 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzM4NCJ9.eyJpc3MiOiIwMTllNjY4Yy1iMGYxLTc0NWMtODJhYy1kOWZmMTVhYjRjNDgiLCJhdWQiOiJjb3NtbzpncmFwaC1rZXkiLCJmZWRlcmF0ZWRfZ3JhcGhfaWQiOiIwMTllNjY4Yy1lZjgyLTcwYmUtYTg2Ny0yYWYzNmIzNjJiN2QiLCJvcmdhbml6YXRpb25faWQiOiIwMTllNjY4ZC02ODBjLTc1NWEtOWUzNi1lM2Q0OTg0ODA3MTgiLCJmZWF0dXJlcyI6WyJzcGxpdC1jb25maWctbG9hZGluZyJdfQ.7GvXTrnK2H9jiKOTR2FMadQpIoRKF713IxSztgFCDntCqnzN-1_20jmqnk_wxK0Q' + : 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzM4NCJ9.eyJpc3MiOiIwMTllNjY4Yy1iMGYxLTc0NWMtODJhYy1kOWZmMTVhYjRjNDgiLCJhdWQiOiJjb3NtbzpncmFwaC1rZXkiLCJmZWRlcmF0ZWRfZ3JhcGhfaWQiOiIwMTllNjY4Yy1lZjgyLTcwYmUtYTg2Ny0yYWYzNmIzNjJiN2QiLCJvcmdhbml6YXRpb25faWQiOiIwMTllNjY4ZC02ODBjLTc1NWEtOWUzNi1lM2Q0OTg0ODA3MTgifQ.PPMNqh7EfTiTiLD9AydAf7CSg_LRXTkdAOy1SNMlLc0Pe58e4tNDPuDx3iWbdJ3m', + }; +} + +export const mockFetchRouterConfig: typeof fetch = async (info): Promise => { + let url: URL | undefined; + if (info instanceof URL) { + url = info; + } else if (typeof info === 'string') { + url = new URL(info); + } else if (info instanceof Request) { + url = new URL(info.url); + } + + let filePath: string | undefined; + if (url) { + if (url.pathname.endsWith('routerconfigs/latest.json')) { + filePath = join(FIXTURES_DIR_PATH, 'router-compose', 'router-config.json.snap'); + } else if (url.pathname.endsWith('manifest/latest.json')) { + filePath = join(FIXTURES_DIR_PATH, 'router-compose', 'split-config', 'router-config.json.snap'); + } else if (url.pathname.endsWith('manifest/mapper.json')) { + filePath = join(FIXTURES_DIR_PATH, 'router-compose', 'split-config', 'mapper.json.snap'); + } else if (url.pathname.endsWith('manifest/feature-flags/my-feature-flag.json')) { + filePath = join( + FIXTURES_DIR_PATH, + 'router-compose', + 'split-config', + 'feature-flags', + 'my-feature-flag.json.snap', + ); + } + } + + let body: ReadableStream | undefined; + if (filePath && existsSync(filePath)) { + body = new Blob([await readFile(filePath)]).stream(); + } + + return new Response(body, { + status: body ? 200 : 404, + statusText: body ? 'OK' : 'Not Found', + }); +}; diff --git a/cli/test/testdata/compose.yaml b/cli/test/testdata/compose.yaml new file mode 100644 index 0000000000..6bdfc3957f --- /dev/null +++ b/cli/test/testdata/compose.yaml @@ -0,0 +1,15 @@ +version: 1 +subgraphs: + - name: schema + routing_url: http://localhost:4001/graphql + schema: + file: ../fixtures/full-schema.graphql + +feature_flags: + - name: my-feature-flag + feature_graphs: + - name: feature-schema + subgraph_name: schema + routing_url: http://localhost:4001/graphql-feature-flag + schema: + file: ../fixtures/full-schema.graphql diff --git a/controlplane/src/core/bufservices/federated-graph/createFederatedGraphToken.ts b/controlplane/src/core/bufservices/federated-graph/createFederatedGraphToken.ts index 71e2bd0870..1e65c5f320 100644 --- a/controlplane/src/core/bufservices/federated-graph/createFederatedGraphToken.ts +++ b/controlplane/src/core/bufservices/federated-graph/createFederatedGraphToken.ts @@ -73,15 +73,7 @@ export function createFederatedGraphToken( } const orgRepo = new OrganizationRepository(logger, opts.db, opts.billingDefaultPlanId); - const splitConfigFeature = await orgRepo.getFeature({ - organizationId: authContext.organizationId, - featureId: 'split-config-loading', - }); - - const features: string[] = []; - if (splitConfigFeature?.enabled) { - features.push('split-config-loading'); - } + const features = await orgRepo.getOrganizationGraphTokenFeatures(authContext.organizationId); const tokenValue = await signJwtHS256({ secret: opts.jwtSecret, diff --git a/controlplane/src/core/bufservices/federated-graph/generateRouterToken.ts b/controlplane/src/core/bufservices/federated-graph/generateRouterToken.ts index 2a256df78c..9e4de64ed8 100644 --- a/controlplane/src/core/bufservices/federated-graph/generateRouterToken.ts +++ b/controlplane/src/core/bufservices/federated-graph/generateRouterToken.ts @@ -13,6 +13,7 @@ import { DefaultNamespace } from '../../repositories/NamespaceRepository.js'; import type { RouterOptions } from '../../routes.js'; import { enrichLogger, getLogger, handleError } from '../../util.js'; import { UnauthorizedError } from '../../errors/errors.js'; +import { OrganizationRepository } from '../../repositories/OrganizationRepository.js'; export function generateRouterToken( opts: RouterOptions, @@ -49,6 +50,9 @@ export function generateRouterToken( throw new UnauthorizedError(); } + const orgRepo = new OrganizationRepository(logger, opts.db, opts.billingDefaultPlanId); + const features = await orgRepo.getOrganizationGraphTokenFeatures(authContext.organizationId); + const token = await signJwtHS256({ secret: opts.jwtSecret, token: { @@ -57,6 +61,7 @@ export function generateRouterToken( aud: audiences.cosmoGraphKey, // to distinguish from other tokens organization_id: authContext.organizationId, exp: nowInSeconds() + 5 * 60, // 5 minutes + features: features.length > 0 ? features : undefined, }, }); diff --git a/controlplane/src/core/constants.ts b/controlplane/src/core/constants.ts index e965fb6012..17660a0f95 100644 --- a/controlplane/src/core/constants.ts +++ b/controlplane/src/core/constants.ts @@ -1,4 +1,5 @@ import * as z from 'zod'; +import { FeatureIds } from '../types/index.js'; export const hubUserAgent = 'cosmo-hub'; @@ -55,3 +56,5 @@ export const organizationSchema = z.object({ }); export const defaultRetentionLimitInDays = 7; + +export const graphTokenFeatures: FeatureIds[] = ['split-config-loading']; diff --git a/controlplane/src/core/repositories/OrganizationRepository.ts b/controlplane/src/core/repositories/OrganizationRepository.ts index bc4a7be1b1..a5a9beb52b 100644 --- a/controlplane/src/core/repositories/OrganizationRepository.ts +++ b/controlplane/src/core/repositories/OrganizationRepository.ts @@ -38,7 +38,11 @@ import { import Keycloak from '../services/Keycloak.js'; import { DeleteOrganizationQueue } from '../workers/DeleteOrganizationWorker.js'; import { BlobStorage } from '../blobstorage/index.js'; -import { delayForManualOrgDeletionInDays, delayForOrgAuditLogsDeletionInDays } from '../constants.js'; +import { + delayForManualOrgDeletionInDays, + delayForOrgAuditLogsDeletionInDays, + graphTokenFeatures, +} from '../constants.js'; import { DeleteOrganizationAuditLogsQueue } from '../workers/DeleteOrganizationAuditLogsWorker.js'; import { RBACEvaluator } from '../services/RBACEvaluator.js'; import { traced } from '../tracing.js'; @@ -1712,4 +1716,17 @@ export class OrganizationRepository { }), }; } + + async getOrganizationGraphTokenFeatures(organizationId: string): Promise { + const features: string[] = []; + + const orgFeatures = await this.getFeatures({ organizationId }); + for (const feature of orgFeatures) { + if (feature.enabled && graphTokenFeatures.includes(feature.id)) { + features.push('split-config-loading'); + } + } + + return features; + } } diff --git a/controlplane/src/core/services/CompositionService.ts b/controlplane/src/core/services/CompositionService.ts index 07cf224b75..e35ee3187f 100644 --- a/controlplane/src/core/services/CompositionService.ts +++ b/controlplane/src/core/services/CompositionService.ts @@ -644,6 +644,7 @@ export class CompositionService { result, composer, baseCompositionData, + isFeatureFlagComposition, }); if (baseCompositionFailed) { @@ -1173,6 +1174,7 @@ export class CompositionService { result, composer, baseCompositionData, + isFeatureFlagComposition, }: { actorId: string; federatedGraph: FederatedGraphDTO; @@ -1180,6 +1182,7 @@ export class CompositionService { result: ComposeAndDeployResult; composer: Composer; baseCompositionData: BaseCompositionData; + isFeatureFlagComposition: boolean; }): Promise<{ baseCompositionFailed: boolean; federatedSchemaVersionId: string; @@ -1260,6 +1263,10 @@ export class CompositionService { ); } + if (isFeatureFlagComposition) { + baseCompositionData.schemaVersionId = baseComposition.schemaVersionId; + } + baseCompositionData.featureFlagRouterExecutionConfigByFeatureFlagName.set( compositionResult.featureFlagName, routerConfigToFeatureFlagExecutionConfig(routerExecutionConfig), @@ -1333,6 +1340,7 @@ export class CompositionService { result, composer, baseCompositionData, + isFeatureFlagComposition, }); if (baseCompositionFailed) { @@ -1456,6 +1464,7 @@ export class CompositionService { await this.deployFeatureFlags( actorId, graph, + baseCompositionData.schemaVersionId ?? '', baseCompositionData.featureFlagRouterExecutionConfigByFeatureFlagName, composer, result, @@ -1589,6 +1598,7 @@ export class CompositionService { await this.deployFeatureFlags( actorId, graph, + schemaVersionId, featureFlagRouterExecutionConfigByFeatureFlagName, composer, result, @@ -1599,6 +1609,7 @@ export class CompositionService { private async deployFeatureFlags( actorId: string, graph: FederatedGraphDTO, + baseCompositionSchemaVersionId: string, featureFlagRouterExecutionConfigByFeatureFlagName: Map, composer: Composer, result: ComposeAndDeployResult, @@ -1619,7 +1630,7 @@ export class CompositionService { jwtSecret: this.admissionConfig.webhookJWTSecret, }, baseCompositionRouterExecutionConfig: routerExecutionConfig, - baseCompositionSchemaVersionId: '', + baseCompositionSchemaVersionId, blobStorage: this.blobStorage, featureFlagRouterExecutionConfigByFeatureFlagName: new Map(), federatedGraphId: graph.id, diff --git a/docs-website/cli/router/compose.mdx b/docs-website/cli/router/compose.mdx index 1d8349bcec..b12d026f72 100644 --- a/docs-website/cli/router/compose.mdx +++ b/docs-website/cli/router/compose.mdx @@ -26,6 +26,12 @@ The `npx wgc router compose` command allows you to compose subgraphs and build a * `--suppress-warnings`: This flag suppresses any warnings produced by composition. +* `--split-configs-enabled`: This flag enables splitting the router config into multiple files. + + + Note: The `--split-configs-enabled` flag requires Router version [0.315.0](https://github.com/wundergraph/cosmo/releases/tag/router%400.315.0) or higher. + + ## Input file structure ```bash @@ -88,3 +94,4 @@ Compose subgraphs mentioned in graph.yaml and write it to `router.json` * The `npx wgc router compose` command does not interact with the control plane and completely runs locally. +* When using the `--split-configs-enabled` option, the `--out` is always treated as a directory.