mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
Experimental: Incremental native composition migration (#4936)
This commit is contained in:
parent
524e2cd9d3
commit
53a0804434
35 changed files with 1660 additions and 188 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -576,7 +576,7 @@ describe.each`
|
|||
targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite],
|
||||
projectScopes: [],
|
||||
organizationScopes: [],
|
||||
targetId: target2.cleanId,
|
||||
target: target2,
|
||||
});
|
||||
const publishResult2 = await writeTokenResult2
|
||||
.publishSchema({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ export default gql`
|
|||
type: ProjectType!
|
||||
buildUrl: String
|
||||
validationUrl: String
|
||||
experimental_nativeCompositionPerTarget: Boolean!
|
||||
}
|
||||
|
||||
type ProjectConnection {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export class SchemaVersionHelper {
|
|||
native: this.schemaManager.checkProjectNativeFederationSupport({
|
||||
project,
|
||||
organization,
|
||||
targetId: schemaVersion.target,
|
||||
}),
|
||||
contracts: null,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue