Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pnpm-lock.yaml
# Output
gen
dist
connect/src/wg

# Next.js build output
.next
Expand Down
2,427 changes: 1,271 additions & 1,156 deletions connect-go/gen/proto/wg/cosmo/platform/v1/platform.pb.go

Large diffs are not rendered by default.

297 changes: 177 additions & 120 deletions connect/src/wg/cosmo/platform/v1/platform_pb.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { HandlerContext } from '@connectrpc/connect';
import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb';
import {
FeatureSubgraphInFlagComposition,
GetFeatureFlagsInLatestCompositionByFederatedGraphRequest,
GetFeatureFlagsInLatestCompositionByFederatedGraphResponse,
} from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb';
import { PlainMessage, FeatureFlagDTO } from '../../../types/index.js';
import { FeatureFlagRepository } from '../../repositories/FeatureFlagRepository.js';
import { FederatedGraphRepository } from '../../repositories/FederatedGraphRepository.js';
import { GraphCompositionRepository } from '../../repositories/GraphCompositionRepository.js';
import { NamespaceRepository } from '../../repositories/NamespaceRepository.js';
import type { RouterOptions } from '../../routes.js';
import { enrichLogger, getLogger, handleError } from '../../util.js';
Expand Down Expand Up @@ -37,6 +39,7 @@ export function getFeatureFlagsInLatestCompositionByFederatedGraph(
details: `Namespace ${req.namespace} not found`,
},
featureFlags: [],
featureSubgraphs: [],
};
}

Expand All @@ -48,6 +51,7 @@ export function getFeatureFlagsInLatestCompositionByFederatedGraph(
details: `Federated Graph '${req.federatedGraphName}' not found`,
},
featureFlags: [],
featureSubgraphs: [],
};
}

Expand All @@ -62,28 +66,54 @@ export function getFeatureFlagsInLatestCompositionByFederatedGraph(
});

const featureFlags: FeatureFlagDTO[] = [];
if (ffsInLatestValidComposition) {
for (const ff of ffsInLatestValidComposition) {
if (!ff.featureFlagId) {
continue;
}
const flag = await featureFlagRepo.getFeatureFlagById({
featureFlagId: ff.featureFlagId,
namespaceId: namespace.id,
includeSubgraphs: false,
});
if (flag) {
// True means the composition reported for this flag is its last successful one, not its latest.
featureFlags.push({ ...flag, hasFailedLatestComposition: ff.hasFailedLatestComposition });
}
const flagIdByComposedSchemaVersionId = new Map<string, string>();
for (const ff of ffsInLatestValidComposition ?? []) {
if (!ff.featureFlagId) {
continue;
}
const flag = await featureFlagRepo.getFeatureFlagById({
featureFlagId: ff.featureFlagId,
namespaceId: namespace.id,
includeSubgraphs: false,
});
if (flag) {
// True means the composition reported for this flag is its last successful one, not its latest.
featureFlags.push({ ...flag, hasFailedLatestComposition: ff.hasFailedLatestComposition });
flagIdByComposedSchemaVersionId.set(ff.id, ff.featureFlagId);
}
}

const compositionRepo = new GraphCompositionRepository(logger, opts.db);
const pinnedFeatureSubgraphs = await compositionRepo.getFeatureSubgraphsByComposedSchemaVersionIds({
schemaVersionIds: [...flagIdByComposedSchemaVersionId.keys()],
organizationId: authContext.organizationId,
rbac: authContext.rbac,
});

const featureSubgraphs: PlainMessage<FeatureSubgraphInFlagComposition>[] = [];
for (const pinned of pinnedFeatureSubgraphs) {
const featureFlagId = flagIdByComposedSchemaVersionId.get(pinned.composedSchemaVersionId);
if (!featureFlagId) {
continue;
}

featureSubgraphs.push({
featureFlagId,
id: pinned.id,
name: pinned.name,
targetId: pinned.targetId,
schemaVersionId: pinned.schemaVersionId,
routingUrl: pinned.routingUrl,
subscriptionUrl: pinned.subscriptionUrl ?? '',
});
}

return {
response: {
code: EnumStatusCode.OK,
},
featureFlags,
featureSubgraphs,
};
},
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ import {
graphCompositionSubgraphs,
schemaVersion,
subgraphs,
targets,
users,
} from '../../db/schema.js';
import { DateRange, GraphCompositionDTO } from '../../types/index.js';
import { CompositionSubgraphRecord } from '../composition/composer.js';
import { RBACEvaluator } from '../services/RBACEvaluator.js';
import { traced } from '../tracing.js';
import { FederatedGraphRepository } from './FederatedGraphRepository.js';
import { OrganizationRepository } from './OrganizationRepository.js';
import { SubgraphRepository } from './SubgraphRepository.js';

@traced
export class GraphCompositionRepository {
Expand Down Expand Up @@ -411,6 +414,51 @@ export class GraphCompositionRepository {
return [...compositionSubgraphs, ...childCompositionSubgraphs];
}

/**
* @param input.schemaVersionIds Composed schema versions of the compositions to read.
* @returns A row per feature subgraph per composition, where `schemaVersionId` is the version
* that composition froze rather than the latest published one. Feature subgraphs deleted since
* the composition are omitted, even though `graph_composition_subgraphs` retains their rows.
*/
public async getFeatureSubgraphsByComposedSchemaVersionIds(input: {
schemaVersionIds: string[];
organizationId: string;
rbac?: RBACEvaluator;
}) {
Comment thread
gausie marked this conversation as resolved.
if (input.schemaVersionIds.length === 0) {
return [];
}

const conditions: (SQL<unknown> | undefined)[] = [
inArray(graphCompositions.schemaVersionId, input.schemaVersionIds),
eq(schemaVersion.organizationId, input.organizationId),
eq(graphCompositionSubgraphs.isFeatureSubgraph, true),
];
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// The query must join targets, which the RBAC conditions gate on.
if (!SubgraphRepository.applyRbacConditionsToQuery(input.rbac, conditions)) {
return [];
}

return await this.db
.select({
composedSchemaVersionId: graphCompositions.schemaVersionId,
id: graphCompositionSubgraphs.subgraphId,
name: graphCompositionSubgraphs.subgraphName,
targetId: graphCompositionSubgraphs.subgraphTargetId,
schemaVersionId: graphCompositionSubgraphs.schemaVersionId,
routingUrl: subgraphs.routingUrl,
subscriptionUrl: subgraphs.subscriptionUrl,
})
.from(graphCompositionSubgraphs)
.innerJoin(graphCompositions, eq(graphCompositions.id, graphCompositionSubgraphs.graphCompositionId))
.innerJoin(schemaVersion, eq(schemaVersion.id, graphCompositions.schemaVersionId))
.innerJoin(subgraphs, eq(subgraphs.id, graphCompositionSubgraphs.subgraphId))
.innerJoin(targets, eq(targets.id, subgraphs.targetId))
.where(and(...conditions))
.execute();
}

public async getGraphCompositions({
fedGraphTargetId,
organizationId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import { join } from 'node:path';
import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb';
import { addMinutes, formatISO, subDays } from 'date-fns';
import { afterAll, beforeAll, describe, expect, onTestFinished, test } from 'vitest';
import { afterAllSetup, beforeAllSetup, genID, genUniqueLabel } from '../../src/core/test-util.js';
import {
afterAllSetup,
beforeAllSetup,
createTestGroup,
createTestRBACEvaluator,
genID,
genUniqueLabel,
} from '../../src/core/test-util.js';
import {
createAndPublishSubgraph,
createFeatureFlag,
Expand All @@ -17,6 +24,103 @@ import {

let dbname = '';

async function expectFeatureSubgraphsScopedToTheirFlag(client: Awaited<ReturnType<typeof SetupTest>>['client']) {
const namespace = genID('namespace').toLowerCase();
await createNamespace(client, namespace);

const labels = [genUniqueLabel()];
const federatedGraphName = genID('fedGraph');

await createAndPublishSubgraph(
client,
'products',
namespace,
fs.readFileSync(join(process.cwd(), 'test/test-data/feature-flags/products-standalone.graphql')).toString(),
labels,
DEFAULT_SUBGRAPH_URL_ONE,
);

// Two feature subgraphs over one base subgraph, which is only legal across separate flags.
await createThenPublishFeatureSubgraph(
client,
'products-feature-one',
'products',
namespace,
fs.readFileSync(join(process.cwd(), 'test/test-data/feature-flags/products-standalone-feature.graphql')).toString(),
labels,
'http://localhost:4101',
);

await createThenPublishFeatureSubgraph(
client,
'products-feature-two',
'products',
namespace,
fs.readFileSync(join(process.cwd(), 'test/test-data/feature-flags/products-standalone-update.graphql')).toString(),
labels,
'http://localhost:4102',
);

const federatedGraphLabels = labels.map(({ key, value }) => `${key}=${value}`);
await createFederatedGraph(client, federatedGraphName, namespace, federatedGraphLabels, DEFAULT_ROUTER_URL);

const flagOneName = genID('flag');
await createFeatureFlag(client, flagOneName, labels, ['products-feature-one'], namespace, true);

const flagTwoName = genID('flag');
await createFeatureFlag(client, flagTwoName, labels, ['products-feature-two'], namespace, true);

let resp = await client.getFeatureFlagsInLatestCompositionByFederatedGraph({
federatedGraphName,
namespace,
});
expect(resp.response?.code).toBe(EnumStatusCode.OK);

const flagOne = resp.featureFlags.find((flag) => flag.name === flagOneName);
const flagTwo = resp.featureFlags.find((flag) => flag.name === flagTwoName);
expect(flagOne).toBeDefined();
expect(flagTwo).toBeDefined();

const featureSubgraphOne = resp.featureSubgraphs.find((sg) => sg.featureFlagId === flagOne!.id);
const featureSubgraphTwo = resp.featureSubgraphs.find((sg) => sg.featureFlagId === flagTwo!.id);

expect(featureSubgraphOne?.name).toBe('products-feature-one');
expect(featureSubgraphOne?.routingUrl).toBe('http://localhost:4101');
expect(featureSubgraphOne?.schemaVersionId).toBeTruthy();

expect(featureSubgraphTwo?.name).toBe('products-feature-two');
expect(featureSubgraphTwo?.routingUrl).toBe('http://localhost:4102');
expect(featureSubgraphTwo?.schemaVersionId).toBeTruthy();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Republishing recomposes only the flag that contains it.
const previousVersionOne = featureSubgraphOne!.schemaVersionId;
const previousVersionTwo = featureSubgraphTwo!.schemaVersionId;

const republishResp = await client.publishFederatedSubgraph({
name: 'products-feature-one',
namespace,
schema: fs
.readFileSync(join(process.cwd(), 'test/test-data/feature-flags/products-standalone-update.graphql'))
.toString(),
});
expect(republishResp.response?.code).toBe(EnumStatusCode.OK);

resp = await client.getFeatureFlagsInLatestCompositionByFederatedGraph({
federatedGraphName,
namespace,
});
expect(resp.response?.code).toBe(EnumStatusCode.OK);

const updatedFlagOne = resp.featureFlags.find((flag) => flag.name === flagOneName);
const updatedFlagTwo = resp.featureFlags.find((flag) => flag.name === flagTwoName);

const updatedFeatureSubgraphOne = resp.featureSubgraphs.find((sg) => sg.featureFlagId === updatedFlagOne!.id);
const updatedFeatureSubgraphTwo = resp.featureSubgraphs.find((sg) => sg.featureFlagId === updatedFlagTwo!.id);

expect(updatedFeatureSubgraphOne?.schemaVersionId).not.toBe(previousVersionOne);
expect(updatedFeatureSubgraphTwo?.schemaVersionId).toBe(previousVersionTwo);
}

describe('GetFeatureFlagsInLatestCompositionByFederatedGraph', () => {
beforeAll(async () => {
dbname = await beforeAllSetup();
Expand Down Expand Up @@ -769,4 +873,76 @@ describe('GetFeatureFlagsInLatestCompositionByFederatedGraph', () => {
expect(resp.featureFlags.map((f) => f.name)).toStrictEqual([enabledFlagName]);
},
);

test('that feature subgraphs are returned scoped to the flag whose composition pinned them', async (testContext) => {
const { client, server } = await SetupTest({ dbname });
testContext.onTestFinished(() => server.close());

await expectFeatureSubgraphsScopedToTheirFlag(client);
});

test('that feature subgraphs are scoped to their flag when split config loading is enabled', async (testContext) => {
const { client, server } = await SetupTest({ dbname, enabledFeatures: ['split-config-loading'] });
testContext.onTestFinished(() => server.close());

await expectFeatureSubgraphsScopedToTheirFlag(client);
});

test('that feature subgraphs are hidden from a caller without subgraph read access', async (testContext) => {
const { client, server, authenticator, users } = await SetupTest({ dbname });
testContext.onTestFinished(() => server.close());

const namespace = genID('namespace').toLowerCase();
const labels = [genUniqueLabel()];
const federatedGraphName = genID('fedGraph');

await createNamespace(client, namespace);

await createAndPublishSubgraph(
client,
'products',
namespace,
fs.readFileSync(join(process.cwd(), 'test/test-data/feature-flags/products-standalone.graphql')).toString(),
labels,
DEFAULT_SUBGRAPH_URL_ONE,
);

await createThenPublishFeatureSubgraph(
client,
'products-feature',
'products',
namespace,
fs
.readFileSync(join(process.cwd(), 'test/test-data/feature-flags/products-standalone-feature.graphql'))
.toString(),
labels,
'http://localhost:4101',
);

const federatedGraphLabels = labels.map(({ key, value }) => `${key}=${value}`);
await createFederatedGraph(client, federatedGraphName, namespace, federatedGraphLabels, DEFAULT_ROUTER_URL);

const flagName = genID('flag');
await createFeatureFlag(client, flagName, labels, ['products-feature'], namespace, true);

const namespaceResp = await client.getNamespace({ name: namespace });
expect(namespaceResp.response?.code).toBe(EnumStatusCode.OK);

// graph-viewer can read the graph and its flags, but has no subgraph role.
authenticator.changeUserWithSuppliedContext({
...users.adminAliceCompanyA,
rbac: createTestRBACEvaluator(
createTestGroup({ role: 'graph-viewer', namespaces: [namespaceResp.namespace!.id] }),
),
});

const resp = await client.getFeatureFlagsInLatestCompositionByFederatedGraph({
federatedGraphName,
namespace,
});

expect(resp.response?.code).toBe(EnumStatusCode.OK);
expect(resp.featureFlags.some((flag) => flag.name === flagName)).toBe(true);
expect(resp.featureSubgraphs).toHaveLength(0);
});
});
13 changes: 13 additions & 0 deletions docs-website/studio/playground.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@ The playground is enhanced with visual representations of the query execution pl
For more information about Advanced Request Tracing (ART) click [here](/router/advanced-request-tracing-art)
</Info>

## Choosing what to query

The dropdown in the playground toolbar selects what the editor validates against and where requests are sent:

- **Graph** sends requests to the router and uses the federated client schema.
- **Feature flags** send requests to the router with the `X-Feature-Flag` header set to that flag, and use the flag's composed client schema. Only flags in the graph's latest valid composition are listed.
- **Subgraphs** send requests to the subgraph's own routing URL, bypassing the router, and use the schema that subgraph contributed to the latest composition.
- **Feature subgraphs** are listed under the feature flag they belong to. They send requests to the feature subgraph's own routing URL, bypassing the router, and use the schema version that flag's composition pinned. No `X-Feature-Flag` header is sent, since the router is not in the request path.
Comment thread
gausie marked this conversation as resolved.

<Note>
A feature subgraph appears under a feature flag rather than in the Subgraphs group because feature flags compose independently, so the same feature subgraph can be pinned at a different schema version by each flag that contains it. See [Feature Flags](/concepts/feature-flags).
</Note>

<Frame caption="Waterfall View">
<img
src="/images/studio/parallel-queries-for-employees-and-products.png"
Expand Down
Loading
Loading