Experimental: Incremental native composition migration (#4936)

This commit is contained in:
Kamil Kisiela 2024-06-14 09:12:07 +02:00 committed by GitHub
parent 524e2cd9d3
commit 53a0804434
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1660 additions and 188 deletions

View file

@ -123,6 +123,8 @@ module.exports = {
],
'@typescript-eslint/no-floating-promises': 'error',
...rulesToExtends,
'no-lonely-if': 'off',
'object-shorthand': 'off',
'no-restricted-syntax': ['error', ...HIVE_RESTRICTED_SYNTAX, ...RESTRICTED_SYNTAX],
'prefer-destructuring': 'off',
'prefer-const': 'off',

View file

@ -10,6 +10,7 @@ import type {
DeleteMemberRoleInput,
DeleteTokensInput,
EnableExternalSchemaCompositionInput,
Experimental__UpdateTargetSchemaCompositionInput,
InviteToOrganizationByEmailInput,
OperationsStatsSelectorInput,
OrganizationSelectorInput,
@ -1333,3 +1334,24 @@ export async function enableExternalSchemaComposition(
token,
});
}
export async function updateTargetSchemaComposition(
input: Experimental__UpdateTargetSchemaCompositionInput,
token: string,
) {
return execute({
document: graphql(`
mutation experimental__updateTargetSchemaComposition(
$input: Experimental__UpdateTargetSchemaCompositionInput!
) {
experimental__updateTargetSchemaComposition(input: $input) {
id
}
}
`),
variables: {
input,
},
token,
});
}

View file

@ -92,12 +92,12 @@ export function initSeed() {
return {
organization,
async setFeatureFlag(name: string, enabled: boolean) {
async setFeatureFlag(name: string, value: boolean | string[]) {
const pool = await createConnectionPool();
await pool.query(sql`
UPDATE organizations SET feature_flags = ${sql.jsonb({
[name]: enabled,
[name]: value,
})}
WHERE id = ${organization.id}
`);
@ -399,21 +399,29 @@ export function initSeed() {
targetScopes = [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite],
projectScopes = [],
organizationScopes = [],
targetId = target.cleanId,
target: forTarget = {
cleanId: target.cleanId,
id: target.id,
},
actorToken = ownerToken,
}: {
targetScopes?: TargetAccessScope[];
projectScopes?: ProjectAccessScope[];
organizationScopes?: OrganizationAccessScope[];
targetId?: string;
target?: {
cleanId: string;
id: string;
};
actorToken?: string;
}) {
const target = forTarget;
const tokenResult = await createToken(
{
name: generateUnique(),
organization: organization.cleanId,
project: project.cleanId,
target: targetId,
target: target.cleanId,
organizationScopes: organizationScopes,
projectScopes: projectScopes,
targetScopes: targetScopes,

View file

@ -576,7 +576,7 @@ describe.each`
targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite],
projectScopes: [],
organizationScopes: [],
targetId: target2.cleanId,
target: target2,
});
const publishResult2 = await writeTokenResult2
.publishSchema({

View file

@ -54,7 +54,7 @@ test.concurrent('cannot set a scope on a token if user has no access to that sco
targetScopes: [TargetAccessScope.RegistryWrite],
projectScopes: [],
organizationScopes: [],
targetId: target.cleanId,
target,
actorToken: memberToken,
});

View file

@ -360,7 +360,7 @@ test.concurrent('check usage from two selected targets', async () => {
],
projectScopes: [ProjectAccessScope.Read],
organizationScopes: [OrganizationAccessScope.Read],
targetId: productionTarget.cleanId,
target: productionTarget,
});
const schemaPublishResult = await stagingToken
@ -677,7 +677,7 @@ describe('changes with usage data', () => {
],
projectScopes: [ProjectAccessScope.Read],
organizationScopes: [OrganizationAccessScope.Read],
targetId: target.cleanId,
target,
});
const schemaPublishResult = await token

View file

@ -284,7 +284,7 @@ describe('publish', () => {
}),
).resolves.toMatchInlineSnapshot(`
v Published initial schema.
i Available at http://localhost:8080/$organization/$project/production
i Available at http://localhost:8080/$organization/$project/$target
`);
await expect(
@ -308,7 +308,7 @@ describe('publish', () => {
Safe changes:
- Field price was added to object type Product
v Schema published
i Available at http://localhost:8080/$organization/$project/production/history/$version
i Available at http://localhost:8080/$organization/$project/$target/history/$version
`);
});
});

View file

@ -0,0 +1,974 @@
import { updateTargetSchemaComposition } from 'testkit/flow';
import { ProjectType, TargetAccessScope } from 'testkit/gql/graphql';
import { normalizeCliOutput } from '../../../scripts/serializers/cli-output';
import { createCLI, schemaPublish } from '../../testkit/cli';
import { initSeed } from '../../testkit/seed';
type TargetOption = 'targetWithNativeComposition' | 'targetWithLegacyComposition';
function isLegacyComposition(caseName: TargetOption) {
return caseName === 'targetWithLegacyComposition';
}
const options = ['targetWithNativeComposition', 'targetWithLegacyComposition'] as TargetOption[];
describe('publish', () => {
describe.concurrent.each(options)('%s', caseName => {
const legacyComposition = isLegacyComposition(caseName);
test.concurrent('accepted: composable', async () => {
const {
cli: { publish },
} = await prepare(caseName);
await publish({
sdl: `type Query { topProductName: String }`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
expect: 'latest-composable',
});
});
test.concurrent('accepted: composable, breaking changes', async () => {
const {
cli: { publish },
} = await prepare(caseName);
await publish({
sdl: /* GraphQL */ `
type Query {
topProductName: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
expect: 'latest-composable',
});
await publish({
sdl: /* GraphQL */ `
type Query {
nooooo: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
expect: 'latest-composable',
});
});
test.concurrent(
`${legacyComposition ? 'rejected' : 'accepted'}: not composable (graphql errors)`,
async () => {
const {
cli: { publish },
} = await prepare(caseName);
// non-composable
await publish({
sdl: /* GraphQL */ `
type Query {
topProduct: Product
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
expect: legacyComposition ? 'rejected' : 'latest',
});
},
);
test.concurrent('accepted: composable, previous version was not', async () => {
const {
cli: { publish },
} = await prepare(caseName);
// non-composable
await publish({
sdl: /* GraphQL */ `
type Query {
product(id: ID!): Product
}
type Product @key(fields: "it") {
id: ID!
name: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
expect: 'latest',
});
// composable
await publish({
sdl: /* GraphQL */ `
type Query {
product(id: ID!): Product
}
type Product @key(fields: "id") {
id: ID!
name: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
expect: 'latest-composable',
});
});
test.concurrent('accepted: composable, no changes', async () => {
const {
cli: { publish },
} = await prepare(caseName);
// composable
await publish({
sdl: /* GraphQL */ `
type Query {
topProduct: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
expect: 'latest-composable',
});
// composable but no changes
await publish({
sdl: /* GraphQL */ `
type Query {
topProduct: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
expect: 'ignored',
});
});
test.concurrent('accepted: composable, no changes, no metadata modification', async () => {
const {
cli: { publish },
} = await prepare(caseName);
// composable
await publish({
sdl: /* GraphQL */ `
type Query {
topProduct: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
metadata: { products: 3000 },
expect: 'latest-composable',
});
// composable but no changes
await publish({
sdl: /* GraphQL */ `
type Query {
topProduct: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
metadata: { products: 3000 },
expect: 'ignored',
});
});
test.concurrent('accepted: composable, new url', async () => {
const {
cli: { publish },
} = await prepare(caseName);
// composable
await publish({
sdl: /* GraphQL */ `
type Query {
topProduct: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
expect: 'latest-composable',
});
// composable, no changes, only url is different
await publish({
sdl: /* GraphQL */ `
type Query {
topProduct: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:4321/graphql', // new url
expect: 'latest-composable',
});
});
test.concurrent('accepted: composable, new metadata', async () => {
const {
cli: { publish },
} = await prepare(caseName);
// composable
await publish({
sdl: /* GraphQL */ `
type Query {
topProduct: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
metadata: { products: 'old' },
expect: 'latest-composable',
});
// composable, no changes, only url is different
await publish({
sdl: /* GraphQL */ `
type Query {
topProduct: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
metadata: { products: 'new' }, // new metadata
expect: 'latest-composable',
});
});
test.concurrent('rejected: missing service name', async () => {
const {
cli: { publish },
} = await prepare(caseName);
// composable
await publish({
sdl: /* GraphQL */ `
type Query {
topProduct: String
}
`,
serviceUrl: 'http://products:3000/graphql',
expect: 'rejected',
});
});
test.concurrent('rejected: missing service url', async () => {
const {
cli: { publish },
} = await prepare(caseName);
// composable
await publish({
sdl: /* GraphQL */ `
type Query {
topProduct: String
}
`,
serviceName: 'products',
expect: 'rejected',
});
});
test.concurrent('CLI output', async ({ expect }) => {
const {
cli: { publish },
} = await prepare(caseName);
const service = {
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
};
let output = normalizeCliOutput(
(await publish({
sdl: /* GraphQL */ `
type Query {
topProduct: Product
}
type Product {
id: ID!
name: String!
}
`,
...service,
expect: 'latest-composable',
})) ?? '',
);
expect(output).toEqual(expect.stringContaining(`v Published initial schema.`));
expect(output).toEqual(
expect.stringContaining(
`i Available at http://localhost:8080/$organization/$project/$target`,
),
);
output = normalizeCliOutput(
(await publish({
sdl: /* GraphQL */ `
type Query {
topProduct: Product
}
type Product {
id: ID!
name: String!
price: Int!
}
`,
...service,
expect: 'latest-composable',
})) ?? '',
);
expect(output).toEqual(expect.stringContaining(`v Schema published`));
expect(output).toEqual(
expect.stringContaining(
`i Available at http://localhost:8080/$organization/$project/$target/history/$version`,
),
);
});
});
});
describe('check', () => {
describe.concurrent.each(options)('%s', caseName => {
const legacyComposition = isLegacyComposition(caseName);
test.concurrent('accepted: composable, no breaking changes', async () => {
const {
cli: { publish, check },
} = await prepare(caseName);
await publish({
sdl: /* GraphQL */ `
type Query {
topProduct: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
expect: 'latest-composable',
});
const message = await check({
sdl: /* GraphQL */ `
type Query {
topProduct: String
topProductName: String
}
`,
serviceName: 'products',
expect: 'approved',
});
expect(message).toMatch('topProductName');
});
test.concurrent('accepted: composable, previous version was not', async () => {
const {
cli: { publish, check },
} = await prepare(caseName);
await publish({
sdl: /* GraphQL */ `
type Query {
product(id: ID!): Product
}
type Product @key(fields: "it") {
id: ID!
name: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
expect: 'latest',
});
await check({
sdl: /* GraphQL */ `
type Query {
product(id: ID!): Product
topProduct: Product
}
type Product @key(fields: "id") {
id: ID!
name: String
}
`,
serviceName: 'products',
expect: 'approved',
});
});
test.concurrent('accepted: no changes', async () => {
const {
cli: { publish, check },
} = await prepare(caseName);
await publish({
sdl: /* GraphQL */ `
type Query {
topProduct: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
expect: 'latest-composable',
});
await check({
sdl: /* GraphQL */ `
type Query {
topProduct: String
}
`,
serviceName: 'products',
expect: 'approved',
});
});
test.concurrent('rejected: missing service name', async () => {
const {
cli: { check },
} = await prepare(caseName);
const message = await check({
sdl: /* GraphQL */ `
type Query {
topProduct: String
}
`,
expect: 'rejected',
});
expect(message).toMatch('name');
});
test.concurrent('rejected: composable, breaking changes', async () => {
const {
cli: { publish, check },
} = await prepare(caseName);
await publish({
sdl: /* GraphQL */ `
type Query {
topProduct: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
expect: 'latest-composable',
});
const message = await check({
sdl: /* GraphQL */ `
type Query {
topProductName: String
}
`,
serviceName: 'products',
expect: 'rejected',
});
expect(message).toMatch('removed');
});
test.concurrent('rejected: not composable, no breaking changes', async () => {
const {
cli: { publish, check },
} = await prepare(caseName);
await publish({
sdl: /* GraphQL */ `
type Query {
topProduct: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
expect: 'latest-composable',
});
const message = await check({
sdl: /* GraphQL */ `
type Query {
topProduct: String
topProductName: Strin
}
`,
serviceName: 'products',
expect: 'rejected',
});
expect(message).toMatch('Strin');
});
test.concurrent('rejected: not composable, breaking changes', async () => {
const {
cli: { publish, check },
} = await prepare(caseName);
await publish({
sdl: /* GraphQL */ `
type Query {
topProduct: Product
}
type Product @key(fields: "id") {
id: ID!
name: String
}
`,
serviceName: 'products' + caseName,
serviceUrl: 'http://products:3000/graphql',
expect: 'latest-composable',
});
const message = normalizeCliOutput(
await check({
sdl: /* GraphQL */ `
type Query {
product(id: ID!): Product
}
type Product @key(fields: "it") {
id: ID!
name: String
}
`,
serviceName: 'products' + caseName,
expect: 'rejected',
}),
);
if (legacyComposition) {
expect(message).toMatch('Product.it');
expect(message).toMatch('topProduct');
} else {
expect(message).toContain('Cannot query field it on type Product');
}
});
});
});
describe('delete', () => {
describe.concurrent.each(options)('%s', caseName => {
test.concurrent('accepted: composable before and after', async () => {
const { cli } = await prepare(caseName);
await cli.publish({
sdl: /* GraphQL */ `
type Query {
topProduct: Product
}
type Product @key(fields: "id") {
id: ID!
name: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
expect: 'latest-composable',
});
await cli.publish({
sdl: /* GraphQL */ `
type Query {
topReview: Review
}
type Review @key(fields: "id") {
id: ID!
title: String
}
`,
serviceName: 'reviews',
serviceUrl: 'http://reviews:3000/graphql',
expect: 'latest-composable',
});
const message = await cli.delete({
serviceName: 'reviews',
expect: 'latest-composable',
});
expect(message).toMatch('reviews deleted');
});
test.concurrent('rejected: unknown service', async () => {
const { cli } = await prepare(caseName);
await cli.publish({
sdl: /* GraphQL */ `
type Query {
topProduct: Product
}
type Product @key(fields: "id") {
id: ID!
name: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
expect: 'latest-composable',
});
const message = await cli.delete({
serviceName: 'unknown_service',
expect: 'rejected',
});
expect(message).toMatch('not found');
});
});
});
describe('other', () => {
describe.concurrent.each(options)('%s', caseName => {
test.concurrent('service url should be available in supergraph', async () => {
const { createOrg } = await initSeed().createOwner();
const { inviteAndJoinMember, createProject } = await createOrg();
await inviteAndJoinMember();
const { createToken } = await createProject(ProjectType.Federation);
const { secret, fetchSupergraph } = await createToken({});
await schemaPublish([
'--token',
secret,
'--author',
'Kamil',
'--commit',
'abc123',
'--service',
'users',
'--url',
'https://api.com/users-subgraph',
'fixtures/federation-init.graphql',
]);
const supergraph = await fetchSupergraph();
expect(supergraph).toMatch('(name: "users", url: "https://api.com/users-subgraph")');
});
test.concurrent(
'publishing composable schema without the definition of the Query type, but only extension, should work',
async () => {
const { tokens } = await prepare(caseName);
await tokens.readWriteToken.publishSchema({
service: 'products',
author: 'Kamil',
commit: 'products',
url: 'https://api.com/products',
experimental_acceptBreakingChanges: true,
force: true,
sdl: /* GraphQL */ `
type Product @key(fields: "id") {
id: ID!
title: String
url: String
}
extend type Query {
product(id: ID!): Product
}
`,
});
await tokens.readWriteToken.publishSchema({
service: 'users',
author: 'Kamil',
commit: 'users',
url: 'https://api.com/users',
experimental_acceptBreakingChanges: true,
force: true,
sdl: /* GraphQL */ `
type User @key(fields: "id") {
id: ID!
name: String!
}
extend type Query {
user(id: ID!): User
}
`,
});
const latestValid = await tokens.readWriteToken.fetchLatestValidSchema();
expect(latestValid.latestValidVersion?.schemas.nodes[0]).toEqual(
expect.objectContaining({
commit: 'users',
}),
);
},
);
test.concurrent(
'(experimental_acceptBreakingChanges and force) publishing composable schema on second attempt',
async () => {
const { tokens } = await prepare(caseName);
await tokens.readWriteToken.publishSchema({
service: 'reviews',
author: 'Kamil',
commit: 'reviews',
url: 'https://api.com/reviews',
experimental_acceptBreakingChanges: true,
force: true,
sdl: /* GraphQL */ `
extend type Product @key(fields: "id") {
id: ID! @external
reviews: [Review]
reviewSummary: ReviewSummary
}
type Review @key(fields: "id") {
id: ID!
rating: Float
}
type ReviewSummary {
totalReviews: Int
}
`,
});
await tokens.readWriteToken.publishSchema({
service: 'products',
author: 'Kamil',
commit: 'products',
url: 'https://api.com/products',
experimental_acceptBreakingChanges: true,
force: true,
sdl: /* GraphQL */ `
enum CURRENCY_CODE {
USD
}
type Department {
category: ProductCategory
url: String
}
type Money {
amount: Float
currencyCode: CURRENCY_CODE
}
type Price {
cost: Money
deal: Float
dealSavings: Money
}
type Product @key(fields: "id") {
id: ID!
title: String
url: String
description: String
price: Price
salesRank(category: ProductCategory = ALL): Int
salesRankOverall: Int
salesRankInCategory: Int
category: ProductCategory
images(size: Int = 1000): [String]
primaryImage(size: Int = 1000): String
}
enum ProductCategory {
ALL
GIFT_CARDS
ELECTRONICS
CAMERA_N_PHOTO
VIDEO_GAMES
BOOKS
CLOTHING
}
extend type Query {
categories: [Department]
product(id: ID!): Product
}
`,
});
const latestValid = await tokens.readWriteToken.fetchLatestValidSchema();
expect(latestValid.latestValidVersion?.schemas.nodes[0]).toEqual(
expect.objectContaining({
commit: 'products',
}),
);
},
);
test.concurrent('metadata should always be published as an array', async () => {
const { cli, cdn } = await prepare(caseName);
await cli.publish({
sdl: /* GraphQL */ `
type Query {
topProduct: Product
}
type Product @key(fields: "id") {
id: ID!
name: String
}
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
metadata: { products: 'v1' },
expect: 'latest-composable',
});
await expect(cdn.fetchMetadata()).resolves.toEqual(
expect.objectContaining({
status: 200,
body: [{ products: 'v1' }], // array
}),
);
await cli.publish({
sdl: /* GraphQL */ `
type Query {
topReview: Review
}
type Review @key(fields: "id") {
id: ID!
title: String
}
`,
serviceName: 'reviews',
serviceUrl: 'http://reviews:3000/graphql',
metadata: { reviews: 'v1' },
expect: 'latest-composable',
});
await expect(cdn.fetchMetadata()).resolves.toEqual(
expect.objectContaining({
status: 200,
body: [{ products: 'v1' }, { reviews: 'v1' }], // array
}),
);
await cli.delete({
serviceName: 'reviews',
expect: 'latest-composable',
});
await expect(cdn.fetchMetadata()).resolves.toEqual(
expect.objectContaining({
status: 200,
body: [{ products: 'v1' }], // array
}),
);
});
});
});
async function prepare(targetPick: TargetOption) {
const { targetWithLegacyComposition, targetWithNativeComposition, organization, project } =
await prepareProject(ProjectType.Federation);
// force legacy composition
await updateTargetSchemaComposition(
{
organization: organization.cleanId,
project: project.cleanId,
target: targetWithLegacyComposition.cleanId,
nativeComposition: false,
},
targetWithLegacyComposition.tokens.secrets.settings,
);
const target =
targetPick === 'targetWithLegacyComposition'
? targetWithLegacyComposition
: targetWithNativeComposition;
return {
cli: createCLI(target.tokens.secrets),
cdn: target.cdn,
tokens: target.tokens,
};
}
async function prepareProject(projectType: ProjectType) {
const { createOrg } = await initSeed().createOwner();
const { organization, createProject } = await createOrg();
const { project, createToken, targets } = await createProject(projectType, {
useLegacyRegistryModels: false,
});
if (targets.length < 2) {
throw new Error('Expected at least two targets');
}
const nativeTarget = targets[0];
const legacyTarget = targets[1];
async function prepareTarget(target: (typeof targets)[number]) {
// Create a token with write rights
const readWriteToken = await createToken({
organizationScopes: [],
projectScopes: [],
targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite],
target,
});
// Create a token with read-only rights
const readonlyToken = await createToken({
organizationScopes: [],
projectScopes: [],
targetScopes: [TargetAccessScope.RegistryRead],
target,
});
const settingsToken = await createToken({
organizationScopes: [],
projectScopes: [],
targetScopes: [TargetAccessScope.Settings],
target,
});
// Create CDN token
const { secretAccessToken: cdnToken, cdnUrl } = await readWriteToken.createCdnAccess();
return {
id: target.id,
cleanId: target.cleanId,
fetchVersions: readonlyToken.fetchVersions,
tokens: {
secrets: {
readwrite: readWriteToken.secret,
readonly: readonlyToken.secret,
settings: settingsToken.secret,
},
readWriteToken,
readonlyToken,
},
cdn: {
token: cdnToken,
url: cdnUrl,
fetchMetadata() {
return readWriteToken.fetchMetadataFromCDN();
},
},
};
}
return {
organization,
project,
targets,
targetWithNativeComposition: await prepareTarget(nativeTarget),
targetWithLegacyComposition: await prepareTarget(legacyTarget),
};
}

View file

@ -4,23 +4,23 @@ import { createCLI, schemaPublish } from '../../testkit/cli';
import { prepareProject } from '../../testkit/registry-models';
import { initSeed } from '../../testkit/seed';
type FFValue = boolean | string[];
type FeatureFlags = [string, FFValue][];
const cases = [
['default' as const, [] as [string, boolean][]],
['default' as const, [] as FeatureFlags],
[
'compareToPreviousComposableVersion' as const,
[['compareToPreviousComposableVersion', true]] as [string, boolean][],
[['compareToPreviousComposableVersion', true]] as FeatureFlags,
],
['@apollo/federation' as const, [] as [string, boolean][]],
] as Array<
[
'default' | 'compareToPreviousComposableVersion' | '@apollo/federation',
Array<[string, boolean]>,
]
>;
['@apollo/federation' as const, [] as FeatureFlags],
] as const;
const isLegacyComposition = (caseName: string) => caseName === '@apollo/federation';
describe('publish', () => {
describe.concurrent.each(cases)('%s', (caseName, ffs) => {
const legacyComposition = caseName === '@apollo/federation';
const legacyComposition = isLegacyComposition(caseName);
test.concurrent('accepted: composable', async () => {
const {
@ -62,7 +62,7 @@ describe('publish', () => {
});
test.concurrent(
`${caseName === '@apollo/federation' ? 'rejected' : 'accepted'}: not composable (graphql errors)`,
`${legacyComposition ? 'rejected' : 'accepted'}: not composable (graphql errors)`,
async () => {
const {
cli: { publish },
@ -77,7 +77,7 @@ describe('publish', () => {
`,
serviceName: 'products',
serviceUrl: 'http://products:3000/graphql',
expect: caseName === '@apollo/federation' ? 'rejected' : 'latest',
expect: legacyComposition ? 'rejected' : 'latest',
});
},
);
@ -308,7 +308,7 @@ describe('publish', () => {
expect(output).toEqual(expect.stringContaining(`v Published initial schema.`));
expect(output).toEqual(
expect.stringContaining(
`i Available at http://localhost:8080/$organization/$project/production`,
`i Available at http://localhost:8080/$organization/$project/$target`,
),
);
@ -333,7 +333,7 @@ describe('publish', () => {
expect(output).toEqual(expect.stringContaining(`v Schema published`));
expect(output).toEqual(
expect.stringContaining(
`i Available at http://localhost:8080/$organization/$project/production/history/$version`,
`i Available at http://localhost:8080/$organization/$project/$target/history/$version`,
),
);
});
@ -342,7 +342,7 @@ describe('publish', () => {
describe('check', () => {
describe.concurrent.each(cases)('%s', (caseName, ffs) => {
const legacyComposition = caseName === '@apollo/federation';
const legacyComposition = isLegacyComposition(caseName);
test.concurrent('accepted: composable, no breaking changes', async () => {
const {
@ -563,7 +563,7 @@ describe('check', () => {
describe('delete', () => {
describe.concurrent.each(cases)('%s', (caseName, ffs) => {
const legacyComposition = caseName === '@apollo/federation';
const legacyComposition = isLegacyComposition(caseName);
test.concurrent('accepted: composable before and after', async () => {
const { cli } = await prepare(ffs, legacyComposition);
@ -905,13 +905,13 @@ describe('other', () => {
});
});
async function prepare(featureFlags: Array<[string, boolean]> = [], legacyComposition = false) {
async function prepare(featureFlags: Array<[string, FFValue]> = [], legacyComposition = false) {
const { tokens, setFeatureFlag, setNativeFederation, cdn } = await prepareProject(
ProjectType.Federation,
);
for await (const [name, enabled] of featureFlags) {
await setFeatureFlag(name, enabled);
for await (const [name, value] of featureFlags) {
await setFeatureFlag(name, value);
}
if (legacyComposition === true) {

View file

@ -149,7 +149,7 @@ describe('publish', () => {
}),
).resolves.toMatchInlineSnapshot(`
v Published initial schema.
i Available at http://localhost:8080/$organization/$project/production
i Available at http://localhost:8080/$organization/$project/$target
`);
await expect(
@ -172,7 +172,7 @@ describe('publish', () => {
Safe changes:
- Field price was added to object type Product
v Schema published
i Available at http://localhost:8080/$organization/$project/production/history/$version
i Available at http://localhost:8080/$organization/$project/$target/history/$version
`);
});
});

View file

@ -144,7 +144,7 @@ describe('publish', () => {
expect(output).toEqual(expect.stringContaining(`v Published initial schema.`));
expect(output).toEqual(
expect.stringContaining(
`i Available at http://localhost:8080/$organization/$project/production`,
`i Available at http://localhost:8080/$organization/$project/$target`,
),
);
@ -168,7 +168,7 @@ describe('publish', () => {
expect(output).toEqual(expect.stringContaining(`v Schema published`));
expect(output).toEqual(
expect.stringContaining(
`i Available at http://localhost:8080/$organization/$project/production/history/$version`,
`i Available at http://localhost:8080/$organization/$project/$target/history/$version`,
),
);
});

View file

@ -253,7 +253,7 @@ describe('publish', () => {
}),
).resolves.toMatchInlineSnapshot(`
v Published initial schema.
i Available at http://localhost:8080/$organization/$project/production
i Available at http://localhost:8080/$organization/$project/$target
`);
await expect(
@ -277,7 +277,7 @@ describe('publish', () => {
Safe changes:
- Field price was added to object type Product
v Schema published
i Available at http://localhost:8080/$organization/$project/production/history/$version
i Available at http://localhost:8080/$organization/$project/$target/history/$version
`);
});
});

View file

@ -269,7 +269,7 @@ describe('publish', () => {
expect(output).toEqual(expect.stringContaining('v Published initial schema.'));
expect(output).toEqual(
expect.stringContaining(
'i Available at http://localhost:8080/$organization/$project/production',
'i Available at http://localhost:8080/$organization/$project/$target',
),
);
@ -294,7 +294,7 @@ describe('publish', () => {
expect(output).toEqual(expect.stringContaining(`v Schema published`));
expect(output).toEqual(
expect.stringContaining(
`i Available at http://localhost:8080/$organization/$project/production/history/$version`,
`i Available at http://localhost:8080/$organization/$project/$target/history/$version`,
),
);
});

View file

@ -110,6 +110,10 @@ export class OrganizationManager {
return this.storage.getOrganizations({ user: user.id });
}
getFeatureFlags(selector: OrganizationSelector) {
return this.getOrganization(selector).then(organization => organization.featureFlags);
}
async canLeaveOrganization({
organizationId,
userId,

View file

@ -88,6 +88,7 @@ export default gql`
type: ProjectType!
buildUrl: String
validationUrl: String
experimental_nativeCompositionPerTarget: Boolean!
}
type ProjectConnection {

View file

@ -162,5 +162,22 @@ export const resolvers: ProjectModule.Resolvers & { ProjectType: any } = {
return injector.get(ProjectManager).getProjects({ organization: organization.id });
},
},
Project: {
async experimental_nativeCompositionPerTarget(project, _, { injector }) {
if (project.type !== ProjectType.FEDERATION) {
return false;
}
if (!project.nativeFederation) {
return false;
}
const organization = await injector.get(OrganizationManager).getOrganization({
organization: project.orgId,
});
return organization.featureFlags.forceLegacyCompositionInTargets.length > 0;
},
},
ProjectConnection: createConnection(),
};

View file

@ -105,6 +105,7 @@ export class CompositeLegacyModel {
const compositionCheck = await this.checks.composition({
orchestrator,
targetId: selector.target,
project,
organization,
schemas,
@ -117,6 +118,7 @@ export class CompositeLegacyModel {
version: latest,
organization,
project,
targetId: selector.target,
});
const diffCheck = await this.checks.diff({
@ -265,6 +267,7 @@ export class CompositeLegacyModel {
const compositionCheck = await this.checks.composition({
orchestrator,
targetId: target.id,
project,
organization,
schemas,
@ -277,6 +280,7 @@ export class CompositeLegacyModel {
version: latestVersion,
organization,
project,
targetId: target.id,
});
const [diffCheck, metadataCheck] = await Promise.all([

View file

@ -4,6 +4,7 @@ import { FederationOrchestrator } from '../orchestrators/federation';
import { StitchingOrchestrator } from '../orchestrators/stitching';
import { RegistryChecks, type ConditionalBreakingChangeDiffConfig } from '../registry-checks';
import { swapServices } from '../schema-helper';
import { shouldUseLatestComposableVersion } from '../schema-manager';
import type { PublishInput } from '../schema-publisher';
import type {
DeletedCompositeSchema,
@ -144,8 +145,11 @@ export class CompositeModel {
const schemas = latest ? swapServices(latest.schemas, incoming).schemas : [incoming];
schemas.sort((a, b) => a.service_name.localeCompare(b.service_name));
const compareToPreviousComposableVersion =
organization.featureFlags.compareToPreviousComposableVersion || project.nativeFederation;
const compareToPreviousComposableVersion = shouldUseLatestComposableVersion(
selector.target,
project,
organization,
);
const comparedVersion = compareToPreviousComposableVersion ? latestComposable : latest;
const checksumCheck = await this.checks.checksum({
@ -174,6 +178,7 @@ export class CompositeModel {
const compositionCheck = await this.checks.composition({
orchestrator,
targetId: selector.target,
project,
organization,
schemas,
@ -195,6 +200,7 @@ export class CompositeModel {
version: comparedVersion,
organization,
project,
targetId: selector.target,
});
const contractChecks = await this.getContractChecks({
@ -318,8 +324,11 @@ export class CompositeModel {
const previousService = swap?.existing;
const schemas = swap?.schemas ?? [incoming];
schemas.sort((a, b) => a.service_name.localeCompare(b.service_name));
const compareToLatestComposable =
organization.featureFlags.compareToPreviousComposableVersion || project.nativeFederation;
const compareToLatestComposable = shouldUseLatestComposableVersion(
target.id,
project,
organization,
);
const schemaVersionToCompareAgainst = compareToLatestComposable ? latestComposable : latest;
const [serviceNameCheck, serviceUrlCheck] = await Promise.all([
@ -399,6 +408,7 @@ export class CompositeModel {
const compositionCheck = await this.checks.composition({
orchestrator,
targetId: target.id,
project,
organization,
schemas,
@ -436,6 +446,7 @@ export class CompositeModel {
version: schemaVersionToCompareAgainst,
organization,
project,
targetId: target.id,
});
const diffCheck = await this.checks.diff({
@ -543,8 +554,11 @@ export class CompositeModel {
};
const latestVersion = latest;
const compareToLatestComposable =
organization.featureFlags.compareToPreviousComposableVersion || project.nativeFederation;
const compareToLatestComposable = shouldUseLatestComposableVersion(
selector.target,
project,
organization,
);
const serviceNameCheck = await this.checks.serviceName({
name: incoming.service_name,
@ -570,6 +584,7 @@ export class CompositeModel {
const compositionCheck = await this.checks.composition({
orchestrator,
targetId: selector.target,
project,
organization,
schemas,
@ -591,6 +606,7 @@ export class CompositeModel {
version: compareToLatestComposable ? latestComposable : latest,
organization,
project,
targetId: selector.target,
});
const diffCheck = await this.checks.diff({

View file

@ -89,6 +89,7 @@ export class SingleLegacyModel {
const compositionCheck = await this.checks.composition({
orchestrator: this.orchestrator,
targetId: selector.target,
project,
organization,
schemas,
@ -101,6 +102,7 @@ export class SingleLegacyModel {
version: latestVersion,
organization,
project,
targetId: selector.target,
});
const diffCheck = await this.checks.diff({
@ -198,6 +200,7 @@ export class SingleLegacyModel {
const compositionCheck = await this.checks.composition({
orchestrator: this.orchestrator,
targetId: target.id,
project,
organization,
baseSchema,
@ -217,6 +220,7 @@ export class SingleLegacyModel {
version: latestVersion,
organization,
project,
targetId: target.id,
});
const [diffCheck, metadataCheck] = await Promise.all([

View file

@ -99,6 +99,7 @@ export class SingleModel {
const compositionCheck = await this.checks.composition({
orchestrator: this.orchestrator,
targetId: selector.target,
project,
organization,
schemas,
@ -111,6 +112,7 @@ export class SingleModel {
version: comparedVersion,
organization,
project,
targetId: selector.target,
});
const [diffCheck, policyCheck] = await Promise.all([
@ -225,6 +227,7 @@ export class SingleModel {
const compositionCheck = await this.checks.composition({
orchestrator: this.orchestrator,
targetId: target.id,
project,
organization,
baseSchema,
@ -244,6 +247,7 @@ export class SingleModel {
version: comparedVersion,
organization,
project,
targetId: target.id,
});
const [metadataCheck, diffCheck] = await Promise.all([

View file

@ -233,6 +233,7 @@ export class RegistryChecks {
async composition({
orchestrator,
targetId,
project,
organization,
schemas,
@ -240,6 +241,7 @@ export class RegistryChecks {
contracts,
}: {
orchestrator: Orchestrator;
targetId: string;
project: Project;
organization: Organization;
schemas: Schemas;
@ -250,7 +252,7 @@ export class RegistryChecks {
extendWithBase(schemas, baseSchema).map(s => this.helper.createSchemaObject(s)),
{
external: project.externalComposition,
native: this.checkProjectNativeFederationSupport(project, organization),
native: this.checkProjectNativeFederationSupport(targetId, project, organization),
contracts,
},
);
@ -305,6 +307,7 @@ export class RegistryChecks {
orchestrator: Orchestrator;
organization: Organization;
project: Project;
targetId: string;
}): Promise<string | null> {
this.logger.debug('Retrieve previous version SDL.');
if (!args.version) {
@ -328,7 +331,11 @@ export class RegistryChecks {
args.version.schemas.map(s => this.helper.createSchemaObject(s)),
{
external: args.project.externalComposition,
native: this.checkProjectNativeFederationSupport(args.project, args.organization),
native: this.checkProjectNativeFederationSupport(
args.targetId,
args.project,
args.organization,
),
contracts: null,
},
);
@ -669,6 +676,7 @@ export class RegistryChecks {
}
public checkProjectNativeFederationSupport(
targetId: string,
project: Project,
organization: Organization,
): boolean {
@ -689,6 +697,16 @@ export class RegistryChecks {
return false;
}
if (organization.featureFlags.forceLegacyCompositionInTargets.includes(targetId)) {
this.logger.warn(
'Project is using legacy composition in target, ignoring native Federation support (organization=%s, project=%s, target=%s)',
organization.id,
project.id,
targetId,
);
return false;
}
this.logger.debug(
'Native Federation support available (organization=%s, project=%s)',
organization.id,

View file

@ -159,7 +159,11 @@ export class SchemaManager {
const compositionResult = await orchestrator.composeAndValidate(services, {
external: project.externalComposition,
native: this.checkProjectNativeFederationSupport({ project, organization }),
native: this.checkProjectNativeFederationSupport({
project,
organization,
targetId: input.target,
}),
contracts: null,
});
@ -523,6 +527,7 @@ export class SchemaManager {
native: this.checkProjectNativeFederationSupport({
project,
organization,
targetId: null,
}),
contracts: null,
},
@ -1070,8 +1075,7 @@ export class SchemaManager {
target: args.target,
beforeVersionId: args.beforeVersionId,
beforeVersionCreatedAt: args.beforeVersionCreatedAt,
onlyComposable:
organization.featureFlags.compareToPreviousComposableVersion || project.nativeFederation,
onlyComposable: shouldUseLatestComposableVersion(args.target, project, organization),
});
if (!schemaVersion) {
@ -1113,6 +1117,7 @@ export class SchemaManager {
}
checkProjectNativeFederationSupport(input: {
targetId: string | null;
project: Pick<Project, 'id' | 'legacyRegistryModel' | 'nativeFederation'>;
organization: Pick<Organization, 'id' | 'featureFlags'>;
}) {
@ -1129,6 +1134,19 @@ export class SchemaManager {
return false;
}
if (
input.targetId &&
input.organization.featureFlags.forceLegacyCompositionInTargets.includes(input.targetId)
) {
this.logger.warn(
'Native Federation support is disabled for this target (organization=%s, project=%s, target=%s)',
input.organization.id,
input.project.id,
input.targetId,
);
return false;
}
this.logger.debug(
'Native Federation support available (organization=%s, project=%s)',
input.organization.id,
@ -1312,3 +1330,17 @@ export class SchemaManager {
return null;
}
}
export function shouldUseLatestComposableVersion(
targetId: string,
project: Project,
organization: Organization,
) {
return (
organization.featureFlags.compareToPreviousComposableVersion ||
// If the project is a native federation project, we should compare to the latest composable version
(project.nativeFederation &&
// but only if the target is not forced to use the legacy composition
!organization.featureFlags.forceLegacyCompositionInTargets.includes(targetId))
);
}

View file

@ -58,7 +58,7 @@ import { SingleModel } from './models/single';
import { SingleLegacyModel } from './models/single-legacy';
import type { ConditionalBreakingChangeDiffConfig } from './registry-checks';
import { ensureCompositeSchemas, ensureSingleSchema, SchemaHelper } from './schema-helper';
import { SchemaManager } from './schema-manager';
import { SchemaManager, shouldUseLatestComposableVersion } from './schema-manager';
import { SchemaVersionHelper } from './schema-version-helper';
const schemaCheckCount = new promClient.Counter({
@ -509,8 +509,11 @@ export class SchemaPublisher {
);
});
const compareToPreviousComposableVersion =
organization.featureFlags.compareToPreviousComposableVersion || project.nativeFederation;
const compareToPreviousComposableVersion = shouldUseLatestComposableVersion(
target.id,
project,
organization,
);
const comparedVersion = compareToPreviousComposableVersion
? latestComposableVersion
@ -1119,6 +1122,7 @@ export class SchemaPublisher {
native: this.schemaManager.checkProjectNativeFederationSupport({
project,
organization,
targetId: target.id,
}),
contracts: null,
});
@ -1211,8 +1215,11 @@ export class SchemaPublisher {
}),
]);
const compareToPreviousComposableVersion =
organization.featureFlags.compareToPreviousComposableVersion || project.nativeFederation;
const compareToPreviousComposableVersion = shouldUseLatestComposableVersion(
input.target.id,
project,
organization,
);
const modelVersion = project.legacyRegistryModel ? 'legacy' : 'modern';
@ -1624,8 +1631,11 @@ export class SchemaPublisher {
);
}
const compareToPreviousComposableVersion =
organization.featureFlags.compareToPreviousComposableVersion || project.nativeFederation;
const compareToPreviousComposableVersion = shouldUseLatestComposableVersion(
target.id,
project,
organization,
);
const comparedSchemaVersion = compareToPreviousComposableVersion
? latestComposableSchemaVersion
: latestSchemaVersion;

View file

@ -68,6 +68,7 @@ export class SchemaVersionHelper {
native: this.schemaManager.checkProjectNativeFederationSupport({
project,
organization,
targetId: schemaVersion.target,
}),
contracts: null,
},

View file

@ -814,6 +814,16 @@ export interface Storage {
userId: string;
organizationId: string;
}): Promise<void>;
/**
* @deprecated It's a temporary method to force legacy composition in targets, when native composition is enabled for a project.
*/
updateTargetSchemaComposition(_: {
organizationId: string;
projectId: string;
targetId: string;
nativeComposition: boolean;
}): Promise<Target>;
}
@Injectable()

View file

@ -15,12 +15,27 @@ export default gql`
): UpdateTargetValidationSettingsResult!
setTargetValidation(input: SetTargetValidationInput!): Target!
"""
"
Updates the target's explorer endpoint url.
"""
updateTargetGraphQLEndpointUrl(
input: UpdateTargetGraphQLEndpointUrlInput!
): UpdateTargetGraphQLEndpointUrlResult!
"""
Overwrites project's schema composition library.
Works only for Federation projects with native composition enabled.
This mutation is temporary and will be removed once no longer needed.
It's part of a feature flag called "forceLegacyCompositionInTargets".
"""
experimental__updateTargetSchemaComposition(
input: Experimental__UpdateTargetSchemaCompositionInput!
): Target!
}
input Experimental__UpdateTargetSchemaCompositionInput {
organization: ID!
project: ID!
target: ID!
nativeComposition: Boolean!
}
input UpdateTargetGraphQLEndpointUrlInput {
@ -148,6 +163,7 @@ export default gql`
"""
graphqlEndpointUrl: String
validationSettings: TargetValidationSettings!
experimental_forcedLegacySchemaComposition: Boolean!
}
type TargetValidationSettings {

View file

@ -314,6 +314,38 @@ export class TargetManager {
target: args.targetId,
});
}
/**
* @deprecated It's a temporary method to force legacy composition in targets, when native composition is enabled for a project.
*/
async updateTargetSchemaComposition(args: {
organizationId: string;
projectId: string;
targetId: string;
nativeComposition: boolean;
}) {
await this.authManager.ensureTargetAccess({
organization: args.organizationId,
project: args.projectId,
target: args.targetId,
scope: TargetAccessScope.SETTINGS,
});
this.logger.info(
`Updating target schema composition (targetId=%s, nativeComposition=%s)`,
args.targetId,
args.nativeComposition,
);
const target = await this.storage.updateTargetSchemaComposition({
organizationId: args.organizationId,
projectId: args.projectId,
targetId: args.targetId,
nativeComposition: args.nativeComposition,
});
return target;
}
}
const TargetGraphQLEndpointUrlModel = zod

View file

@ -39,6 +39,14 @@ export const resolvers: TargetModule.Resolvers = {
),
};
},
experimental_forcedLegacySchemaComposition(target, _, { injector }) {
return injector
.get(OrganizationManager)
.getFeatureFlags({
organization: target.orgId,
})
.then(flags => flags.forceLegacyCompositionInTargets.includes(target.id));
},
},
Query: {
async target(_, { selector }, { injector }) {
@ -296,6 +304,21 @@ export const resolvers: TargetModule.Resolvers = {
},
};
},
async experimental__updateTargetSchemaComposition(_, { input }, { injector }) {
const translator = injector.get(IdTranslator);
const [organizationId, projectId, targetId] = await Promise.all([
translator.translateOrganizationId(input),
translator.translateProjectId(input),
translator.translateTargetId(input),
]);
return injector.get(TargetManager).updateTargetSchemaComposition({
organizationId,
projectId,
targetId,
nativeComposition: input.nativeComposition,
});
},
},
Project: {
targets(project, _, { injector }) {

View file

@ -173,7 +173,22 @@ export interface Organization {
};
getStarted: OrganizationGetStarted;
featureFlags: {
/**
* @deprecated This feature flag is now a default for newly created organizations and projects.
*/
compareToPreviousComposableVersion: boolean;
/**
* Forces selected targets to use @apollo/federation library
* when native composition is enabled for a project.
* This is a temporary solution, requested by one of Hive users.
*
* Before enabling native composition on a project, set a feature flag with a list of ids of all targets.
* We do it this way to allow new targets to use native composition by default and gradually migrate existing ones.
* The other way around would mean that we would have additional complexity and hard time moving away from this feature flag.
*
* @deprecated This feature flag should be removed once no longer needed.
*/
forceLegacyCompositionInTargets: string[];
};
zendeskId: string | null;
}

View file

@ -4714,6 +4714,52 @@ export async function createStorage(
AND "user_id" = ${userId}
`);
},
async updateTargetSchemaComposition(args) {
// I could do it in one query, but the amount of SQL needed to do it in one go is just not worth it...
// It is just too complex to understand.
await pool.transaction(async t => {
const { feature_flags } = await t.one<
Pick<organizations, 'feature_flags'>
>(sql`/* updateTargetSchemaComposition_select */
SELECT feature_flags FROM organizations WHERE id = ${args.organizationId};
`);
const ff = decodeFeatureFlags(feature_flags);
let modify = false;
const includesTarget = ff.forceLegacyCompositionInTargets.includes(args.targetId);
if (args.nativeComposition) {
// delete from the list of targets that need to be forced to use the legacy composition
if (includesTarget) {
ff.forceLegacyCompositionInTargets = ff.forceLegacyCompositionInTargets.filter(
id => id !== args.targetId,
);
modify = true;
}
} else {
// add to the list of targets that need to be forced to use the legacy composition
if (!includesTarget) {
ff.forceLegacyCompositionInTargets.push(args.targetId);
modify = true;
}
}
if (modify) {
await pool.query(sql`/* updateTargetSchemaComposition_update */
UPDATE organizations
SET feature_flags = ${sql.jsonb(ff)}
WHERE id = ${args.organizationId};
`);
}
});
return this.getTarget({
target: args.targetId,
project: args.projectId,
organization: args.organizationId,
});
},
pool,
};
@ -4823,6 +4869,7 @@ const decodeCDNAccessTokenRecord = (result: unknown): CDNAccessToken => {
const FeatureFlagsModel = zod
.object({
compareToPreviousComposableVersion: zod.boolean().default(false),
forceLegacyCompositionInTargets: zod.array(zod.string()).default([]),
})
.optional()
.nullable()
@ -4831,6 +4878,7 @@ const FeatureFlagsModel = zod
val =>
val ?? {
compareToPreviousComposableVersion: false,
forceLegacyCompositionInTargets: [],
},
);

View file

@ -1,13 +1,25 @@
import { useCallback, useState } from 'react';
import { useFormik } from 'formik';
import { useForm } from 'react-hook-form';
import { useMutation, useQuery } from 'urql';
import * as Yup from 'yup';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ProductUpdatesLink } from '@/components/ui/docs-note';
import { DocsNote, Input, Spinner, Switch, Tooltip } from '@/components/v2';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { DocsNote, Spinner, Switch } from '@/components/v2';
import { FragmentType, graphql, useFragment } from '@/gql';
import { useNotifications } from '@/lib/hooks';
import { zodResolver } from '@hookform/resolvers/zod';
import { CheckIcon, Cross2Icon, UpdateIcon } from '@radix-ui/react-icons';
const ExternalCompositionStatus_TestQuery = graphql(`
@ -81,26 +93,53 @@ const ExternalCompositionStatus = ({
const error = query.error?.message ?? query.data?.testExternalSchemaComposition?.error?.message;
return (
<Tooltip.Provider delayDuration={100}>
<TooltipProvider delayDuration={100}>
{query.fetching ? (
<Tooltip content="Connecting..." contentProps={{ side: 'right' }}>
<UpdateIcon className="size-5 animate-spin text-gray-500" />
<Tooltip>
<TooltipTrigger>
<UpdateIcon className="size-5 animate-spin text-gray-500" />
</TooltipTrigger>
<TooltipContent side="right">Connecting...</TooltipContent>
</Tooltip>
) : null}
{error ? (
<Tooltip content={error} contentProps={{ side: 'right' }}>
<Cross2Icon className="size-5 text-red-500" />
<Tooltip>
<TooltipTrigger>
<Cross2Icon className="size-5 text-red-500" />
</TooltipTrigger>
<TooltipContent side="right">{error}</TooltipContent>
</Tooltip>
) : null}
{query.data?.testExternalSchemaComposition?.ok?.externalSchemaComposition?.endpoint ? (
<Tooltip content="Service is available" contentProps={{ side: 'right' }}>
<CheckIcon className="size-5 text-green-500" />
<Tooltip>
<TooltipTrigger>
<CheckIcon className="size-5 text-green-500" />
</TooltipTrigger>
<TooltipContent side="right">Service is available</TooltipContent>
</Tooltip>
) : null}
</Tooltip.Provider>
</TooltipProvider>
);
};
const formSchema = z.object({
endpoint: z
.string({
required_error: 'Please provide an endpoint',
})
.url({
message: 'Invalid URL',
}),
secret: z
.string({
required_error: 'Please provide a secret',
})
.min(2, 'Too short')
.max(256, 'Max 256 characters long'),
});
type FormValues = z.infer<typeof formSchema>;
const ExternalCompositionForm = ({
endpoint,
...props
@ -116,109 +155,136 @@ const ExternalCompositionForm = ({
);
const notify = useNotifications();
const [mutation, enable] = useMutation(ExternalCompositionForm_EnableMutation);
const {
handleSubmit,
values,
handleChange,
handleBlur,
isSubmitting,
errors,
touched,
dirty,
resetForm,
} = useFormik({
enableReinitialize: true,
initialValues: {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
mode: 'onChange',
defaultValues: {
endpoint: endpoint ?? '',
secret: '',
},
validationSchema: Yup.object().shape({
endpoint: Yup.string().required(),
secret: Yup.string().required(),
}),
onSubmit: values =>
enable({
input: {
project: project.cleanId,
organization: organization.cleanId,
endpoint: values.endpoint,
secret: values.secret,
},
}).then(result => {
resetForm();
if (result.data?.enableExternalSchemaComposition?.ok) {
notify('External composition enabled', 'success');
}
}),
disabled: mutation.fetching,
});
const mutationError = mutation.data?.enableExternalSchemaComposition.error;
function onSubmit(values: FormValues) {
void enable({
input: {
project: project.cleanId,
organization: organization.cleanId,
endpoint: values.endpoint,
secret: values.secret,
},
}).then(result => {
if (result.data?.enableExternalSchemaComposition?.ok) {
notify('External composition enabled', 'success');
const endpoint =
result.data?.enableExternalSchemaComposition?.ok.externalSchemaComposition?.endpoint;
if (endpoint) {
form.reset(
{
endpoint,
secret: '',
},
{
keepDirty: false,
keepDirtyValues: false,
},
);
}
} else {
const error =
result.error?.message || result.data?.enableExternalSchemaComposition.error?.message;
if (error) {
notify(error, 'error');
}
const inputErrors = result.data?.enableExternalSchemaComposition.error?.inputErrors;
if (inputErrors?.endpoint) {
form.setError('endpoint', {
type: 'manual',
message: inputErrors.endpoint,
});
}
if (inputErrors?.secret) {
form.setError('secret', {
type: 'manual',
message: inputErrors.secret,
});
}
}
});
}
return (
<div className="flex justify-between">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<span>HTTP endpoint</span>
<p className="pb-2 text-xs text-gray-300">A POST request will be sent to that endpoint</p>
<div className="flex items-center gap-2">
<Input
placeholder="Endpoint"
name="endpoint"
value={values.endpoint}
onChange={handleChange}
onBlur={handleBlur}
disabled={isSubmitting}
isInvalid={touched.endpoint && !!errors.endpoint}
className="w-96"
/>
{!dirty &&
(endpoint ||
mutation.data?.enableExternalSchemaComposition.ok?.externalSchemaComposition
?.endpoint) ? (
<ExternalCompositionStatus
projectId={project.cleanId}
organizationId={organization.cleanId}
/>
) : null}
</div>
{touched.endpoint && (errors.endpoint || mutationError?.inputErrors?.endpoint) && (
<div className="mt-2 text-xs text-red-500">
{errors.endpoint ?? mutationError?.inputErrors?.endpoint}
</div>
)}
</div>
<div>
<span>Secret</span>
<p className="pb-2 text-xs text-gray-300">
The secret is needed to sign and verify the request.
</p>
<Input
placeholder="Secret"
name="secret"
type="password"
value={values.secret}
onChange={handleChange}
onBlur={handleBlur}
disabled={isSubmitting}
isInvalid={touched.secret && !!errors.secret}
className="w-96"
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>HTTP Endpoint</FormLabel>
<FormDescription>A POST request will be sent to that endpoint</FormDescription>
<div className="flex w-full max-w-sm items-center space-x-2">
<FormControl>
<Input
className="w-96 shrink-0"
placeholder="Endpoint"
type="text"
autoComplete="off"
{...field}
/>
</FormControl>
{!form.formState.isDirty &&
(endpoint ||
mutation.data?.enableExternalSchemaComposition.ok?.externalSchemaComposition
?.endpoint) ? (
<ExternalCompositionStatus
projectId={project.cleanId}
organizationId={organization.cleanId}
/>
) : null}
</div>
<FormMessage />
</FormItem>
)}
/>
{touched.secret && (errors.secret || mutationError?.inputErrors?.secret) && (
<div className="mt-2 text-xs text-red-500">
{errors.secret ?? mutationError?.inputErrors?.secret}
</div>
<FormField
control={form.control}
name="secret"
render={({ field }) => (
<FormItem>
<FormLabel>Secret</FormLabel>
<FormDescription>
The secret is needed to sign and verify the request.
</FormDescription>
<FormControl>
<Input
className="w-96"
placeholder="Secret"
type="password"
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{mutation.error && (
<div className="mt-2 text-xs text-red-500">{mutation.error.message}</div>
)}
</div>
{mutation.error && (
<div className="mt-2 text-xs text-red-500">{mutation.error.message}</div>
)}
<div>
<Button type="submit" disabled={isSubmitting}>
Save
</Button>
</div>
</form>
<div>
<Button type="submit" disabled={form.formState.isSubmitting}>
Save
</Button>
</div>
</form>
</Form>
</div>
);
};
@ -322,7 +388,7 @@ export const ExternalCompositionSettings = (props: {
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div>External Composition</div>
<div>External Schema Composition</div>
<div>
{isLoading ? (
<Spinner />
@ -338,16 +404,17 @@ export const ExternalCompositionSettings = (props: {
</CardTitle>
<CardDescription>
<ProductUpdatesLink href="#native-composition">
You can enable native Apollo Federation v2 support in Hive
Enable native Apollo Federation v2 support in Hive
</ProductUpdatesLink>
</CardDescription>
</CardHeader>
<CardContent>
{isNativeCompositionEnabled && isEnabled ? (
<DocsNote warn>
It appears that Native Federation v2 Composition is activated, external composition
won't have any effect.
<DocsNote warn className={isFormVisible ? 'mb-6 mt-0' : ''}>
It appears that Native Federation v2 Composition is activated and will be used instead.
<br />
External composition won't have any effect.
</DocsNote>
) : null}
@ -357,7 +424,11 @@ export const ExternalCompositionSettings = (props: {
organization={organization}
endpoint={externalCompositionConfig?.endpoint}
/>
) : null}
) : (
<Button disabled={mutation.fetching} onClick={() => handleSwitch(true)}>
Enable external composition
</Button>
)}
</CardContent>
</Card>
);

View file

@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { FlaskConicalIcon, HeartCrackIcon, PartyPopperIcon, RefreshCcwIcon } from 'lucide-react';
import { useMutation, useQuery } from 'urql';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
@ -11,9 +12,84 @@ import {
CardTitle,
} from '@/components/ui/card';
import { ProductUpdatesLink } from '@/components/ui/docs-note';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useToast } from '@/components/ui/use-toast';
import { FragmentType, graphql, useFragment } from '@/gql';
import { NativeFederationCompatibilityStatus } from '@/gql/graphql';
import { cn } from '@/lib/utils';
const IncrementalNativeCompositionSwitch_TargetFragment = graphql(`
fragment IncrementalNativeCompositionSwitch_TargetFragment on Target {
id
cleanId
name
experimental_forcedLegacySchemaComposition
}
`);
const IncrementalNativeCompositionSwitch_Mutation = graphql(`
mutation IncrementalNativeCompositionSwitch_Mutation(
$input: Experimental__UpdateTargetSchemaCompositionInput!
) {
experimental__updateTargetSchemaComposition(input: $input) {
...IncrementalNativeCompositionSwitch_TargetFragment
}
}
`);
const IncrementalNativeCompositionSwitch = (props: {
organizationCleanId: string;
projectCleanId: string;
target: FragmentType<typeof IncrementalNativeCompositionSwitch_TargetFragment>;
}) => {
const target = useFragment(IncrementalNativeCompositionSwitch_TargetFragment, props.target);
const [mutation, mutate] = useMutation(IncrementalNativeCompositionSwitch_Mutation);
return (
<div
className={cn(
'flex flex-row items-center gap-x-10 rounded border-[1px] border-gray-800 bg-gray-800/50 p-4',
mutation.fetching && 'animate-pulse',
)}
>
<div>
<div className="text-sm font-semibold">{target.name}</div>
<div className="min-w-32 text-xs">
{target.experimental_forcedLegacySchemaComposition ? 'Legacy' : 'Native'} composition
</div>
</div>
<div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Switch
disabled={mutation.fetching}
onCheckedChange={nativeComposition => {
void mutate({
input: {
organization: props.organizationCleanId,
project: props.projectCleanId,
target: target.cleanId,
nativeComposition,
},
});
}}
checked={!target.experimental_forcedLegacySchemaComposition}
/>
</TooltipTrigger>
<TooltipContent sideOffset={2}>
<span className="font-semibold">
{target.experimental_forcedLegacySchemaComposition ? 'Enable' : 'Disable'}
</span>{' '}
native composition for the target
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
);
};
const NativeCompositionSettings_OrganizationFragment = graphql(`
fragment NativeCompositionSettings_OrganizationFragment on Organization {
@ -27,9 +103,16 @@ const NativeCompositionSettings_ProjectFragment = graphql(`
id
cleanId
isNativeFederationEnabled
experimental_nativeCompositionPerTarget
externalSchemaComposition {
endpoint
}
targets {
nodes {
id
...IncrementalNativeCompositionSwitch_TargetFragment
}
}
}
`);
@ -38,6 +121,7 @@ const NativeCompositionSettings_ProjectQuery = graphql(`
project(selector: $selector) {
id
nativeFederationCompatibility
experimental_nativeCompositionPerTarget
}
}
`);
@ -74,6 +158,7 @@ export function NativeCompositionSettings(props: {
project: project.cleanId,
},
},
pause: project.isNativeFederationEnabled,
});
const [mutationState, mutate] = useMutation(
@ -129,6 +214,14 @@ export function NativeCompositionSettings(props: {
[mutate, toast, organization.cleanId, project.cleanId],
);
let display: 'error' | 'compatibility' | 'enabled' = 'compatibility';
if (projectQuery.error) {
display = 'error';
} else if (project.isNativeFederationEnabled) {
display = 'enabled';
}
return (
<Card>
<CardHeader>
@ -136,15 +229,46 @@ export function NativeCompositionSettings(props: {
<a id="native-composition">Native Federation v2 Composition</a>
</CardTitle>
<CardDescription>Native Apollo Federation v2 support for your project.</CardDescription>
{project.isNativeFederationEnabled ? null : (
{display !== 'enabled' ? (
<CardDescription>
<ProductUpdatesLink href="2023-10-10-native-federation-2">
Read the announcement!
</ProductUpdatesLink>
</CardDescription>
)}
) : null}
</CardHeader>
{projectQuery.error ? (
{display === 'enabled' && project.experimental_nativeCompositionPerTarget === true ? (
<CardContent>
<div className="space-y-4">
<div>
<div className="flex flex-row items-center gap-x-2">
<div className="font-semibold">Incremental migration</div>
<Badge variant="outline">experimental</Badge>
</div>
<div className="text-muted-foreground text-sm">
Your project is using the experimental incremental migration feature. <br />
Migrate targets one by one to the native schema composition.
</div>
</div>
<div>
<div className="flex flex-row gap-4">
{project.targets.nodes.map(target => (
<IncrementalNativeCompositionSwitch
organizationCleanId={organization.cleanId}
projectCleanId={project.cleanId}
key={target.id}
target={target}
/>
))}
</div>
</div>
</div>
</CardContent>
) : null}
{display === 'error' ? (
<CardContent>
<div className="flex flex-row items-center gap-x-4">
<div>
@ -157,7 +281,9 @@ export function NativeCompositionSettings(props: {
</div>
</div>
</CardContent>
) : project.isNativeFederationEnabled || !projectQuery.data?.project ? null : (
) : null}
{display === 'compatibility' && projectQuery.data?.project ? (
<CardContent>
<div className="flex flex-row items-center gap-x-4">
<div>
@ -236,7 +362,7 @@ export function NativeCompositionSettings(props: {
</div>
</div>
</CardContent>
)}
) : null}
<CardFooter>
<div className="flex flex-row items-center gap-x-2">

View file

@ -5,10 +5,22 @@ import { getDocsUrl, getProductUpdatesUrl } from '@/lib/docs-url';
import { cn } from '@/lib/utils';
import { ExternalLinkIcon } from '@radix-ui/react-icons';
export const DocsNote = ({ children, warn }: { warn?: boolean; children: React.ReactNode }) => {
export const DocsNote = ({
children,
warn,
className,
}: {
warn?: boolean;
children: React.ReactNode;
className?: string;
}) => {
return (
<div
className={cn('my-2 flex border-l-2 px-4 py-2', warn ? 'border-orange-500' : 'border-white')}
className={cn(
'my-2 flex border-l-2 px-4 py-2',
warn ? 'border-orange-500' : 'border-white',
className,
)}
>
{/* <div className="items-center align-middle pr-2 flex flex-row">
{warn ? (

View file

@ -55,19 +55,21 @@ type SubPageLayoutHeaderProps = {
description?: string | ReactNode;
} & HTMLAttributes<HTMLDivElement>;
const SubPageLayoutHeader = forwardRef<HTMLDivElement, SubPageLayoutHeaderProps>(({ ...props }) => (
<div className="flex flex-row items-center justify-between">
<div className="space-y-1.5">
<CardTitle>{props.title}</CardTitle>
{typeof props.description === 'string' ? (
<CardDescription>{props.description}</CardDescription>
) : (
props.description
)}
const SubPageLayoutHeader = forwardRef<HTMLDivElement, SubPageLayoutHeaderProps>(
({ ...props }, ref) => (
<div className="flex flex-row items-center justify-between" ref={ref}>
<div className="space-y-1.5">
<CardTitle>{props.title}</CardTitle>
{typeof props.description === 'string' ? (
<CardDescription>{props.description}</CardDescription>
) : (
props.description
)}
</div>
<div>{props.children}</div>
</div>
<div>{props.children}</div>
</div>
));
),
);
SubPageLayoutHeader.displayName = 'SubPageLayoutHeader';
export { PageLayout, NavLayout, PageLayoutContent, SubPageLayout, SubPageLayoutHeader };

View file

@ -8,8 +8,8 @@ export function normalizeCliOutput(value: string) {
// eslint-disable-next-line no-control-regex
.replace(/\x1B[[(?);]{0,2}(;?\d)*./g, '')
.replace(
/http:\/\/localhost:8080\/[$]*\w+\/[$]*\w+\/production/i,
'http://localhost:8080/$organization/$project/production',
/http:\/\/localhost:8080\/[$]*\w+\/[$]*\w+\/[$]*\w+/i,
'http://localhost:8080/$organization/$project/$target',
)
.replace(/history\/[$]*\w+-\w+-\w+-\w+-\w+/i, 'history/$version')
.trim(),