From a8cc87cbc3453f5dfbb7ff46c655a5d6b897c32d Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Tue, 26 May 2026 13:35:18 -0400 Subject: [PATCH 01/28] feat: add support for config splitting --- cli/src/commands/auth/utils.ts | 3 + .../graph/federated-graph/commands/fetch.ts | 15 +- cli/src/commands/router/commands/compose.ts | 39 +- cli/src/commands/router/commands/fetch.ts | 126 +++--- cli/src/commands/router/utils.ts | 143 +++++++ .../createFederatedGraphToken.ts | 10 +- .../federated-graph/generateRouterToken.ts | 5 + controlplane/src/core/constants.ts | 3 + .../repositories/FederatedGraphRepository.ts | 395 +----------------- .../repositories/OrganizationRepository.ts | 19 +- .../src/core/services/CompositionService.ts | 12 +- 11 files changed, 282 insertions(+), 488 deletions(-) create mode 100644 cli/src/commands/router/utils.ts diff --git a/cli/src/commands/auth/utils.ts b/cli/src/commands/auth/utils.ts index d03c9b52c0..99b5a14544 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..a525598140 100644 --- a/cli/src/commands/graph/federated-graph/commands/fetch.ts +++ b/cli/src/commands/graph/federated-graph/commands/fetch.ts @@ -4,7 +4,8 @@ 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 { fetchRouterConfig } from '../../../router/utils.js'; export default (opts: BaseCommandOptions) => { const cmd = new Command('fetch'); @@ -49,7 +50,17 @@ export default (opts: BaseCommandOptions) => { name, namespace: options.namespace, }); - writeFileSync(join(superGraphPath, `cosmoConfig.json`), routerConfig); + writeFileSync(join(superGraphPath, `cosmoConfig.json`), routerConfig.routerConfig); + if (routerConfig.featureFlags?.size) { + const featureFlagsPath = join(basePath, 'feature-flags'); + if (!existsSync(featureFlagsPath)) { + mkdirSync(featureFlagsPath, { recursive: true }); + } + + for (const [featureFlagName, featureFlagConfig] of routerConfig.featureFlags) { + writeFileSync(join(featureFlagsPath, `${featureFlagName}.json`), featureFlagConfig); + } + } writeFileSync(join(superGraphPath, `cosmoSchema.graphql`), fedGraphSchemas.sdl); diff --git a/cli/src/commands/router/commands/compose.ts b/cli/src/commands/router/commands/compose.ts index 5f9790d2e6..b8ab467695 100644 --- a/cli/src/commands/router/commands/compose.ts +++ b/cli/src/commands/router/commands/compose.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { readFile, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { buildRouterConfig, type ComposedSubgraph, @@ -15,13 +15,14 @@ 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'; @@ -176,6 +177,7 @@ 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.'); command.action(async (options) => { const inputFile = resolve(options.input); @@ -187,6 +189,13 @@ export default (opts: BaseCommandOptions) => { ); } + if (options.out) { + options.out = resolve(options.out); + if (!existsSync(options.out)) { + await mkdir(options.out, { recursive: true }); + } + } + const fileContent = (await readFile(inputFile)).toString(); const config = yaml.load(fileContent) as Config; @@ -265,11 +274,33 @@ export default (opts: BaseCommandOptions) => { if (config.feature_flags && config.feature_flags.length > 0) { const ffConfigs = await buildFeatureFlagsConfig(config, inputFileLocation, subgraphs, options); - routerConfig.featureFlagConfigs = ffConfigs; + if (!options.splitConfigsEnabled) { + routerConfig.featureFlagConfigs = ffConfigs; + } else if (ffConfigs.configByFeatureFlagName && options.out) { + const outDir = join(options.out, 'feature-flags'); + if (!existsSync(outDir)) { + await mkdir(outDir, { recursive: true }); + } + + 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, + }); + + await writeFile(join(outDir, `${featureFlagName}.json`), ffRouterConfig.toJsonString()); + } + } } if (options.out) { - await writeFile(options.out, routerConfig.toJsonString()); + await writeFile( + options.splitConfigsEnabled ? join(options.out, 'router-config.json') : options.out, + routerConfig.toJsonString(), + ); + console.log(pc.green(`Router config successfully written to ${pc.bold(options.out)}`)); } else { console.log(routerConfig.toJsonString()); diff --git a/cli/src/commands/router/commands/fetch.ts b/cli/src/commands/router/commands/fetch.ts index df6524adf6..cd9a934fd9 100644 --- a/cli/src/commands/router/commands/fetch.ts +++ b/cli/src/commands/router/commands/fetch.ts @@ -1,19 +1,42 @@ -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 } from 'node:fs/promises'; +import { Command } from 'commander'; import pc from 'picocolors'; import { resolve } from 'pathe'; -import { getBaseHeaders, config } from '../../../core/config.js'; import { BaseCommandOptions } from '../../../core/types/types.js'; -import { GraphToken } from '../../auth/utils.js'; -import { makeSignature, safeCompare } from '../../../core/signature.js'; +import { fetchRouterConfig, type FetchRouterConfigResult } from '../utils.js'; -export const handleOutput = async (out: string | undefined, config: string) => { +export const handleOutput = async ( + out: string | undefined, + graphSignKey: string | undefined, + config: FetchRouterConfigResult, +) => { if (out) { - await writeFile(resolve(out), config ?? ''); + if (config.splitConfigLoading) { + let directory = resolve(out); + await mkdir(directory, { recursive: true }); + await writeFile(resolve(directory, 'latest.json'), config.routerConfig); + + if (config.featureFlags && config.featureFlags.size > 0) { + directory = resolve(directory, 'feature-flags'); + await mkdir(directory, { recursive: true }); + + for (const [featureFlagName, featureFlagRouterConfig] of config.featureFlags) { + await writeFile(resolve(directory, `${featureFlagName}.json`), featureFlagRouterConfig); + } + } + } else { + await writeFile(resolve(out), config.routerConfig); + } + + if (graphSignKey) { + console.log(pc.green('The signature of the router config matches the local computed signature.')); + } + + console.log( + pc.green(`The router config${config.splitConfigLoading ? 's' : ''} has been written to ${pc.bold(out)}`), + ); } else { - console.log(config); + console.log(config.routerConfig); } }; @@ -30,82 +53,27 @@ 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, - namespace: options.namespace, - }, - { - headers: getBaseHeaders(), - }, - ); - - 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))); - } - process.exitCode = 1; - return; - } - - 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 body = await response.text(); + const result = await fetchRouterConfig({ + client: opts.client, + name, + namespace: options.namespace, + graphSignKey: options.graphSignKey, + }); - 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 handleOutput(options.out, options.graphSignKey, result); + if (options.graphSignKey) { + console.log(pc.green('The signature of the router config matches the local computed signature.')); } - const hash = await makeSignature(body, options.graphSignKey); - - 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; + process.exit(0); + } catch (err) { + if (err instanceof Error) { + console.error(err.message); } - if (options.out) { - await handleOutput(options.out, body); - - 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)}`)); - - return; - } + process.exitCode = 1; } - - await handleOutput(options.out, body); }); return command; diff --git a/cli/src/commands/router/utils.ts b/cli/src/commands/router/utils.ts new file mode 100644 index 0000000000..0a2403b705 --- /dev/null +++ b/cli/src/commands/router/utils.ts @@ -0,0 +1,143 @@ +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +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'; +import { makeSignature, safeCompare } from '../../core/signature.js'; + +export interface FetchRouterConfigResult { + splitConfigLoading: boolean; + routerConfig: string; + featureFlags?: Map; +} + +export const fetchRouterConfig = async ({ + client, + name, + namespace, + graphSignKey, +}: { + client: Client; + name: string; + namespace?: string; + graphSignKey?: string; +}): 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 `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(); + + mapper.delete(''); // Delete the federated graph hash + + // Retrieve the latest router configuration + const result: FetchRouterConfigResult = { + splitConfigLoading: true, + routerConfig: await fetchFileContentFromCdn( + new URL('routerconfigs/latest.json', baseUrl), + resp.token, + graphSignKey, + ), + }; + + 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; +}; + +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/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..0cf2609ec7 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 featuresToSurfaceWithGraphToken: FeatureIds[] = ['split-config-loading']; diff --git a/controlplane/src/core/repositories/FederatedGraphRepository.ts b/controlplane/src/core/repositories/FederatedGraphRepository.ts index c9a7c1bd52..e86b90c34d 100644 --- a/controlplane/src/core/repositories/FederatedGraphRepository.ts +++ b/controlplane/src/core/repositories/FederatedGraphRepository.ts @@ -1,12 +1,5 @@ /* eslint-disable no-labels */ -import { KeyObject, randomUUID } from 'node:crypto'; -import { PlainMessage } from '@bufbuild/protobuf'; -import { FeatureFlagRouterExecutionConfig } from '@wundergraph/cosmo-connect/dist/node/v1/node_pb'; -import { - CompositionError, - CompositionWarning, - DeploymentError, -} from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; +import { KeyObject } from 'node:crypto'; import { joinLabel, normalizeURL } from '@wundergraph/cosmo-shared'; import { and, @@ -28,10 +21,9 @@ import { } from 'drizzle-orm'; import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { FastifyBaseLogger } from 'fastify'; -import { parse } from 'graphql'; import { generateKeyPair, importPKCS8, SignJWT } from 'jose'; import { uid } from 'uid/secure'; -import { CompositionOptions, Warning } from '@wundergraph/composition'; +import type { Warning } from '@wundergraph/composition'; import * as schema from '../../db/schema.js'; import { federatedGraphs, @@ -55,30 +47,13 @@ import { RouterRequestKeysDTO, ComposeAndDeployResult, } from '../../types/index.js'; -import { BlobStorage } from '../blobstorage/index.js'; -import { - BaseCompositionData, - CompositionSubgraphRecord, - Composer, - ContractBaseCompositionData, - routerConfigToFeatureFlagExecutionConfig, - RouterConfigUploadError, -} from '../composition/composer.js'; -import { - composeGraphsInWorker, - deserializeComposedGraphArtifact, - deserializeRouterExecutionConfig, -} from '../composition/composeGraphs.pool.js'; +import { CompositionSubgraphRecord } from '../composition/composer.js'; import { SchemaDiff } from '../composition/schemaCheck.js'; -import { AdmissionError } from '../services/AdmissionWebhookController.js'; import { checkIfLabelMatchersChanged, normalizeLabelMatchers, normalizeLabels } from '../util.js'; -import { unsuccessfulBaseCompositionError } from '../errors/errors.js'; -import { ClickHouseClient } from '../clickhouse/index.js'; import { RBACEvaluator } from '../services/RBACEvaluator.js'; import { traced } from '../tracing.js'; import type { CompositionService } from '../services/CompositionService.js'; import { ContractRepository } from './ContractRepository.js'; -import { FeatureFlagRepository, SubgraphsToCompose } from './FeatureFlagRepository.js'; import { GraphCompositionRepository } from './GraphCompositionRepository.js'; import { SubgraphRepository } from './SubgraphRepository.js'; import { TargetRepository } from './TargetRepository.js'; @@ -1415,370 +1390,6 @@ export class FederatedGraphRepository { }); } - /** - * This method recomposes and deploys federated graphs and their respective contract graphs. - */ - public composeAndDeployGraphs({ - actorId, - admissionConfig, - compositionOptions, - chClient, - blobStorage, - federatedGraphs, - webhookProxyUrl, - }: { - actorId: string; - admissionConfig: { - webhookJWTSecret: string; - cdnBaseUrl: string; - }; - blobStorage: BlobStorage; - chClient: ClickHouseClient; - federatedGraphs: FederatedGraphDTO[]; - compositionOptions?: CompositionOptions; - webhookProxyUrl?: string; - }) { - return this.db.transaction(async (tx) => { - const subgraphRepo = new SubgraphRepository(this.logger, tx, this.organizationId); - const fedGraphRepo = new FederatedGraphRepository(this.logger, tx, this.organizationId); - const contractRepo = new ContractRepository(this.logger, tx, this.organizationId); - const featureFlagRepo = new FeatureFlagRepository(this.logger, tx, this.organizationId); - const graphCompositionRepo = new GraphCompositionRepository(this.logger, tx); - const composer = new Composer( - this.logger, - this.db, - fedGraphRepo, - subgraphRepo, - contractRepo, - graphCompositionRepo, - chClient, - webhookProxyUrl, - ); - - const allDeploymentErrors: PlainMessage[] = []; - const allCompositionErrors: PlainMessage[] = []; - const allCompositionWarnings: PlainMessage[] = []; - - parentLoop: for (const federatedGraph of federatedGraphs) { - // Get published subgraphs for recomposition of the federated graph - const subgraphs = await subgraphRepo.listByFederatedGraph({ - federatedGraphTargetId: federatedGraph.targetId, - published: true, - }); - - const contracts = await contractRepo.bySourceFederatedGraphId(federatedGraph.id); - const tagOptionsByContractName = contracts.map((contract) => ({ - contractName: contract.downstreamFederatedGraph.target.name, - excludeTags: contract.excludeTags, - includeTags: contract.includeTags, - })); - - const baseCompositionSubgraphs = subgraphs.map((s) => ({ - name: s.name, - url: s.routingUrl, - definitions: parse(s.schemaSDL), - })); - - // Collects the base graph and applicable feature flag related graphs - const allSubgraphsToCompose: SubgraphsToCompose[] = await featureFlagRepo.getSubgraphsToCompose({ - baseSubgraphs: subgraphs, - baseCompositionSubgraphs, - fedGraphLabelMatchers: federatedGraph.labelMatchers, - }); - - const { results } = await composeGraphsInWorker({ - federatedGraph, - subgraphsToCompose: allSubgraphsToCompose.map((subgraphsToCompose) => ({ - subgraphs: subgraphsToCompose.subgraphs, - isFeatureFlagComposition: subgraphsToCompose.isFeatureFlagComposition, - featureFlagName: subgraphsToCompose.featureFlagName, - featureFlagId: subgraphsToCompose.featureFlagId, - })), - tagOptionsByContractName, - compositionOptions, - }); - - /* baseCompositionData contains the router execution config and the schema version ID for the source graph - * base composition (not a contract or feature flag composition) - * */ - const baseCompositionData: BaseCompositionData = { - featureFlagRouterExecutionConfigByFeatureFlagName: new Map(), - }; - - /* Map of the contract base composition schema version ID, router execution config, - * and any feature flag schema version IDs by contract ID */ - const contractBaseCompositionDataByContractId = new Map(); - - for (const compositionResult of results) { - if (!compositionResult.base.success) { - // Collect all composition errors - allCompositionErrors.push( - ...compositionResult.base.errors.map((message) => ({ - federatedGraphName: federatedGraph.name, - namespace: federatedGraph.namespace, - message, - featureFlag: compositionResult.featureFlagName || '', - })), - ); - } - - // Collect all composition warnings - allCompositionWarnings.push( - ...compositionResult.base.warnings.map((warning) => ({ - federatedGraphName: federatedGraph.name, - namespace: federatedGraph.namespace, - message: warning.message, - featureFlag: compositionResult.featureFlagName || '', - })), - ); - - if ( - !compositionResult.isFeatureFlagComposition && - !compositionResult.base.success && - !federatedGraph.contract - ) { - allCompositionErrors.push(unsuccessfulBaseCompositionError(federatedGraph.name, federatedGraph.namespace)); - } - - const federatedSchemaVersionId = randomUUID(); - const baseComposedGraph = deserializeComposedGraphArtifact(federatedGraph, compositionResult.base); - let routerExecutionConfig; - if (compositionResult.base.success) { - if (!compositionResult.base.routerExecutionConfigJson) { - throw new Error( - `Successful composition for federated graph "${federatedGraph.name}" does not contain a router execution config.`, - ); - } - - routerExecutionConfig = deserializeRouterExecutionConfig(compositionResult.base.routerExecutionConfigJson); - } - - if (routerExecutionConfig) { - routerExecutionConfig.version = federatedSchemaVersionId; - } - - const baseComposition = await composer.saveComposition({ - composedGraph: baseComposedGraph, - composedById: actorId, - isFeatureFlagComposition: compositionResult.isFeatureFlagComposition, - federatedSchemaVersionId, - routerExecutionConfig, - featureFlagId: compositionResult.featureFlagId, - }); - - if (!compositionResult.base.success || !baseComposition.schemaVersionId) { - /* If the base composition failed to compose or deploy, return to the parent loop, because - * contracts are not composed if the base composition fails. - */ - if (!compositionResult.isFeatureFlagComposition) { - continue parentLoop; - } - // Record the feature flag composition to upload (if there are no errors) - } else if (compositionResult.isFeatureFlagComposition) { - if (!routerExecutionConfig) { - throw new Error( - `Successful feature flag composition for federated graph "${federatedGraph.name}" does not contain a router execution config.`, - ); - } - baseCompositionData.featureFlagRouterExecutionConfigByFeatureFlagName.set( - compositionResult.featureFlagName, - routerConfigToFeatureFlagExecutionConfig(routerExecutionConfig), - ); - // Otherwise, this is the base composition, so store the schema version id - } else { - if (!routerExecutionConfig) { - throw new Error( - `Successful composition for federated graph "${federatedGraph.name}" does not contain a router execution config.`, - ); - } - baseCompositionData.schemaVersionId = baseComposition.schemaVersionId; - baseCompositionData.routerExecutionConfig = routerExecutionConfig; - } - - // If there are no contracts, there is nothing further to do - if (compositionResult.contracts.length === 0) { - continue; - } - - for (const { contractName, artifact } of compositionResult.contracts) { - const contractGraph = await fedGraphRepo.byName(contractName, federatedGraph.namespace); - if (!contractGraph) { - throw new Error(`The contract graph "${contractName}" was not found.`); - } - if (!artifact.success) { - allCompositionErrors.push( - ...artifact.errors.map((message) => ({ - federatedGraphName: contractGraph.name, - namespace: contractGraph.namespace, - message, - featureFlag: compositionResult.featureFlagName, - })), - ); - } - - allCompositionWarnings.push( - ...artifact.warnings.map((warning) => ({ - federatedGraphName: contractGraph.name, - namespace: contractGraph.namespace, - message: warning.message, - featureFlag: compositionResult.featureFlagName, - })), - ); - - const contractSchemaVersionId = randomUUID(); - const contractComposedGraph = deserializeComposedGraphArtifact(contractGraph, artifact); - let contractRouterExecutionConfig; - if (artifact.success) { - if (!artifact.routerExecutionConfigJson) { - throw new Error( - `Successful contract composition for federated graph "${contractGraph.name}" does not contain a router execution config.`, - ); - } - contractRouterExecutionConfig = deserializeRouterExecutionConfig(artifact.routerExecutionConfigJson); - if (!contractRouterExecutionConfig) { - throw new Error( - `Successful contract composition for federated graph "${contractGraph.name}" did not produce a router execution config.`, - ); - } - contractRouterExecutionConfig.version = contractSchemaVersionId; - } - - const contractComposition = await composer.saveComposition({ - composedGraph: contractComposedGraph, - composedById: actorId, - isFeatureFlagComposition: compositionResult.isFeatureFlagComposition, - federatedSchemaVersionId: contractSchemaVersionId, - routerExecutionConfig: contractRouterExecutionConfig, - featureFlagId: compositionResult.featureFlagId, - }); - - if (!artifact.success || !contractComposition.schemaVersionId) { - continue; - } - if (!contractRouterExecutionConfig) { - throw new Error( - `Successful contract composition for federated graph "${contractGraph.name}" did not produce a router execution config.`, - ); - } - - /* If the base composition for which this contract has been made is NOT a feature flag composition, - * it must be the contract base composition, which must always be uploaded. - * The base composition is always the first item in the subgraphsToCompose array. - * */ - if (!compositionResult.isFeatureFlagComposition) { - contractBaseCompositionDataByContractId.set(contractGraph.id, { - schemaVersionId: contractComposition.schemaVersionId, - routerExecutionConfig: contractRouterExecutionConfig, - featureFlagRouterExecutionConfigByFeatureFlagName: new Map(), - }); - continue; - } - - /* If the contract has a feature flag, get the current array feature flag versions (or set a new one), - * and then push the current schema version to the array - * */ - const existingContractBaseCompositionData = contractBaseCompositionDataByContractId.get(contractGraph.id); - /* If the existingContractSchemaVersions is undefined, it means the contract base composition failed. - * In this case, simply continue, because when iterating a feature flag for the source graph composition, - * there may not be any errors for the feature flag. - * */ - if (!existingContractBaseCompositionData) { - continue; - } - existingContractBaseCompositionData.featureFlagRouterExecutionConfigByFeatureFlagName.set( - compositionResult.featureFlagName, - routerConfigToFeatureFlagExecutionConfig(contractRouterExecutionConfig), - ); - } - } - - const federatedGraphDTO = await this.byId(federatedGraph.id); - if (!federatedGraphDTO) { - throw new Error(`Fatal: The federated graph "${federatedGraph.name}" was not found.`); - } - if (!baseCompositionData.routerExecutionConfig) { - throw new Error( - `Fatal: The latest router execution config for federated graph "${federatedGraph.name}" was not generated.`, - ); - } - if (!baseCompositionData.schemaVersionId) { - throw new Error( - `Fatal: The latest base composition for federated graph "${federatedGraph.name}" was not found.`, - ); - } - - const { errors: uploadErrors } = await composer.composeAndUploadRouterConfig({ - federatedGraphId: federatedGraphDTO.id, - featureFlagRouterExecutionConfigByFeatureFlagName: - baseCompositionData.featureFlagRouterExecutionConfigByFeatureFlagName, - blobStorage, - organizationId: this.organizationId, - admissionConfig: { - cdnBaseUrl: admissionConfig.cdnBaseUrl, - jwtSecret: admissionConfig.webhookJWTSecret, - }, - baseCompositionRouterExecutionConfig: baseCompositionData.routerExecutionConfig, - baseCompositionSchemaVersionId: baseCompositionData.schemaVersionId, - federatedGraphAdmissionWebhookURL: federatedGraphDTO.admissionWebhookURL, - federatedGraphAdmissionWebhookSecret: federatedGraphDTO.admissionWebhookSecret, - actorId, - }); - - allDeploymentErrors.push( - ...uploadErrors - .filter((e) => e instanceof AdmissionError || e instanceof RouterConfigUploadError) - .map((e) => ({ - federatedGraphName: federatedGraph.name, - namespace: federatedGraph.namespace, - message: e.message ?? '', - })), - ); - - for (const [ - contractId, - { featureFlagRouterExecutionConfigByFeatureFlagName, schemaVersionId, routerExecutionConfig }, - ] of contractBaseCompositionDataByContractId) { - const contractDTO = await this.byId(contractId); - if (!contractDTO) { - throw new Error(`Unexpected: Contract graph with id "${contractId}" not found after latest composition`); - } - - const { errors: uploadErrors } = await composer.composeAndUploadRouterConfig({ - admissionConfig: { - cdnBaseUrl: admissionConfig.cdnBaseUrl, - jwtSecret: admissionConfig.webhookJWTSecret, - }, - baseCompositionRouterExecutionConfig: routerExecutionConfig, - baseCompositionSchemaVersionId: schemaVersionId, - blobStorage, - featureFlagRouterExecutionConfigByFeatureFlagName, - federatedGraphId: contractDTO.id, - organizationId: this.organizationId, - federatedGraphAdmissionWebhookURL: contractDTO.admissionWebhookURL, - federatedGraphAdmissionWebhookSecret: contractDTO.admissionWebhookSecret, - actorId, - }); - - allDeploymentErrors.push( - ...uploadErrors - .filter((e) => e instanceof AdmissionError || e instanceof RouterConfigUploadError) - .map((e) => ({ - federatedGraphName: federatedGraph.name, - namespace: federatedGraph.namespace, - message: e.message ?? '', - })), - ); - } - } - - return { - compositionErrors: allCompositionErrors, - deploymentErrors: allDeploymentErrors, - compositionWarnings: allCompositionWarnings, - }; - }); - } - public updateRouterCompatibilityVersion(id: string, version: string) { return this.db .update(federatedGraphs) diff --git a/controlplane/src/core/repositories/OrganizationRepository.ts b/controlplane/src/core/repositories/OrganizationRepository.ts index 2ceb5c86cb..d0b95b8893 100644 --- a/controlplane/src/core/repositories/OrganizationRepository.ts +++ b/controlplane/src/core/repositories/OrganizationRepository.ts @@ -37,7 +37,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, + featuresToSurfaceWithGraphToken, +} from '../constants.js'; import { DeleteOrganizationAuditLogsQueue } from '../workers/DeleteOrganizationAuditLogsWorker.js'; import { RBACEvaluator } from '../services/RBACEvaluator.js'; import { traced } from '../tracing.js'; @@ -1701,4 +1705,17 @@ export class OrganizationRepository { }), }; } + + async getOrganizationGraphTokenFeatures(organizationId: string): Promise { + const features: string[] = []; + + const orgFeatures = await this.getFeatures({ organizationId }); + for (const feature of orgFeatures) { + if (featuresToSurfaceWithGraphToken.includes(feature.id) && feature.enabled) { + features.push('split-config-loading'); + } + } + + return features; + } } diff --git a/controlplane/src/core/services/CompositionService.ts b/controlplane/src/core/services/CompositionService.ts index 638d3986bf..d70f4e5dca 100644 --- a/controlplane/src/core/services/CompositionService.ts +++ b/controlplane/src/core/services/CompositionService.ts @@ -628,6 +628,7 @@ export class CompositionService { result, composer, baseCompositionData, + isFeatureFlagComposition, }: { actorId: string; federatedGraph: FederatedGraphDTO; @@ -635,6 +636,7 @@ export class CompositionService { result: ComposeAndDeployResult; composer: Composer; baseCompositionData: BaseCompositionData; + isFeatureFlagComposition: boolean; }): Promise<{ baseCompositionFailed: boolean; federatedSchemaVersionId: string; @@ -715,6 +717,10 @@ export class CompositionService { ); } + if (isFeatureFlagComposition) { + baseCompositionData.schemaVersionId = baseComposition.schemaVersionId; + } + baseCompositionData.featureFlagRouterExecutionConfigByFeatureFlagName.set( compositionResult.featureFlagName, routerConfigToFeatureFlagExecutionConfig(routerExecutionConfig), @@ -788,6 +794,7 @@ export class CompositionService { result, composer, baseCompositionData, + isFeatureFlagComposition, }); if (baseCompositionFailed) { @@ -911,6 +918,7 @@ export class CompositionService { await this.#deployFeatureFlags( actorId, graph, + baseCompositionData.schemaVersionId ?? '', baseCompositionData.featureFlagRouterExecutionConfigByFeatureFlagName, composer, result, @@ -1044,6 +1052,7 @@ export class CompositionService { await this.#deployFeatureFlags( actorId, graph, + schemaVersionId, featureFlagRouterExecutionConfigByFeatureFlagName, composer, result, @@ -1054,6 +1063,7 @@ export class CompositionService { async #deployFeatureFlags( actorId: string, graph: FederatedGraphDTO, + baseCompositionSchemaVersionId: string, featureFlagRouterExecutionConfigByFeatureFlagName: Map, composer: Composer, result: ComposeAndDeployResult, @@ -1074,7 +1084,7 @@ export class CompositionService { jwtSecret: this.admissionConfig.webhookJWTSecret, }, baseCompositionRouterExecutionConfig: routerExecutionConfig, - baseCompositionSchemaVersionId: '', + baseCompositionSchemaVersionId, blobStorage: this.blobStorage, featureFlagRouterExecutionConfigByFeatureFlagName: new Map(), federatedGraphId: graph.id, From 4b3f6de5d6b95749cf5a5fc867532af4bf4422e6 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Tue, 26 May 2026 13:43:10 -0400 Subject: [PATCH 02/28] chore: update documentation --- docs-website/cli/router/compose.mdx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs-website/cli/router/compose.mdx b/docs-website/cli/router/compose.mdx index 1d8349bcec..ad617cc680 100644 --- a/docs-website/cli/router/compose.mdx +++ b/docs-website/cli/router/compose.mdx @@ -26,6 +26,8 @@ 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. + ## Input file structure ```bash @@ -88,3 +90,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 treated as a directory rather than a file. From 3bea131cfd4e35ae3e75d8065e27ee3a2a68dba2 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Tue, 26 May 2026 14:17:07 -0400 Subject: [PATCH 03/28] chore: do not create directory when not needed --- cli/src/commands/router/commands/compose.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/commands/router/commands/compose.ts b/cli/src/commands/router/commands/compose.ts index b8ab467695..ee8d5e9e36 100644 --- a/cli/src/commands/router/commands/compose.ts +++ b/cli/src/commands/router/commands/compose.ts @@ -191,7 +191,7 @@ export default (opts: BaseCommandOptions) => { if (options.out) { options.out = resolve(options.out); - if (!existsSync(options.out)) { + if (options.splitConfigsEnabled && !existsSync(options.out)) { await mkdir(options.out, { recursive: true }); } } From 4bd743233b8ddd0c7580dd90904f32d211862e90 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Tue, 26 May 2026 14:43:04 -0400 Subject: [PATCH 04/28] chore: remove duplicated message --- cli/src/commands/router/commands/fetch.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/cli/src/commands/router/commands/fetch.ts b/cli/src/commands/router/commands/fetch.ts index cd9a934fd9..89b66f8e68 100644 --- a/cli/src/commands/router/commands/fetch.ts +++ b/cli/src/commands/router/commands/fetch.ts @@ -62,10 +62,6 @@ export default (opts: BaseCommandOptions) => { }); await handleOutput(options.out, options.graphSignKey, result); - if (options.graphSignKey) { - console.log(pc.green('The signature of the router config matches the local computed signature.')); - } - process.exit(0); } catch (err) { if (err instanceof Error) { From e32f7269337f62116c34ce2313ba000e911e228a Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Tue, 26 May 2026 15:26:59 -0400 Subject: [PATCH 05/28] chore: write `mapper.json` file --- .../commands/graph/federated-graph/commands/fetch.ts | 4 ++++ cli/src/commands/router/commands/compose.ts | 12 +++++++++++- cli/src/commands/router/commands/fetch.ts | 7 +++++-- cli/src/commands/router/utils.ts | 2 ++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/graph/federated-graph/commands/fetch.ts b/cli/src/commands/graph/federated-graph/commands/fetch.ts index a525598140..3a600dc157 100644 --- a/cli/src/commands/graph/federated-graph/commands/fetch.ts +++ b/cli/src/commands/graph/federated-graph/commands/fetch.ts @@ -51,6 +51,10 @@ export default (opts: BaseCommandOptions) => { namespace: options.namespace, }); writeFileSync(join(superGraphPath, `cosmoConfig.json`), routerConfig.routerConfig); + if (routerConfig.mapper) { + writeFileSync(join(superGraphPath, `cosmoMapper.json`), JSON.stringify(routerConfig.mapper)); + } + if (routerConfig.featureFlags?.size) { const featureFlagsPath = join(basePath, 'feature-flags'); if (!existsSync(featureFlagsPath)) { diff --git a/cli/src/commands/router/commands/compose.ts b/cli/src/commands/router/commands/compose.ts index ee8d5e9e36..28a76f49f6 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 { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; import { buildRouterConfig, type ComposedSubgraph, @@ -272,6 +273,9 @@ export default (opts: BaseCommandOptions) => { subgraphs: subgraphs.map((s, index) => constructRouterSubgraph(result, s, index)), }); + const mapper = new Map(); + mapper.set('', createHash('sha256').update(routerConfig.toJsonString()).digest('hex')); + if (config.feature_flags && config.feature_flags.length > 0) { const ffConfigs = await buildFeatureFlagsConfig(config, inputFileLocation, subgraphs, options); if (!options.splitConfigsEnabled) { @@ -290,7 +294,9 @@ export default (opts: BaseCommandOptions) => { compatibilityVersion: routerConfig.compatibilityVersion, }); - await writeFile(join(outDir, `${featureFlagName}.json`), ffRouterConfig.toJsonString()); + const routerConfigJson = ffRouterConfig.toJsonString(); + await writeFile(join(outDir, `${featureFlagName}.json`), routerConfigJson); + mapper.set(featureFlagName, createHash('sha256').update(routerConfigJson).digest('hex')); } } } @@ -301,6 +307,10 @@ export default (opts: BaseCommandOptions) => { routerConfig.toJsonString(), ); + if (options.splitConfigsEnabled && mapper.size > 0) { + await writeFile(join(options.out, 'router-config-mapper.json'), JSON.stringify(Object.fromEntries(mapper))); + } + console.log(pc.green(`Router config successfully written to ${pc.bold(options.out)}`)); } else { console.log(routerConfig.toJsonString()); diff --git a/cli/src/commands/router/commands/fetch.ts b/cli/src/commands/router/commands/fetch.ts index 89b66f8e68..e2e06637d6 100644 --- a/cli/src/commands/router/commands/fetch.ts +++ b/cli/src/commands/router/commands/fetch.ts @@ -1,7 +1,7 @@ import { writeFile, mkdir } from 'node:fs/promises'; import { Command } from 'commander'; import pc from 'picocolors'; -import { resolve } from 'pathe'; +import { resolve, join } from 'pathe'; import { BaseCommandOptions } from '../../../core/types/types.js'; import { fetchRouterConfig, type FetchRouterConfigResult } from '../utils.js'; @@ -14,7 +14,10 @@ export const handleOutput = async ( if (config.splitConfigLoading) { let directory = resolve(out); await mkdir(directory, { recursive: true }); - await writeFile(resolve(directory, 'latest.json'), config.routerConfig); + await writeFile(join(directory, 'latest.json'), config.routerConfig); + if (config.mapper) { + await writeFile(join(directory, 'mapper.json'), JSON.stringify(config.mapper)); + } if (config.featureFlags && config.featureFlags.size > 0) { directory = resolve(directory, 'feature-flags'); diff --git a/cli/src/commands/router/utils.ts b/cli/src/commands/router/utils.ts index 0a2403b705..04eb39771f 100644 --- a/cli/src/commands/router/utils.ts +++ b/cli/src/commands/router/utils.ts @@ -10,6 +10,7 @@ export interface FetchRouterConfigResult { splitConfigLoading: boolean; routerConfig: string; featureFlags?: Map; + mapper?: Record; } export const fetchRouterConfig = async ({ @@ -81,6 +82,7 @@ export const fetchRouterConfig = async ({ resp.token, graphSignKey, ), + mapper: Object.fromEntries(mapper), }; if (mapper.size === 0) { From 63a64300e97b8424945e69993ca8e66e62fd65fa Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Tue, 26 May 2026 16:39:07 -0400 Subject: [PATCH 06/28] chore: add tests for `router compose` and update documentation --- .../router-compose/router-config.json.snap | 1 + .../feature-flags/my-feature-flag.json.snap | 1 + .../router-config-mapper.json.snap | 1 + .../split-config/router-config.json.snap | 1 + cli/test/router/compose.test.ts | 68 +++++++++++++++++++ cli/test/testdata/compose.yaml | 15 ++++ controlplane/src/core/constants.ts | 2 +- .../repositories/OrganizationRepository.ts | 4 +- docs-website/cli/router/compose.mdx | 4 ++ 9 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 cli/test/fixtures/router-compose/router-config.json.snap create mode 100644 cli/test/fixtures/router-compose/split-config/feature-flags/my-feature-flag.json.snap create mode 100644 cli/test/fixtures/router-compose/split-config/router-config-mapper.json.snap create mode 100644 cli/test/fixtures/router-compose/split-config/router-config.json.snap create mode 100644 cli/test/router/compose.test.ts create mode 100644 cli/test/testdata/compose.yaml 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/router-config-mapper.json.snap b/cli/test/fixtures/router-compose/split-config/router-config-mapper.json.snap new file mode 100644 index 0000000000..16da9a5310 --- /dev/null +++ b/cli/test/fixtures/router-compose/split-config/router-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/router/compose.test.ts b/cli/test/router/compose.test.ts new file mode 100644 index 0000000000..6f121844a4 --- /dev/null +++ b/cli/test/router/compose.test.ts @@ -0,0 +1,68 @@ +import { readFileSync, readSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Command } from 'commander'; +import { beforeEach, afterEach, describe, expect, onTestFinished, test, vi, type MockInstance } from 'vitest'; +import { type PartialMessage } from '@bufbuild/protobuf'; +import { createPromiseClient, createRouterTransport } from '@connectrpc/connect'; +import { PlatformService } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_connect'; +import { CheckSubgraphSchemaResponse } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import { resolve } from 'pathe'; +import { config } from '../../src/core/config.js'; +import ComposeCommand from '../../src/commands/router/commands/compose.js'; +import { Client } from '../../src/core/client/client.js'; + +const FIXTURES_DIR_PATH = resolve('./test/fixtures'); + +export const mockPlatformTransport = () => + createRouterTransport(({ service }) => { + service(PlatformService, {}); + }); + +describe('router compose command', () => { + test('that generated router config matches expected snapshot when config splitting is disabled', async () => { + const client: Client = { + platform: createPromiseClient(PlatformService, mockPlatformTransport()), + }; + + const outputFile = join(tmpdir(), 'router-config.json'); + const program = new Command(); + + program.addCommand(ComposeCommand({ client })); + program.parse(['compose', '-i', resolve('./test/testdata/compose.yaml'), '-o', outputFile], { + from: 'user', + }); + + // The output file must match the expected snapshot + const content = readFileSync(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-config-split'); + const program = new Command(); + + program.addCommand(ComposeCommand({ client })); + program.parse( + ['compose', '-i', resolve('./test/testdata/compose.yaml'), '-o', outputDir, '--split-configs-enabled'], + { + from: 'user', + }, + ); + + // All output files should match the snapshots + await expectSplitOutputMatchSnapshot(outputDir, 'router-config.json'); + await expectSplitOutputMatchSnapshot(outputDir, 'router-config-mapper.json'); + await expectSplitOutputMatchSnapshot(outputDir, join('feature-flags', 'my-feature-flag.json')); + }); +}); + +function expectSplitOutputMatchSnapshot(outputDir: string, name: string) { + const content = readFileSync(join(outputDir, name), 'utf8'); + return expect(content).toMatchFileSnapshot(join(FIXTURES_DIR_PATH, 'router-compose', 'split-config', `${name}.snap`)); +} 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/constants.ts b/controlplane/src/core/constants.ts index 0cf2609ec7..17660a0f95 100644 --- a/controlplane/src/core/constants.ts +++ b/controlplane/src/core/constants.ts @@ -57,4 +57,4 @@ export const organizationSchema = z.object({ export const defaultRetentionLimitInDays = 7; -export const featuresToSurfaceWithGraphToken: FeatureIds[] = ['split-config-loading']; +export const graphTokenFeatures: FeatureIds[] = ['split-config-loading']; diff --git a/controlplane/src/core/repositories/OrganizationRepository.ts b/controlplane/src/core/repositories/OrganizationRepository.ts index d0b95b8893..ad9e2d24a4 100644 --- a/controlplane/src/core/repositories/OrganizationRepository.ts +++ b/controlplane/src/core/repositories/OrganizationRepository.ts @@ -40,7 +40,7 @@ import { BlobStorage } from '../blobstorage/index.js'; import { delayForManualOrgDeletionInDays, delayForOrgAuditLogsDeletionInDays, - featuresToSurfaceWithGraphToken, + graphTokenFeatures, } from '../constants.js'; import { DeleteOrganizationAuditLogsQueue } from '../workers/DeleteOrganizationAuditLogsWorker.js'; import { RBACEvaluator } from '../services/RBACEvaluator.js'; @@ -1711,7 +1711,7 @@ export class OrganizationRepository { const orgFeatures = await this.getFeatures({ organizationId }); for (const feature of orgFeatures) { - if (featuresToSurfaceWithGraphToken.includes(feature.id) && feature.enabled) { + if (graphTokenFeatures.includes(feature.id) && feature.enabled) { features.push('split-config-loading'); } } diff --git a/docs-website/cli/router/compose.mdx b/docs-website/cli/router/compose.mdx index ad617cc680..2a4c95797e 100644 --- a/docs-website/cli/router/compose.mdx +++ b/docs-website/cli/router/compose.mdx @@ -28,6 +28,10 @@ The `npx wgc router compose` command allows you to compose subgraphs and build a * `--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 later. + + ## Input file structure ```bash From e53e6bd26d04e2740574ae5f1f18b19b62c177f5 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Tue, 26 May 2026 16:49:49 -0400 Subject: [PATCH 07/28] chore: fix tests --- cli/test/router/compose.test.ts | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/cli/test/router/compose.test.ts b/cli/test/router/compose.test.ts index 6f121844a4..8eae8d7980 100644 --- a/cli/test/router/compose.test.ts +++ b/cli/test/router/compose.test.ts @@ -1,15 +1,12 @@ -import { readFileSync, readSync, rmSync } from 'node:fs'; +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 { beforeEach, afterEach, describe, expect, onTestFinished, test, vi, type MockInstance } from 'vitest'; -import { type PartialMessage } from '@bufbuild/protobuf'; +import { describe, expect, test } from 'vitest'; import { createPromiseClient, createRouterTransport } from '@connectrpc/connect'; import { PlatformService } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_connect'; -import { CheckSubgraphSchemaResponse } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; -import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; import { resolve } from 'pathe'; -import { config } from '../../src/core/config.js'; import ComposeCommand from '../../src/commands/router/commands/compose.js'; import { Client } from '../../src/core/client/client.js'; @@ -26,16 +23,21 @@ describe('router compose command', () => { platform: createPromiseClient(PlatformService, mockPlatformTransport()), }; - const outputFile = join(tmpdir(), 'router-config.json'); + const outputDir = join(tmpdir(), 'router-config'); + const outputFile = join(outputDir, 'router-config.json'); + if (!existsSync(outputDir)) { + await mkdir(outputDir); + } + const program = new Command(); program.addCommand(ComposeCommand({ client })); - program.parse(['compose', '-i', resolve('./test/testdata/compose.yaml'), '-o', outputFile], { + await program.parseAsync(['compose', '-i', resolve('./test/testdata/compose.yaml'), '-o', outputFile], { from: 'user', }); // The output file must match the expected snapshot - const content = readFileSync(outputFile, 'utf8'); + const content = await readFile(outputFile, 'utf8'); await expect(content).toMatchFileSnapshot(join(FIXTURES_DIR_PATH, 'router-compose', `router-config.json.snap`)); }); @@ -45,10 +47,14 @@ describe('router compose command', () => { }; const outputDir = join(tmpdir(), 'router-config-split'); + if (!existsSync(outputDir)) { + await mkdir(outputDir); + } + const program = new Command(); program.addCommand(ComposeCommand({ client })); - program.parse( + await program.parseAsync( ['compose', '-i', resolve('./test/testdata/compose.yaml'), '-o', outputDir, '--split-configs-enabled'], { from: 'user', @@ -62,7 +68,7 @@ describe('router compose command', () => { }); }); -function expectSplitOutputMatchSnapshot(outputDir: string, name: string) { - const content = readFileSync(join(outputDir, name), 'utf8'); - return expect(content).toMatchFileSnapshot(join(FIXTURES_DIR_PATH, 'router-compose', 'split-config', `${name}.snap`)); +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`)); } From 861c51498e88deb376dfc256a3b5aea8a9f05449 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Tue, 26 May 2026 21:27:10 -0400 Subject: [PATCH 08/28] chore: add more tests --- .../graph/federated-graph/commands/fetch.ts | 2 +- .../commands/graph/federated-graph/utils.ts | 65 +-------- .../mcp/tools/federated-graph-tools.ts | 11 +- cli/src/commands/router/commands/compose.ts | 2 +- cli/src/commands/router/commands/fetch.ts | 1 - cli/src/commands/router/utils.ts | 18 +-- ...nfig-mapper.json.snap => mapper.json.snap} | 0 cli/test/graph/federated-graph/fetch.test.ts | 130 ++++++++++++++++++ cli/test/router/compose.test.ts | 19 ++- cli/test/router/fetch.test.ts | 125 +++++++++++++++++ cli/test/router/utils.ts | 74 ++++++++++ 11 files changed, 356 insertions(+), 91 deletions(-) rename cli/test/fixtures/router-compose/split-config/{router-config-mapper.json.snap => mapper.json.snap} (100%) create mode 100644 cli/test/graph/federated-graph/fetch.test.ts create mode 100644 cli/test/router/fetch.test.ts create mode 100644 cli/test/router/utils.ts diff --git a/cli/src/commands/graph/federated-graph/commands/fetch.ts b/cli/src/commands/graph/federated-graph/commands/fetch.ts index 3a600dc157..bcef1d8ea3 100644 --- a/cli/src/commands/graph/federated-graph/commands/fetch.ts +++ b/cli/src/commands/graph/federated-graph/commands/fetch.ts @@ -52,7 +52,7 @@ export default (opts: BaseCommandOptions) => { }); writeFileSync(join(superGraphPath, `cosmoConfig.json`), routerConfig.routerConfig); if (routerConfig.mapper) { - writeFileSync(join(superGraphPath, `cosmoMapper.json`), JSON.stringify(routerConfig.mapper)); + writeFileSync(join(basePath, `cosmo-mapper.json`), JSON.stringify(routerConfig.mapper)); } if (routerConfig.featureFlags?.size) { 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 ab8dcb5ce2..eb28095475 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 28a76f49f6..cd6fa49f6f 100644 --- a/cli/src/commands/router/commands/compose.ts +++ b/cli/src/commands/router/commands/compose.ts @@ -308,7 +308,7 @@ export default (opts: BaseCommandOptions) => { ); if (options.splitConfigsEnabled && mapper.size > 0) { - await writeFile(join(options.out, 'router-config-mapper.json'), JSON.stringify(Object.fromEntries(mapper))); + await writeFile(join(options.out, 'mapper.json'), JSON.stringify(Object.fromEntries(mapper))); } console.log(pc.green(`Router config successfully written to ${pc.bold(options.out)}`)); diff --git a/cli/src/commands/router/commands/fetch.ts b/cli/src/commands/router/commands/fetch.ts index e2e06637d6..e478f31dde 100644 --- a/cli/src/commands/router/commands/fetch.ts +++ b/cli/src/commands/router/commands/fetch.ts @@ -65,7 +65,6 @@ export default (opts: BaseCommandOptions) => { }); await handleOutput(options.out, options.graphSignKey, result); - process.exit(0); } catch (err) { if (err instanceof Error) { console.error(err.message); diff --git a/cli/src/commands/router/utils.ts b/cli/src/commands/router/utils.ts index 04eb39771f..7a843eef86 100644 --- a/cli/src/commands/router/utils.ts +++ b/cli/src/commands/router/utils.ts @@ -63,6 +63,12 @@ export const fetchRouterConfig = async ({ }; } + // 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); @@ -72,19 +78,9 @@ export const fetchRouterConfig = async ({ ? new Map(Object.entries(mapperRecord)) : new Map(); + result.mapper = Object.fromEntries(mapper); mapper.delete(''); // Delete the federated graph hash - // Retrieve the latest router configuration - const result: FetchRouterConfigResult = { - splitConfigLoading: true, - routerConfig: await fetchFileContentFromCdn( - new URL('routerconfigs/latest.json', baseUrl), - resp.token, - graphSignKey, - ), - mapper: Object.fromEntries(mapper), - }; - if (mapper.size === 0) { return result; } diff --git a/cli/test/fixtures/router-compose/split-config/router-config-mapper.json.snap b/cli/test/fixtures/router-compose/split-config/mapper.json.snap similarity index 100% rename from cli/test/fixtures/router-compose/split-config/router-config-mapper.json.snap rename to cli/test/fixtures/router-compose/split-config/mapper.json.snap 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..776a366b0d --- /dev/null +++ b/cli/test/graph/federated-graph/fetch.test.ts @@ -0,0 +1,130 @@ +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/graph/federated-graph/commands/fetch.js'; +import { Client } from '../../../src/core/client/client.js'; +import { FIXTURES_DIR_PATH, mockFetchRouterConfig, mockGenerateRouterToken } from '../../router/utils.js'; + +const routerSdl = 'type User {\n id: String @authenticated\n}'; +const clientSdl = 'type User {\n id: String\n}'; + +export const mockPlatformTransport = (splitConfigsEnabled: boolean) => + createRouterTransport(({ service }) => { + service(PlatformService, { + getFederatedGraphSDLByName(_) { + return { + response: { + code: EnumStatusCode.OK, + }, + sdl: routerSdl, + clientSchema: clientSdl, + }; + }, + 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', () => { + afterEach(vi.clearAllMocks); + + test('that generated 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(), 'federated-graph-fetch'); + if (!existsSync(outputDir)) { + await mkdir(outputDir, { recursive: true }); + } + + const program = new Command(); + + program.addCommand(FetchCommand({ client })); + await program.parseAsync(['fetch', 'fake-graph', '-o', outputDir], { + from: 'user', + }); + + expect(existsSync(join(outputDir, 'fake-graph'))).toBe(true); + expect(existsSync(join(outputDir, 'fake-graph', 'cosmo-composition.yaml'))).toBe(true); + expect(existsSync(join(outputDir, 'fake-graph', 'supergraph'))).toBe(true); + expect(existsSync(join(outputDir, 'fake-graph', 'supergraph', 'cosmoConfig.json'))).toBe(true); + + // The output file must match the expected snapshot + const content = await readFile(join(outputDir, 'fake-graph', 'supergraph', 'cosmoConfig.json'), '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(true)), + }; + + global.fetch = vi.fn(mockFetchRouterConfig); + + let outputDir = join(tmpdir(), 'federated-graph-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', + }); + + outputDir = join(outputDir, 'fake-graph'); + 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/router/compose.test.ts b/cli/test/router/compose.test.ts index 8eae8d7980..eb9a67588d 100644 --- a/cli/test/router/compose.test.ts +++ b/cli/test/router/compose.test.ts @@ -9,21 +9,20 @@ import { PlatformService } from '@wundergraph/cosmo-connect/dist/platform/v1/pla import { resolve } from 'pathe'; import ComposeCommand from '../../src/commands/router/commands/compose.js'; import { Client } from '../../src/core/client/client.js'; - -const FIXTURES_DIR_PATH = resolve('./test/fixtures'); +import { FIXTURES_DIR_PATH } from './utils.js'; export const mockPlatformTransport = () => createRouterTransport(({ service }) => { service(PlatformService, {}); }); -describe('router compose command', () => { +describe('router compose', () => { 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-config'); + const outputDir = join(tmpdir(), 'router-compose'); const outputFile = join(outputDir, 'router-config.json'); if (!existsSync(outputDir)) { await mkdir(outputDir); @@ -36,6 +35,8 @@ describe('router compose command', () => { 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`)); @@ -46,7 +47,7 @@ describe('router compose command', () => { platform: createPromiseClient(PlatformService, mockPlatformTransport()), }; - const outputDir = join(tmpdir(), 'router-config-split'); + const outputDir = join(tmpdir(), 'router-compose-split'); if (!existsSync(outputDir)) { await mkdir(outputDir); } @@ -61,9 +62,15 @@ describe('router compose command', () => { }, ); + 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, 'router-config-mapper.json'); + await expectSplitOutputMatchSnapshot(outputDir, 'mapper.json'); await expectSplitOutputMatchSnapshot(outputDir, join('feature-flags', 'my-feature-flag.json')); }); }); diff --git a/cli/test/router/fetch.test.ts b/cli/test/router/fetch.test.ts new file mode 100644 index 0000000000..834c5ca834 --- /dev/null +++ b/cli/test/router/fetch.test.ts @@ -0,0 +1,125 @@ +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, mockFetchRouterConfig, mockGenerateRouterToken } from './utils.js'; + +const routerSdl = 'type User {\n id: String @authenticated\n}'; +const clientSdl = 'type User {\n id: String\n}'; + +export const mockPlatformTransport = (splitConfigsEnabled: boolean) => + createRouterTransport(({ service }) => { + service(PlatformService, { + getFederatedGraphSDLByName(_) { + return { + response: { + code: EnumStatusCode.OK, + }, + sdl: routerSdl, + clientSchema: clientSdl, + }; + }, + 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', () => { + afterEach(vi.clearAllMocks); + + test('that generated 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 generated 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.ts b/cli/test/router/utils.ts new file mode 100644 index 0000000000..13fea2df7c --- /dev/null +++ b/cli/test/router/utils.ts @@ -0,0 +1,74 @@ +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 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', + }); +}; From 798d37a262c1f648c2fed76aa802ccfe01f09006 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Tue, 26 May 2026 21:29:37 -0400 Subject: [PATCH 09/28] chore: update test labels --- cli/test/graph/federated-graph/fetch.test.ts | 4 ++-- cli/test/router/fetch.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/test/graph/federated-graph/fetch.test.ts b/cli/test/graph/federated-graph/fetch.test.ts index 776a366b0d..d4d4817a98 100644 --- a/cli/test/graph/federated-graph/fetch.test.ts +++ b/cli/test/graph/federated-graph/fetch.test.ts @@ -57,7 +57,7 @@ export const mockPlatformTransport = (splitConfigsEnabled: boolean) => describe('federated-graph fetch', () => { afterEach(vi.clearAllMocks); - test('that generated router config matches expected snapshot when config splitting is disabled', async () => { + test('that router config matches expected snapshot when config splitting is disabled', async () => { const client: Client = { platform: createPromiseClient(PlatformService, mockPlatformTransport(false)), }; @@ -86,7 +86,7 @@ describe('federated-graph fetch', () => { 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 () => { + test('that router config matches expected snapshot when config splitting is enabled ', async () => { const client: Client = { platform: createPromiseClient(PlatformService, mockPlatformTransport(true)), }; diff --git a/cli/test/router/fetch.test.ts b/cli/test/router/fetch.test.ts index 834c5ca834..24f99f1d24 100644 --- a/cli/test/router/fetch.test.ts +++ b/cli/test/router/fetch.test.ts @@ -57,7 +57,7 @@ export const mockPlatformTransport = (splitConfigsEnabled: boolean) => describe('router fetch', () => { afterEach(vi.clearAllMocks); - test('that generated router config matches expected snapshot when config splitting is disabled', async () => { + test('that router config matches expected snapshot when config splitting is disabled', async () => { const client: Client = { platform: createPromiseClient(PlatformService, mockPlatformTransport(false)), }; @@ -84,7 +84,7 @@ describe('router fetch', () => { 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 () => { + test('that router config matches expected snapshot when config splitting is enabled ', async () => { const client: Client = { platform: createPromiseClient(PlatformService, mockPlatformTransport(true)), }; From f976055e809b8a26012a7729c3063bb2cec94c85 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Thu, 28 May 2026 06:29:21 -0400 Subject: [PATCH 10/28] chore: address comments --- cli/src/commands/router/commands/compose.ts | 5 ++++- cli/src/commands/router/utils.ts | 1 - cli/test/graph/federated-graph/fetch.test.ts | 17 ++++++++++------- .../graph/federated-graph/recompose.test.ts | 2 +- cli/test/router/compose.test.ts | 2 +- cli/test/router/fetch.test.ts | 11 ++++------- cli/test/router/utils.ts | 3 +++ .../core/repositories/OrganizationRepository.ts | 2 +- docs-website/cli/router/compose.mdx | 2 +- 9 files changed, 25 insertions(+), 20 deletions(-) diff --git a/cli/src/commands/router/commands/compose.ts b/cli/src/commands/router/commands/compose.ts index cd6fa49f6f..330c2edbad 100644 --- a/cli/src/commands/router/commands/compose.ts +++ b/cli/src/commands/router/commands/compose.ts @@ -178,7 +178,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.'); + 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); diff --git a/cli/src/commands/router/utils.ts b/cli/src/commands/router/utils.ts index 7a843eef86..e57eff34df 100644 --- a/cli/src/commands/router/utils.ts +++ b/cli/src/commands/router/utils.ts @@ -98,7 +98,6 @@ export const fetchRouterConfig = async ({ ); } - // return result; }; diff --git a/cli/test/graph/federated-graph/fetch.test.ts b/cli/test/graph/federated-graph/fetch.test.ts index d4d4817a98..a71dffd9dd 100644 --- a/cli/test/graph/federated-graph/fetch.test.ts +++ b/cli/test/graph/federated-graph/fetch.test.ts @@ -9,10 +9,13 @@ import { PlatformService } from '@wundergraph/cosmo-connect/dist/platform/v1/pla 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, mockFetchRouterConfig, mockGenerateRouterToken } from '../../router/utils.js'; - -const routerSdl = 'type User {\n id: String @authenticated\n}'; -const clientSdl = 'type User {\n id: String\n}'; +import { + FIXTURES_DIR_PATH, + ROUTER_SDL, + CLIENT_SDL, + mockFetchRouterConfig, + mockGenerateRouterToken, +} from '../../router/utils.js'; export const mockPlatformTransport = (splitConfigsEnabled: boolean) => createRouterTransport(({ service }) => { @@ -22,8 +25,8 @@ export const mockPlatformTransport = (splitConfigsEnabled: boolean) => response: { code: EnumStatusCode.OK, }, - sdl: routerSdl, - clientSchema: clientSdl, + sdl: ROUTER_SDL, + clientSchema: CLIENT_SDL, }; }, getSubgraphSDLFromLatestComposition(_) { @@ -54,7 +57,7 @@ export const mockPlatformTransport = (splitConfigsEnabled: boolean) => }); }); -describe('federated-graph fetch', () => { +describe('federated-graph fetch command', () => { afterEach(vi.clearAllMocks); test('that router config matches expected snapshot when config splitting is disabled', async () => { diff --git a/cli/test/graph/federated-graph/recompose.test.ts b/cli/test/graph/federated-graph/recompose.test.ts index e9bb4ae429..ada3f09889 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', () => { let logSpy: MockInstance; beforeEach(() => { diff --git a/cli/test/router/compose.test.ts b/cli/test/router/compose.test.ts index eb9a67588d..9fa52955ac 100644 --- a/cli/test/router/compose.test.ts +++ b/cli/test/router/compose.test.ts @@ -16,7 +16,7 @@ export const mockPlatformTransport = () => service(PlatformService, {}); }); -describe('router compose', () => { +describe('router compose command', () => { test('that generated router config matches expected snapshot when config splitting is disabled', async () => { const client: Client = { platform: createPromiseClient(PlatformService, mockPlatformTransport()), diff --git a/cli/test/router/fetch.test.ts b/cli/test/router/fetch.test.ts index 24f99f1d24..6f659c78b0 100644 --- a/cli/test/router/fetch.test.ts +++ b/cli/test/router/fetch.test.ts @@ -9,10 +9,7 @@ import { PlatformService } from '@wundergraph/cosmo-connect/dist/platform/v1/pla 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, mockFetchRouterConfig, mockGenerateRouterToken } from './utils.js'; - -const routerSdl = 'type User {\n id: String @authenticated\n}'; -const clientSdl = 'type User {\n id: String\n}'; +import { FIXTURES_DIR_PATH, ROUTER_SDL, CLIENT_SDL, mockFetchRouterConfig, mockGenerateRouterToken } from './utils.js'; export const mockPlatformTransport = (splitConfigsEnabled: boolean) => createRouterTransport(({ service }) => { @@ -22,8 +19,8 @@ export const mockPlatformTransport = (splitConfigsEnabled: boolean) => response: { code: EnumStatusCode.OK, }, - sdl: routerSdl, - clientSchema: clientSdl, + sdl: ROUTER_SDL, + clientSchema: CLIENT_SDL, }; }, getSubgraphSDLFromLatestComposition(_) { @@ -54,7 +51,7 @@ export const mockPlatformTransport = (splitConfigsEnabled: boolean) => }); }); -describe('router fetch', () => { +describe('router fetch command', () => { afterEach(vi.clearAllMocks); test('that router config matches expected snapshot when config splitting is disabled', async () => { diff --git a/cli/test/router/utils.ts b/cli/test/router/utils.ts index 13fea2df7c..005cc90721 100644 --- a/cli/test/router/utils.ts +++ b/cli/test/router/utils.ts @@ -10,6 +10,9 @@ 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, diff --git a/controlplane/src/core/repositories/OrganizationRepository.ts b/controlplane/src/core/repositories/OrganizationRepository.ts index ad9e2d24a4..cb04781cde 100644 --- a/controlplane/src/core/repositories/OrganizationRepository.ts +++ b/controlplane/src/core/repositories/OrganizationRepository.ts @@ -1711,7 +1711,7 @@ export class OrganizationRepository { const orgFeatures = await this.getFeatures({ organizationId }); for (const feature of orgFeatures) { - if (graphTokenFeatures.includes(feature.id) && feature.enabled) { + if (feature.enabled && graphTokenFeatures.includes(feature.id)) { features.push('split-config-loading'); } } diff --git a/docs-website/cli/router/compose.mdx b/docs-website/cli/router/compose.mdx index 2a4c95797e..0fd437b869 100644 --- a/docs-website/cli/router/compose.mdx +++ b/docs-website/cli/router/compose.mdx @@ -94,4 +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 treated as a directory rather than a file. +* When using the `--split-configs-enabled` option, the `--out` is always treated as a directory. From 515e109c728a17aad38e10c81efa1a58ee751359 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Fri, 29 May 2026 04:43:01 -0400 Subject: [PATCH 11/28] chore: update wording --- cli/test/graph/federated-graph/fetch.test.ts | 2 +- cli/test/graph/federated-graph/recompose.test.ts | 2 +- cli/test/router/compose.test.ts | 2 +- cli/test/router/fetch.test.ts | 2 +- docs-website/cli/router/compose.mdx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cli/test/graph/federated-graph/fetch.test.ts b/cli/test/graph/federated-graph/fetch.test.ts index a71dffd9dd..8d43ffc695 100644 --- a/cli/test/graph/federated-graph/fetch.test.ts +++ b/cli/test/graph/federated-graph/fetch.test.ts @@ -57,7 +57,7 @@ export const mockPlatformTransport = (splitConfigsEnabled: boolean) => }); }); -describe('federated-graph fetch command', () => { +describe('federated-graph fetch command tests', () => { afterEach(vi.clearAllMocks); test('that router config matches expected snapshot when config splitting is disabled', async () => { diff --git a/cli/test/graph/federated-graph/recompose.test.ts b/cli/test/graph/federated-graph/recompose.test.ts index ada3f09889..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 command', () => { +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 index 9fa52955ac..4e670ce0aa 100644 --- a/cli/test/router/compose.test.ts +++ b/cli/test/router/compose.test.ts @@ -16,7 +16,7 @@ export const mockPlatformTransport = () => service(PlatformService, {}); }); -describe('router compose command', () => { +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()), diff --git a/cli/test/router/fetch.test.ts b/cli/test/router/fetch.test.ts index 6f659c78b0..8d870e994f 100644 --- a/cli/test/router/fetch.test.ts +++ b/cli/test/router/fetch.test.ts @@ -51,7 +51,7 @@ export const mockPlatformTransport = (splitConfigsEnabled: boolean) => }); }); -describe('router fetch command', () => { +describe('router fetch command tests', () => { afterEach(vi.clearAllMocks); test('that router config matches expected snapshot when config splitting is disabled', async () => { diff --git a/docs-website/cli/router/compose.mdx b/docs-website/cli/router/compose.mdx index 0fd437b869..b12d026f72 100644 --- a/docs-website/cli/router/compose.mdx +++ b/docs-website/cli/router/compose.mdx @@ -29,7 +29,7 @@ The `npx wgc router compose` command allows you to compose subgraphs and build a * `--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 later. + 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 From 452b33e27bfe17f8a5b66e3cbd8a4ef086832b8d Mon Sep 17 00:00:00 2001 From: Aenimus Date: Wed, 17 Jun 2026 12:09:33 +0100 Subject: [PATCH 12/28] chore: refactor router compose --- cli/src/commands/router/commands/compose.ts | 269 ++++++++---------- .../commands/router/commands/types/params.ts | 10 + .../commands/router/commands/types/types.ts | 80 ++++++ 3 files changed, 214 insertions(+), 145 deletions(-) create mode 100644 cli/src/commands/router/commands/types/params.ts create mode 100644 cli/src/commands/router/commands/types/types.ts diff --git a/cli/src/commands/router/commands/compose.ts b/cli/src/commands/router/commands/compose.ts index 330c2edbad..36e7417bdb 100644 --- a/cli/src/commands/router/commands/compose.ts +++ b/cli/src/commands/router/commands/compose.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { buildRouterConfig, @@ -9,8 +9,6 @@ import { normalizeURL, type RouterSubgraph, SubgraphKind, - type SubscriptionProtocol, - type WebsocketSubprotocol, } from '@wundergraph/cosmo-shared'; import semver from 'semver'; import { Command, program } from 'commander'; @@ -29,95 +27,29 @@ 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 { + Config, + ConfigSubgraph, + GRPCSubgraphConfig, + GRPCSubgraphMetadata, + StandardSubgraphConfig, + StandardSubgraphMetaData, + SubgraphMetaData, + SubgraphPluginConfig, + SubgraphPluginMetadata, +} from './types/types'; +import { HandleRouterConfigParams } from './types/params'; 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, @@ -129,12 +61,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, @@ -146,11 +77,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, @@ -161,11 +91,99 @@ function constructRouterSubgraph(result: FederationSuccess, s: SubgraphMetadata, schema, configurationDataByTypeName, costs, - }; - return composedSubgraphGRPC; + } satisfies ComposedSubgraphGRPC; } -export default (opts: BaseCommandOptions) => { +const featureFlagsDir = 'feature-flags'; +const mapperFile = 'mapper.json'; +const routerConfigFile = 'router-config.json'; + +async function handleSplitRouterConfig({ + config, + inputFileLocation, + options, + routerConfig, + subgraphs, +}: HandleRouterConfigParams) { + let outputDir = options.out ? resolve(options.out) : options.out; + if (!outputDir) { + const defaultDirName = resolve('router-compose-output'); + if (!existsSync(defaultDirName)) { + await mkdir(defaultDirName, { recursive: true }); + } + outputDir = defaultDirName; + } + 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 < 1) { + 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}" is not empty. 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 writeFile(join(ffDir, `${featureFlagName}.json`), 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) { + await writeFile(join(options.out, routerConfigFile), 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', @@ -195,7 +213,7 @@ export default (opts: BaseCommandOptions) => { if (options.out) { options.out = resolve(options.out); - if (options.splitConfigsEnabled && !existsSync(options.out)) { + if (!options.splitConfigsEnabled) { await mkdir(options.out, { recursive: true }); } } @@ -203,7 +221,7 @@ export default (opts: BaseCommandOptions) => { 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); @@ -276,47 +294,10 @@ export default (opts: BaseCommandOptions) => { subgraphs: subgraphs.map((s, index) => constructRouterSubgraph(result, s, index)), }); - const mapper = new Map(); - mapper.set('', createHash('sha256').update(routerConfig.toJsonString()).digest('hex')); - - if (config.feature_flags && config.feature_flags.length > 0) { - const ffConfigs = await buildFeatureFlagsConfig(config, inputFileLocation, subgraphs, options); - if (!options.splitConfigsEnabled) { - routerConfig.featureFlagConfigs = ffConfigs; - } else if (ffConfigs.configByFeatureFlagName && options.out) { - const outDir = join(options.out, 'feature-flags'); - if (!existsSync(outDir)) { - await mkdir(outDir, { recursive: true }); - } - - 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 writeFile(join(outDir, `${featureFlagName}.json`), routerConfigJson); - mapper.set(featureFlagName, createHash('sha256').update(routerConfigJson).digest('hex')); - } - } - } - - if (options.out) { - await writeFile( - options.splitConfigsEnabled ? join(options.out, 'router-config.json') : options.out, - routerConfig.toJsonString(), - ); - - if (options.splitConfigsEnabled && mapper.size > 0) { - await writeFile(join(options.out, 'mapper.json'), JSON.stringify(Object.fromEntries(mapper))); - } - - console.log(pc.green(`Router config successfully written to ${pc.bold(options.out)}`)); + if (options.splitConfigsEnabled) { + await handleSplitRouterConfig({ config, inputFileLocation, options, routerConfig, subgraphs }); } else { - console.log(routerConfig.toJsonString()); + await handleEmbeddedRouterConfig({ config, inputFileLocation, options, routerConfig, subgraphs }); } }); @@ -327,8 +308,8 @@ function toSubgraphMetadata( inputFileLocation: string, index: number, subgraphConfig: ConfigSubgraph, - subgraphs: SubgraphMetadata[], -): Promise { + subgraphs: SubgraphMetaData[], +): Promise { if ('plugin' in subgraphConfig) { return toSubgraphMetadataPlugin(inputFileLocation, subgraphConfig, subgraphs); } @@ -362,7 +343,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)) { @@ -397,7 +378,7 @@ async function toSubgraphMetadataStandard( inputFileLocation: string, index: number, s: StandardSubgraphConfig, - subgraphs: SubgraphMetadata[], + subgraphs: SubgraphMetaData[], ): Promise { // The subgraph name is required if (!s.name) { @@ -424,8 +405,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 { @@ -554,7 +534,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(); @@ -562,7 +542,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), @@ -698,7 +678,7 @@ async function buildFeatureFlagsConfig( const configurationDataByTypeName = subgraphConfig?.configurationDataByTypeName; const costs = subgraphConfig?.costs; - const composedSubgraph: ComposedSubgraph = { + return { kind: SubgraphKind.Standard, id: `${index}`, name: s.name, @@ -710,8 +690,7 @@ async function buildFeatureFlagsConfig( schema, configurationDataByTypeName, costs, - }; - return composedSubgraph; + } satisfies ComposedSubgraph; }), }); 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..07f64ba1d3 --- /dev/null +++ b/cli/src/commands/router/commands/types/params.ts @@ -0,0 +1,10 @@ +import { Config, SubgraphMetaData } from './types'; +import { RouterConfig } from '@wundergraph/cosmo-connect/dist/node/v1/node_pb'; + +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..d6cae72c3b --- /dev/null +++ b/cli/src/commands/router/commands/types/types.ts @@ -0,0 +1,80 @@ +import { SubgraphKind, type SubscriptionProtocol, type WebsocketSubprotocol } from '@wundergraph/cosmo-shared'; +import { GRPCMapping } from '@wundergraph/cosmo-connect/dist/node/v1/node_pb'; + +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 })[]; + }[]; +}; From bf0f42535e399b3ec428460990ab5bbf417ce027 Mon Sep 17 00:00:00 2001 From: Aenimus Date: Wed, 17 Jun 2026 12:57:14 +0100 Subject: [PATCH 13/28] chore: refactor dir creation --- cli/src/commands/router/commands/compose.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/cli/src/commands/router/commands/compose.ts b/cli/src/commands/router/commands/compose.ts index 36e7417bdb..a1e56471e0 100644 --- a/cli/src/commands/router/commands/compose.ts +++ b/cli/src/commands/router/commands/compose.ts @@ -107,11 +107,10 @@ async function handleSplitRouterConfig({ }: HandleRouterConfigParams) { let outputDir = options.out ? resolve(options.out) : options.out; if (!outputDir) { - const defaultDirName = resolve('router-compose-output'); - if (!existsSync(defaultDirName)) { - await mkdir(defaultDirName, { recursive: true }); - } - outputDir = defaultDirName; + outputDir = resolve('router-compose-output'); + } + if (!existsSync(outputDir)) { + await mkdir(outputDir, { recursive: true }); } const entries = await readdir(outputDir); if (entries.length > 0) { @@ -140,7 +139,7 @@ async function handleSplitRouterConfig({ } catch { console.log( pc.red( - `Split-config flag enabled; output directory "${ffDir}" is not empty. Please provide an empty root directory path.`, + `Split-config flag enabled; output directory "${ffDir}" already exists. Please provide an empty root directory path.`, ), ); process.exitCode = 1; From c6dcf888eff1d06aae463e7e9b14123afbd519b9 Mon Sep 17 00:00:00 2001 From: Aenimus Date: Wed, 17 Jun 2026 13:01:10 +0100 Subject: [PATCH 14/28] chore: import formatting --- cli/src/commands/router/commands/types/params.ts | 2 +- cli/src/commands/router/commands/types/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/router/commands/types/params.ts b/cli/src/commands/router/commands/types/params.ts index 07f64ba1d3..306424cb9a 100644 --- a/cli/src/commands/router/commands/types/params.ts +++ b/cli/src/commands/router/commands/types/params.ts @@ -1,5 +1,5 @@ -import { Config, SubgraphMetaData } from './types'; import { RouterConfig } from '@wundergraph/cosmo-connect/dist/node/v1/node_pb'; +import { Config, SubgraphMetaData } from './types.js'; export type HandleRouterConfigParams = { config: Config; diff --git a/cli/src/commands/router/commands/types/types.ts b/cli/src/commands/router/commands/types/types.ts index d6cae72c3b..bf8bc220e5 100644 --- a/cli/src/commands/router/commands/types/types.ts +++ b/cli/src/commands/router/commands/types/types.ts @@ -1,5 +1,5 @@ -import { SubgraphKind, type SubscriptionProtocol, type WebsocketSubprotocol } from '@wundergraph/cosmo-shared'; 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; From b3b63ecc887517d7b22e9b9b781990a5fd8b4400 Mon Sep 17 00:00:00 2001 From: Aenimus Date: Wed, 17 Jun 2026 13:21:27 +0100 Subject: [PATCH 15/28] chore: import formatting (again) --- cli/src/commands/router/commands/compose.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/router/commands/compose.ts b/cli/src/commands/router/commands/compose.ts index a1e56471e0..cf87ce0955 100644 --- a/cli/src/commands/router/commands/compose.ts +++ b/cli/src/commands/router/commands/compose.ts @@ -37,8 +37,8 @@ import { SubgraphMetaData, SubgraphPluginConfig, SubgraphPluginMetadata, -} from './types/types'; -import { HandleRouterConfigParams } from './types/params'; +} from './types/types.js'; +import { HandleRouterConfigParams } from './types/params.js'; const STATIC_SCHEMA_VERSION_ID = '00000000-0000-0000-0000-000000000000'; From c7f1a410f83fbdbd7d030d3936deb048f04a38b5 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 17 Jun 2026 09:20:20 -0400 Subject: [PATCH 16/28] chore: linting --- cli/src/commands/router/commands/compose.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/cli/src/commands/router/commands/compose.ts b/cli/src/commands/router/commands/compose.ts index cf87ce0955..fa8cbaecef 100644 --- a/cli/src/commands/router/commands/compose.ts +++ b/cli/src/commands/router/commands/compose.ts @@ -127,7 +127,7 @@ async function handleSplitRouterConfig({ 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 < 1) { + if (!config.feature_flags || config.feature_flags.length === 0) { await writeFile(join(outputDir, mapperFile), JSON.stringify(Object.fromEntries(mapper))); return; } @@ -293,11 +293,9 @@ export default (_: BaseCommandOptions) => { subgraphs: subgraphs.map((s, index) => constructRouterSubgraph(result, s, index)), }); - if (options.splitConfigsEnabled) { - await handleSplitRouterConfig({ config, inputFileLocation, options, routerConfig, subgraphs }); - } else { - await handleEmbeddedRouterConfig({ config, inputFileLocation, options, routerConfig, subgraphs }); - } + await (options.splitConfigsEnabled + ? handleSplitRouterConfig({ config, inputFileLocation, options, routerConfig, subgraphs }) + : handleEmbeddedRouterConfig({ config, inputFileLocation, options, routerConfig, subgraphs })); }); return command; From 56e5565bc96ff4d489e5e0b0ce2986e2d207d388 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 17 Jun 2026 10:24:19 -0400 Subject: [PATCH 17/28] chore: fix tests --- cli/e2e/smoke.test.ts | 5 +++-- cli/src/commands/router/commands/compose.ts | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/cli/e2e/smoke.test.ts b/cli/e2e/smoke.test.ts index 49aa371c3f..9b572ed035 100644 --- a/cli/e2e/smoke.test.ts +++ b/cli/e2e/smoke.test.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs'; import { expect, test, describe } from 'bun:test'; import { $ } from 'bun'; +import pc from 'picocolors'; import packageJSON from '../package.json' with { type: 'json' }; @@ -27,7 +28,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(pc.green('Router config successfully written')); }); }); @@ -52,6 +53,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(pc.green('Router config successfully written')); }); }); diff --git a/cli/src/commands/router/commands/compose.ts b/cli/src/commands/router/commands/compose.ts index fa8cbaecef..2e10aa8d71 100644 --- a/cli/src/commands/router/commands/compose.ts +++ b/cli/src/commands/router/commands/compose.ts @@ -175,7 +175,18 @@ async function handleEmbeddedRouterConfig({ } const routerConfigJson = routerConfig.toJsonString(); if (options.out) { - await writeFile(join(options.out, routerConfigFile), routerConfigJson); + let output: string = options.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')) { + output = join(options.out, routerConfigJson); + } else if (!existsSync(output)) { + await mkdir(output, { recursive: true }); + } + + await writeFile(output, routerConfigJson); console.log(pc.green(`Router execution config successfully written to "${pc.bold(options.out)}".`)); } else { console.log(routerConfigJson); @@ -212,7 +223,7 @@ export default (_: BaseCommandOptions) => { if (options.out) { options.out = resolve(options.out); - if (!options.splitConfigsEnabled) { + if (options.splitConfigsEnabled) { await mkdir(options.out, { recursive: true }); } } From 5df98900e96e1608c11ad62bbc7934da4d933018 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 17 Jun 2026 10:42:52 -0400 Subject: [PATCH 18/28] chore: add uniqueness to tests --- cli/test/router/compose.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cli/test/router/compose.test.ts b/cli/test/router/compose.test.ts index 4e670ce0aa..e601601300 100644 --- a/cli/test/router/compose.test.ts +++ b/cli/test/router/compose.test.ts @@ -2,6 +2,7 @@ import { readFile, mkdir } 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'; @@ -22,7 +23,7 @@ describe('router compose command tests', () => { platform: createPromiseClient(PlatformService, mockPlatformTransport()), }; - const outputDir = join(tmpdir(), 'router-compose'); + const outputDir = join(tmpdir(), 'router-compose', randomUUID()); const outputFile = join(outputDir, 'router-config.json'); if (!existsSync(outputDir)) { await mkdir(outputDir); @@ -47,7 +48,7 @@ describe('router compose command tests', () => { platform: createPromiseClient(PlatformService, mockPlatformTransport()), }; - const outputDir = join(tmpdir(), 'router-compose-split'); + const outputDir = join(tmpdir(), 'router-compose-split', randomUUID()); if (!existsSync(outputDir)) { await mkdir(outputDir); } From e04430ba3337329222a59da2b49be749c81ab9b7 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 17 Jun 2026 10:47:45 -0400 Subject: [PATCH 19/28] chore: create directory recursively --- cli/src/commands/router/commands/compose.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/commands/router/commands/compose.ts b/cli/src/commands/router/commands/compose.ts index 2e10aa8d71..37284b4867 100644 --- a/cli/src/commands/router/commands/compose.ts +++ b/cli/src/commands/router/commands/compose.ts @@ -135,7 +135,7 @@ async function handleSplitRouterConfig({ const ffConfigs = await buildFeatureFlagsConfig(config, inputFileLocation, subgraphs, options); const ffDir = join(outputDir, featureFlagsDir); try { - await mkdir(ffDir); + await mkdir(ffDir, { recursive: true }); } catch { console.log( pc.red( From 2331489e4e4a0b36ef97fbf48d3c09987af6eb2f Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 17 Jun 2026 11:09:47 -0400 Subject: [PATCH 20/28] chore: fix dir creation --- cli/src/commands/router/commands/compose.ts | 13 ++++++++++--- cli/test/router/compose.test.ts | 8 -------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/cli/src/commands/router/commands/compose.ts b/cli/src/commands/router/commands/compose.ts index 37284b4867..6eba5aff3c 100644 --- a/cli/src/commands/router/commands/compose.ts +++ b/cli/src/commands/router/commands/compose.ts @@ -180,10 +180,17 @@ async function handleEmbeddedRouterConfig({ * 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')) { + 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(options.out, routerConfigJson); - } else if (!existsSync(output)) { - await mkdir(output, { recursive: true }); } await writeFile(output, routerConfigJson); diff --git a/cli/test/router/compose.test.ts b/cli/test/router/compose.test.ts index e601601300..ed1f68ae1d 100644 --- a/cli/test/router/compose.test.ts +++ b/cli/test/router/compose.test.ts @@ -25,10 +25,6 @@ describe('router compose command tests', () => { const outputDir = join(tmpdir(), 'router-compose', randomUUID()); const outputFile = join(outputDir, 'router-config.json'); - if (!existsSync(outputDir)) { - await mkdir(outputDir); - } - const program = new Command(); program.addCommand(ComposeCommand({ client })); @@ -49,10 +45,6 @@ describe('router compose command tests', () => { }; const outputDir = join(tmpdir(), 'router-compose-split', randomUUID()); - if (!existsSync(outputDir)) { - await mkdir(outputDir); - } - const program = new Command(); program.addCommand(ComposeCommand({ client })); From 6a001d16e2445862d1e9daf868d307b9456c828f Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 17 Jun 2026 11:20:10 -0400 Subject: [PATCH 21/28] chore: fix e2e tests --- cli/e2e/smoke.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/e2e/smoke.test.ts b/cli/e2e/smoke.test.ts index 9b572ed035..3254fb4c29 100644 --- a/cli/e2e/smoke.test.ts +++ b/cli/e2e/smoke.test.ts @@ -28,7 +28,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(pc.green('Router config successfully written')); + expect(stdout.toString()).toContain('Router execution config successfully written to'); }); }); @@ -53,6 +53,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(pc.green('Router config successfully written')); + expect(stdout.toString()).toContain('Router execution config successfully written to'); }); }); From 45ac19b03113a1d8bb1b77bbf77af7197826b8e4 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 17 Jun 2026 16:33:33 -0400 Subject: [PATCH 22/28] chore: restore non-recursive directory creation for split config --- cli/src/commands/router/commands/compose.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/router/commands/compose.ts b/cli/src/commands/router/commands/compose.ts index 6eba5aff3c..bba911e151 100644 --- a/cli/src/commands/router/commands/compose.ts +++ b/cli/src/commands/router/commands/compose.ts @@ -110,7 +110,7 @@ async function handleSplitRouterConfig({ outputDir = resolve('router-compose-output'); } if (!existsSync(outputDir)) { - await mkdir(outputDir, { recursive: true }); + await mkdir(outputDir); } const entries = await readdir(outputDir); if (entries.length > 0) { @@ -135,7 +135,7 @@ async function handleSplitRouterConfig({ const ffConfigs = await buildFeatureFlagsConfig(config, inputFileLocation, subgraphs, options); const ffDir = join(outputDir, featureFlagsDir); try { - await mkdir(ffDir, { recursive: true }); + await mkdir(ffDir); } catch { console.log( pc.red( From e9ca493641a201ecc33decc0ce6cdab73545f2f9 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 17 Jun 2026 17:33:31 -0400 Subject: [PATCH 23/28] chore: improvements --- cli/e2e/smoke.test.ts | 1 - .../graph/federated-graph/commands/fetch.ts | 61 +++++---- cli/src/commands/router/commands/compose.ts | 25 +--- cli/src/commands/router/commands/fetch.ts | 124 ++++++++++++------ cli/src/commands/router/types/params.ts | 8 ++ cli/src/commands/router/types/types.ts | 6 + cli/src/commands/router/utils.ts | 44 +++++-- 7 files changed, 172 insertions(+), 97 deletions(-) create mode 100644 cli/src/commands/router/types/params.ts create mode 100644 cli/src/commands/router/types/types.ts diff --git a/cli/e2e/smoke.test.ts b/cli/e2e/smoke.test.ts index 3254fb4c29..6158b710f4 100644 --- a/cli/e2e/smoke.test.ts +++ b/cli/e2e/smoke.test.ts @@ -1,7 +1,6 @@ import { existsSync } from 'node:fs'; import { expect, test, describe } from 'bun:test'; import { $ } from 'bun'; -import pc from 'picocolors'; import packageJSON from '../package.json' with { type: 'json' }; diff --git a/cli/src/commands/graph/federated-graph/commands/fetch.ts b/cli/src/commands/graph/federated-graph/commands/fetch.ts index bcef1d8ea3..66559aebcd 100644 --- a/cli/src/commands/graph/federated-graph/commands/fetch.ts +++ b/cli/src/commands/graph/federated-graph/commands/fetch.ts @@ -1,11 +1,21 @@ -import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; +import { mkdir, 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 { getFederatedGraphSchemas, getSubgraphSDL, getSubgraphsOfFedGraph } from '../utils.js'; -import { fetchRouterConfig } from '../../../router/utils.js'; +import { featureFlagsDir, fetchRouterConfig } 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'); @@ -30,19 +40,28 @@ export default (opts: BaseCommandOptions) => { namespace: options.namespace, }); - const basePath = resolve(options.out, `${name}${options.namespace ? `-${options.namespace}` : ''}`); + let basePath = options.out ? resolve(options.out) : options.out; + if (!basePath) { + basePath = resolve('fetched-schemas'); + } + + basePath = join(basePath, `${name}${options.namespace ? `-${options.namespace}` : ''}`); + if (!existsSync(basePath)) { + await mkdir(basePath, { recursive: true }); + } + 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); } if (!existsSync(subgraphPath)) { - mkdirSync(subgraphPath, { recursive: true }); + await mkdir(subgraphPath); } if (!existsSync(scriptsPath) && options.apolloCompatibility) { - mkdirSync(scriptsPath, { recursive: true }); + await mkdir(scriptsPath); } const routerConfig = await fetchRouterConfig({ @@ -50,26 +69,25 @@ export default (opts: BaseCommandOptions) => { name, namespace: options.namespace, }); - writeFileSync(join(superGraphPath, `cosmoConfig.json`), routerConfig.routerConfig); + await writeFile(join(superGraphPath, cosmoConfigFile), routerConfig.routerConfig); if (routerConfig.mapper) { - writeFileSync(join(basePath, `cosmo-mapper.json`), JSON.stringify(routerConfig.mapper)); + await writeFile(join(basePath, cosmoMapperFile), JSON.stringify(routerConfig.mapper)); } if (routerConfig.featureFlags?.size) { - const featureFlagsPath = join(basePath, 'feature-flags'); + const featureFlagsPath = join(basePath, featureFlagsDir); if (!existsSync(featureFlagsPath)) { - mkdirSync(featureFlagsPath, { recursive: true }); + await mkdir(featureFlagsPath); } for (const [featureFlagName, featureFlagConfig] of routerConfig.featureFlags) { - writeFileSync(join(featureFlagsPath, `${featureFlagName}.json`), featureFlagConfig); + await writeFile(join(featureFlagsPath, `${featureFlagName}.json`), featureFlagConfig); } } - writeFileSync(join(superGraphPath, `cosmoSchema.graphql`), fedGraphSchemas.sdl); - + 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 }); @@ -140,14 +158,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({ @@ -171,15 +189,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/router/commands/compose.ts b/cli/src/commands/router/commands/compose.ts index bba911e151..fb4d166d0f 100644 --- a/cli/src/commands/router/commands/compose.ts +++ b/cli/src/commands/router/commands/compose.ts @@ -27,6 +27,7 @@ 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 } from '../utils.js'; import { Config, ConfigSubgraph, @@ -94,10 +95,6 @@ function constructRouterSubgraph(result: FederationSuccess, s: SubgraphMetaData, } satisfies ComposedSubgraphGRPC; } -const featureFlagsDir = 'feature-flags'; -const mapperFile = 'mapper.json'; -const routerConfigFile = 'router-config.json'; - async function handleSplitRouterConfig({ config, inputFileLocation, @@ -173,26 +170,10 @@ async function handleEmbeddedRouterConfig({ if (!config.feature_flags || config.feature_flags.length > 0) { routerConfig.featureFlagConfigs = await buildFeatureFlagsConfig(config, inputFileLocation, subgraphs, options); } + const routerConfigJson = routerConfig.toJsonString(); if (options.out) { - let output: string = options.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(options.out, routerConfigJson); - } - + 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 { diff --git a/cli/src/commands/router/commands/fetch.ts b/cli/src/commands/router/commands/fetch.ts index e478f31dde..de259c3605 100644 --- a/cli/src/commands/router/commands/fetch.ts +++ b/cli/src/commands/router/commands/fetch.ts @@ -1,47 +1,11 @@ -import { writeFile, mkdir } from 'node:fs/promises'; +import { writeFile, mkdir, readdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; import { Command } from 'commander'; import pc from 'picocolors'; import { resolve, join } from 'pathe'; import { BaseCommandOptions } from '../../../core/types/types.js'; -import { fetchRouterConfig, type FetchRouterConfigResult } from '../utils.js'; - -export const handleOutput = async ( - out: string | undefined, - graphSignKey: string | undefined, - config: FetchRouterConfigResult, -) => { - if (out) { - if (config.splitConfigLoading) { - let directory = resolve(out); - await mkdir(directory, { recursive: true }); - await writeFile(join(directory, 'latest.json'), config.routerConfig); - if (config.mapper) { - await writeFile(join(directory, 'mapper.json'), JSON.stringify(config.mapper)); - } - - if (config.featureFlags && config.featureFlags.size > 0) { - directory = resolve(directory, 'feature-flags'); - await mkdir(directory, { recursive: true }); - - for (const [featureFlagName, featureFlagRouterConfig] of config.featureFlags) { - await writeFile(resolve(directory, `${featureFlagName}.json`), featureFlagRouterConfig); - } - } - } else { - await writeFile(resolve(out), config.routerConfig); - } - - if (graphSignKey) { - console.log(pc.green('The signature of the router config matches the local computed signature.')); - } - - console.log( - pc.green(`The router config${config.splitConfigLoading ? 's' : ''} has been written to ${pc.bold(out)}`), - ); - } else { - console.log(config.routerConfig); - } -}; +import { fetchRouterConfig, mapperFile, featureFlagsDir, getRouterConfigOutputFile, latestFile } from '../utils.js'; +import type { FetchRouterConfigResult } from '../types/types.js'; export default (opts: BaseCommandOptions) => { const command = new Command('fetch'); @@ -76,3 +40,83 @@ export default (opts: BaseCommandOptions) => { return command; }; + +const handleOutput = (out: string | undefined, graphSignKey: string | undefined, config: FetchRouterConfigResult) => { + return config.splitConfigLoading + ? handleSplitRouterConfig(out, !!graphSignKey, config) + : handleEmbeddedRouterConfig(out, !!graphSignKey, config); +}; + +async function handleSplitRouterConfig( + out: string | undefined, + graphSignKey: boolean, + config: FetchRouterConfigResult, +) { + let outputDir = out ? resolve(out) : out; + if (!outputDir) { + outputDir = resolve('router-config-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; + } + + await writeFile(join(outputDir, latestFile), config.routerConfig); + if (config.mapper) { + await writeFile(join(outputDir, mapperFile), JSON.stringify(config.mapper)); + } + + 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; + } + + for (const [featureFlagName, featureFlagRouterConfig] of config.featureFlags) { + await writeFile(join(ffDir, `${featureFlagName}.json`), featureFlagRouterConfig); + } + } + + if (graphSignKey) { + console.log(pc.green('The signature of the router config matches the local computed signature.')); + } + + console.log(pc.green(`The router configs has been written to ${pc.bold(outputDir)}`)); +} + +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.')); + } + + 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/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 index e57eff34df..02a9cedfab 100644 --- a/cli/src/commands/router/utils.ts +++ b/cli/src/commands/router/utils.ts @@ -1,16 +1,41 @@ +import { existsSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; import jwtDecode from 'jwt-decode'; import pc from 'picocolors'; -import { Client } from '../../core/client/client.js'; +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'; + +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); + } -export interface FetchRouterConfigResult { - splitConfigLoading: boolean; - routerConfig: string; - featureFlags?: Map; - mapper?: Record; + return output; } export const fetchRouterConfig = async ({ @@ -18,12 +43,7 @@ export const fetchRouterConfig = async ({ name, namespace, graphSignKey, -}: { - client: Client; - name: string; - namespace?: string; - graphSignKey?: string; -}): Promise => { +}: FetchRouterConfigParams): Promise => { const resp = await client.platform.generateRouterToken( { fedGraphName: name, From 0d559aa5814e0f28bcb90ef7b361e1771282f28a Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Thu, 18 Jun 2026 09:58:09 -0400 Subject: [PATCH 24/28] chore: validate that the output directory is empty --- cli/src/commands/graph/federated-graph/commands/fetch.ts | 9 ++++++++- cli/test/router/compose.test.ts | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/graph/federated-graph/commands/fetch.ts b/cli/src/commands/graph/federated-graph/commands/fetch.ts index 66559aebcd..564fb72ebe 100644 --- a/cli/src/commands/graph/federated-graph/commands/fetch.ts +++ b/cli/src/commands/graph/federated-graph/commands/fetch.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { mkdir, writeFile } from 'node:fs/promises'; +import { mkdir, readdir, writeFile } from 'node:fs/promises'; import { Command } from 'commander'; import yaml from 'js-yaml'; import { join, resolve } from 'pathe'; @@ -50,6 +50,13 @@ export default (opts: BaseCommandOptions) => { 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/'); diff --git a/cli/test/router/compose.test.ts b/cli/test/router/compose.test.ts index ed1f68ae1d..ee8be6f6fd 100644 --- a/cli/test/router/compose.test.ts +++ b/cli/test/router/compose.test.ts @@ -1,4 +1,4 @@ -import { readFile, mkdir } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { existsSync } from 'node:fs'; From e0bbd16123c476a8335686f7de09664da33ab571 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Thu, 25 Jun 2026 11:53:09 -0400 Subject: [PATCH 25/28] chore: fix missing parameter --- controlplane/src/core/services/CompositionService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/controlplane/src/core/services/CompositionService.ts b/controlplane/src/core/services/CompositionService.ts index 63a8b8e82b..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) { From 2a088580b2bc9fa0274fc63da83c9dd5c41ddd41 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 1 Jul 2026 10:48:11 -0400 Subject: [PATCH 26/28] chore: update base path and add some comments --- .../graph/federated-graph/commands/fetch.ts | 16 ++++++------ cli/test/graph/federated-graph/fetch.test.ts | 25 ++++++++++--------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/cli/src/commands/graph/federated-graph/commands/fetch.ts b/cli/src/commands/graph/federated-graph/commands/fetch.ts index 564fb72ebe..deffd500a8 100644 --- a/cli/src/commands/graph/federated-graph/commands/fetch.ts +++ b/cli/src/commands/graph/federated-graph/commands/fetch.ts @@ -40,12 +40,8 @@ export default (opts: BaseCommandOptions) => { namespace: options.namespace, }); - let basePath = options.out ? resolve(options.out) : options.out; - if (!basePath) { - basePath = resolve('fetched-schemas'); - } - - basePath = join(basePath, `${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 }); } @@ -62,13 +58,13 @@ export default (opts: BaseCommandOptions) => { const scriptsPath = join(basePath, '/scripts/'); if (!existsSync(superGraphPath)) { - await mkdir(superGraphPath); + await mkdir(superGraphPath, { recursive: true }); } if (!existsSync(subgraphPath)) { - await mkdir(subgraphPath); + await mkdir(subgraphPath, { recursive: true }); } if (!existsSync(scriptsPath) && options.apolloCompatibility) { - await mkdir(scriptsPath); + await mkdir(scriptsPath, { recursive: true }); } const routerConfig = await fetchRouterConfig({ @@ -78,10 +74,12 @@ export default (opts: BaseCommandOptions) => { }); 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)); } 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); diff --git a/cli/test/graph/federated-graph/fetch.test.ts b/cli/test/graph/federated-graph/fetch.test.ts index 8d43ffc695..b3eaecca2f 100644 --- a/cli/test/graph/federated-graph/fetch.test.ts +++ b/cli/test/graph/federated-graph/fetch.test.ts @@ -1,4 +1,4 @@ -import { readFile, mkdir } from 'node:fs/promises'; +import { readFile, mkdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { existsSync } from 'node:fs'; @@ -67,9 +67,9 @@ describe('federated-graph fetch command tests', () => { global.fetch = vi.fn(mockFetchRouterConfig); - const outputDir = join(tmpdir(), 'federated-graph-fetch'); - if (!existsSync(outputDir)) { - await mkdir(outputDir, { recursive: true }); + let outputDir = join(tmpdir(), 'federated-graph-fetch'); + if (existsSync(outputDir)) { + await rm(outputDir, { recursive: true }); } const program = new Command(); @@ -79,13 +79,14 @@ describe('federated-graph fetch command tests', () => { from: 'user', }); - expect(existsSync(join(outputDir, 'fake-graph'))).toBe(true); - expect(existsSync(join(outputDir, 'fake-graph', 'cosmo-composition.yaml'))).toBe(true); - expect(existsSync(join(outputDir, 'fake-graph', 'supergraph'))).toBe(true); - expect(existsSync(join(outputDir, 'fake-graph', 'supergraph', 'cosmoConfig.json'))).toBe(true); + 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, 'fake-graph', 'supergraph', 'cosmoConfig.json'), 'utf8'); + const content = await readFile(join(outputDir, 'supergraph', 'cosmoConfig.json'), 'utf8'); await expect(content).toMatchFileSnapshot(join(FIXTURES_DIR_PATH, 'router-compose', `router-config.json.snap`)); }); @@ -97,8 +98,8 @@ describe('federated-graph fetch command tests', () => { global.fetch = vi.fn(mockFetchRouterConfig); let outputDir = join(tmpdir(), 'federated-graph-fetch-split'); - if (!existsSync(outputDir)) { - await mkdir(outputDir); + if (existsSync(outputDir)) { + await rm(outputDir, { recursive: true }); } const program = new Command(); @@ -108,7 +109,7 @@ describe('federated-graph fetch command tests', () => { from: 'user', }); - outputDir = join(outputDir, 'fake-graph'); + 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); From 69121dc69c3e48867d4b72d1217eae76ae790a5a Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 1 Jul 2026 12:03:11 -0400 Subject: [PATCH 27/28] chore: add normalization util for feature flag name --- .../graph/federated-graph/commands/fetch.ts | 4 +- cli/src/commands/router/commands/compose.ts | 10 +++- cli/src/commands/router/commands/fetch.ts | 11 +++- cli/src/commands/router/utils.ts | 30 ++++++++++- cli/test/router/utils.test.ts | 53 +++++++++++++++++++ 5 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 cli/test/router/utils.test.ts diff --git a/cli/src/commands/graph/federated-graph/commands/fetch.ts b/cli/src/commands/graph/federated-graph/commands/fetch.ts index deffd500a8..ba8f8a05eb 100644 --- a/cli/src/commands/graph/federated-graph/commands/fetch.ts +++ b/cli/src/commands/graph/federated-graph/commands/fetch.ts @@ -6,7 +6,7 @@ import { join, resolve } from 'pathe'; import pc from 'picocolors'; import { BaseCommandOptions } from '../../../../core/types/types.js'; import { getFederatedGraphSchemas, getSubgraphSDL, getSubgraphsOfFedGraph } from '../utils.js'; -import { featureFlagsDir, fetchRouterConfig } from '../../../router/utils.js'; +import { featureFlagsDir, fetchRouterConfig, writeFeatureFlagConfigToFile } from '../../../router/utils.js'; const cosmoConfigFile = 'cosmoConfig.json'; const cosmoMapperFile = 'cosmo-mapper.json'; @@ -86,7 +86,7 @@ export default (opts: BaseCommandOptions) => { } for (const [featureFlagName, featureFlagConfig] of routerConfig.featureFlags) { - await writeFile(join(featureFlagsPath, `${featureFlagName}.json`), featureFlagConfig); + await writeFeatureFlagConfigToFile(featureFlagsPath, featureFlagName, featureFlagConfig); } } diff --git a/cli/src/commands/router/commands/compose.ts b/cli/src/commands/router/commands/compose.ts index fb4d166d0f..418b8ffa38 100644 --- a/cli/src/commands/router/commands/compose.ts +++ b/cli/src/commands/router/commands/compose.ts @@ -27,7 +27,13 @@ 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 } from '../utils.js'; +import { + mapperFile, + routerConfigFile, + featureFlagsDir, + getRouterConfigOutputFile, + writeFeatureFlagConfigToFile, +} from '../utils.js'; import { Config, ConfigSubgraph, @@ -152,7 +158,7 @@ async function handleSplitRouterConfig({ }); const routerConfigJson = ffRouterConfig.toJsonString(); - await writeFile(join(ffDir, `${featureFlagName}.json`), routerConfigJson); + await writeFeatureFlagConfigToFile(ffDir, featureFlagName, routerConfigJson); mapper.set(featureFlagName, createHash('sha256').update(routerConfigJson).digest('hex')); } diff --git a/cli/src/commands/router/commands/fetch.ts b/cli/src/commands/router/commands/fetch.ts index de259c3605..f2cbc9043d 100644 --- a/cli/src/commands/router/commands/fetch.ts +++ b/cli/src/commands/router/commands/fetch.ts @@ -4,7 +4,14 @@ import { Command } from 'commander'; import pc from 'picocolors'; import { resolve, join } from 'pathe'; import { BaseCommandOptions } from '../../../core/types/types.js'; -import { fetchRouterConfig, mapperFile, featureFlagsDir, getRouterConfigOutputFile, latestFile } from '../utils.js'; +import { + fetchRouterConfig, + mapperFile, + featureFlagsDir, + getRouterConfigOutputFile, + latestFile, + writeFeatureFlagConfigToFile, +} from '../utils.js'; import type { FetchRouterConfigResult } from '../types/types.js'; export default (opts: BaseCommandOptions) => { @@ -92,7 +99,7 @@ async function handleSplitRouterConfig( } for (const [featureFlagName, featureFlagRouterConfig] of config.featureFlags) { - await writeFile(join(ffDir, `${featureFlagName}.json`), featureFlagRouterConfig); + await writeFeatureFlagConfigToFile(ffDir, featureFlagName, featureFlagRouterConfig); } } diff --git a/cli/src/commands/router/utils.ts b/cli/src/commands/router/utils.ts index 02a9cedfab..f81481a1da 100644 --- a/cli/src/commands/router/utils.ts +++ b/cli/src/commands/router/utils.ts @@ -1,5 +1,6 @@ import { existsSync } from 'node:fs'; -import { mkdir } from 'node:fs/promises'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { basename, resolve, parse, sep } from 'node:path'; import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; import jwtDecode from 'jwt-decode'; import pc from 'picocolors'; @@ -121,6 +122,33 @@ export const fetchRouterConfig = async ({ return result; }; +export async function writeFeatureFlagConfigToFile( + basePath: string, + featureFlagName: string, + featureFlagConfig: string, +) { + let outputDir = basePath; + let fileName = featureFlagName; + if (featureFlagName.includes('/') || featureFlagName.includes('\\')) { + const currentRoot = parse(process.cwd()).root; + + const normalizedSlashes = dirname(fileName.replace(/\\/g, sep).replace(/\//g, sep)); + fileName = basename(fileName); + + if (normalizedSlashes && normalizedSlashes !== currentRoot) { + const normalizedSubpath = resolve(`${currentRoot}${sep}${normalizedSlashes}`); + if (normalizedSubpath !== currentRoot) { + outputDir = join(outputDir, normalizedSubpath); + if (!existsSync(outputDir)) { + await mkdir(outputDir, { recursive: true }); + } + } + } + } + + await writeFile(join(outputDir, `${fileName}.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'); diff --git a/cli/test/router/utils.test.ts b/cli/test/router/utils.test.ts new file mode 100644 index 0000000000..a6bedc6725 --- /dev/null +++ b/cli/test/router/utils.test.ts @@ -0,0 +1,53 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import { mkdir, rm } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +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 uniqueName = randomUUID(); + + expect(existsSync(join(basePath, `${uniqueName}.json`))).toBe(false); + await writeFeatureFlagConfigToFile(basePath, uniqueName, ''); + expect(existsSync(join(basePath, `${uniqueName}.json`))).toBe(true); + }); + + test('that when a name starts with slash, it is written to the base path', async () => { + const uniqueName = randomUUID(); + + expect(existsSync(join(basePath, `${uniqueName}.json`))).toBe(false); + await writeFeatureFlagConfigToFile(basePath, `/${uniqueName}`, ''); + expect(existsSync(join(basePath, `${uniqueName}.json`))).toBe(true); + }); + + test('that when `..` is found in the name, the file does not escape the base path', async () => { + const uniqueName = randomUUID(); + const name = `../../../../${uniqueName}`; + + expect(existsSync(join(basePath, `${uniqueName}.json`))).toBe(false); + await writeFeatureFlagConfigToFile(basePath, name, ''); + expect(existsSync(join(basePath, `${uniqueName}.json`))).toBe(true); + }); + + test('that a name with special characters does not escape the base path', async () => { + const uniqueName = randomUUID(); + const name = `feature/../../flag/${uniqueName}`; + + expect(existsSync(join(basePath, 'flag', `${uniqueName}.json`))).toBe(false); + await writeFeatureFlagConfigToFile(basePath, name, ''); + expect(existsSync(join(basePath, 'flag', `${uniqueName}.json`))).toBe(true); + }); +}); From 470cea1bc8407378797eeee10f81204697fe2832 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 1 Jul 2026 12:39:48 -0400 Subject: [PATCH 28/28] chore: reject feature flags with invalid names --- cli/src/commands/router/utils.ts | 24 ++++-------------- cli/test/router/utils.test.ts | 42 ++++++++++---------------------- 2 files changed, 18 insertions(+), 48 deletions(-) diff --git a/cli/src/commands/router/utils.ts b/cli/src/commands/router/utils.ts index f81481a1da..97697b4090 100644 --- a/cli/src/commands/router/utils.ts +++ b/cli/src/commands/router/utils.ts @@ -1,6 +1,5 @@ import { existsSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; -import { basename, resolve, parse, sep } from 'node:path'; import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; import jwtDecode from 'jwt-decode'; import pc from 'picocolors'; @@ -16,6 +15,8 @@ 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; @@ -127,26 +128,11 @@ export async function writeFeatureFlagConfigToFile( featureFlagName: string, featureFlagConfig: string, ) { - let outputDir = basePath; - let fileName = featureFlagName; - if (featureFlagName.includes('/') || featureFlagName.includes('\\')) { - const currentRoot = parse(process.cwd()).root; - - const normalizedSlashes = dirname(fileName.replace(/\\/g, sep).replace(/\//g, sep)); - fileName = basename(fileName); - - if (normalizedSlashes && normalizedSlashes !== currentRoot) { - const normalizedSubpath = resolve(`${currentRoot}${sep}${normalizedSlashes}`); - if (normalizedSubpath !== currentRoot) { - outputDir = join(outputDir, normalizedSubpath); - if (!existsSync(outputDir)) { - await mkdir(outputDir, { recursive: true }); - } - } - } + if (invalidCharacters.test(featureFlagName)) { + throw new Error(`The feature flag name "${featureFlagName}" contains invalid characters.`); } - await writeFile(join(outputDir, `${fileName}.json`), featureFlagConfig); + await writeFile(join(basePath, `${featureFlagName}.json`), featureFlagConfig); } async function fetchFileContentFromCdn(url: URL, token: string, graphSignKey?: string): Promise { diff --git a/cli/test/router/utils.test.ts b/cli/test/router/utils.test.ts index a6bedc6725..22896c7561 100644 --- a/cli/test/router/utils.test.ts +++ b/cli/test/router/utils.test.ts @@ -2,7 +2,6 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { existsSync } from 'node:fs'; import { mkdir, rm } from 'node:fs/promises'; -import { randomUUID } from 'node:crypto'; import { beforeAll, describe, expect, test } from 'vitest'; import { writeFeatureFlagConfigToFile, featureFlagsDir } from '../../src/commands/router/utils.js'; @@ -18,36 +17,21 @@ describe('writeFeatureFlagConfigToFile', () => { }); test('that when no special characters are found, it is written to the base path', async () => { - const uniqueName = randomUUID(); + const name = 'feature-flag'; - expect(existsSync(join(basePath, `${uniqueName}.json`))).toBe(false); - await writeFeatureFlagConfigToFile(basePath, uniqueName, ''); - expect(existsSync(join(basePath, `${uniqueName}.json`))).toBe(true); - }); - - test('that when a name starts with slash, it is written to the base path', async () => { - const uniqueName = randomUUID(); - - expect(existsSync(join(basePath, `${uniqueName}.json`))).toBe(false); - await writeFeatureFlagConfigToFile(basePath, `/${uniqueName}`, ''); - expect(existsSync(join(basePath, `${uniqueName}.json`))).toBe(true); - }); - - test('that when `..` is found in the name, the file does not escape the base path', async () => { - const uniqueName = randomUUID(); - const name = `../../../../${uniqueName}`; - - expect(existsSync(join(basePath, `${uniqueName}.json`))).toBe(false); + expect(existsSync(join(basePath, `${name}.json`))).toBe(false); await writeFeatureFlagConfigToFile(basePath, name, ''); - expect(existsSync(join(basePath, `${uniqueName}.json`))).toBe(true); + expect(existsSync(join(basePath, `${name}.json`))).toBe(true); }); - test('that a name with special characters does not escape the base path', async () => { - const uniqueName = randomUUID(); - const name = `feature/../../flag/${uniqueName}`; - - expect(existsSync(join(basePath, 'flag', `${uniqueName}.json`))).toBe(false); - await writeFeatureFlagConfigToFile(basePath, name, ''); - expect(existsSync(join(basePath, 'flag', `${uniqueName}.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); + }, + ); });