From 226296862fab1f733535081aac0f0331dcae50d4 Mon Sep 17 00:00:00 2001 From: Iha Shin Date: Wed, 1 Oct 2025 23:37:25 +0900 Subject: [PATCH 1/6] feat(app): unify schema composition settings mutations --- integration-tests/testkit/flow.ts | 21 +- .../schema/composition-federation-2.spec.ts | 19 +- .../tests/api/schema/delete.spec.ts | 16 +- .../api/schema/external-composition.spec.ts | 70 ++++--- .../tests/api/schema/publish.spec.ts | 46 ++-- .../api/src/modules/schema/module.graphql.ts | 78 +++---- .../schema/providers/schema-manager.ts | 146 ++++++------- .../disableExternalSchemaComposition.ts | 18 -- .../enableExternalSchemaComposition.ts | 20 -- .../Mutation/updateNativeFederation.ts | 21 -- .../Mutation/updateSchemaComposition.ts | 42 ++++ .../src/modules/shared/providers/storage.ts | 2 - packages/services/storage/src/index.ts | 19 +- .../project/settings/composition.tsx | 27 ++- .../project/settings/external-composition.tsx | 197 +++++++----------- .../project/settings/legacy-composition.tsx | 114 ++++------ .../project/settings/native-composition.tsx | 114 ++++------ 17 files changed, 419 insertions(+), 551 deletions(-) delete mode 100644 packages/services/api/src/modules/schema/resolvers/Mutation/disableExternalSchemaComposition.ts delete mode 100644 packages/services/api/src/modules/schema/resolvers/Mutation/enableExternalSchemaComposition.ts delete mode 100644 packages/services/api/src/modules/schema/resolvers/Mutation/updateNativeFederation.ts create mode 100644 packages/services/api/src/modules/schema/resolvers/Mutation/updateSchemaComposition.ts diff --git a/integration-tests/testkit/flow.ts b/integration-tests/testkit/flow.ts index a6f7b452a8..d3b81d793b 100644 --- a/integration-tests/testkit/flow.ts +++ b/integration-tests/testkit/flow.ts @@ -12,7 +12,6 @@ import type { CreateTokenInput, DeleteMemberRoleInput, DeleteTokensInput, - EnableExternalSchemaCompositionInput, Experimental__UpdateTargetSchemaCompositionInput, InviteToOrganizationByEmailInput, OrganizationSelectorInput, @@ -27,6 +26,7 @@ import type { UpdateMemberRoleInput, UpdateOrganizationSlugInput, UpdateProjectSlugInput, + UpdateSchemaCompositionInput, UpdateTargetConditionalBreakingChangeConfigurationInput, UpdateTargetSlugInput, } from './gql/graphql'; @@ -1508,14 +1508,11 @@ export async function updateOrgRateLimit( }); } -export async function enableExternalSchemaComposition( - input: EnableExternalSchemaCompositionInput, - token: string, -) { +export async function updateSchemaComposition(input: UpdateSchemaCompositionInput, token: string) { return execute({ document: graphql(` - mutation enableExternalSchemaComposition($input: EnableExternalSchemaCompositionInput!) { - enableExternalSchemaComposition(input: $input) { + mutation updateSchemaComposition($input: UpdateSchemaCompositionInput!) { + updateSchemaComposition(input: $input) { ok { id externalSchemaComposition { @@ -1523,10 +1520,12 @@ export async function enableExternalSchemaComposition( } } error { - message - inputErrors { - endpoint - secret + ... on UpdateSchemaCompositionExternalError { + message + inputErrors { + endpoint + secret + } } } } diff --git a/integration-tests/tests/api/schema/composition-federation-2.spec.ts b/integration-tests/tests/api/schema/composition-federation-2.spec.ts index b01f57baea..15ce1610c5 100644 --- a/integration-tests/tests/api/schema/composition-federation-2.spec.ts +++ b/integration-tests/tests/api/schema/composition-federation-2.spec.ts @@ -1,5 +1,5 @@ import { ProjectType } from 'testkit/gql/graphql'; -import { enableExternalSchemaComposition } from '../../../testkit/flow'; +import { updateSchemaComposition } from '../../../testkit/flow'; import { initSeed } from '../../../testkit/seed'; import { generateUnique, getServiceHost } from '../../../testkit/utils'; @@ -38,19 +38,20 @@ test.concurrent('call an external service to compose and validate services', asy expect(publishUsersResult.schemaPublish.__typename).toBe('SchemaPublishSuccess'); // enable external composition - const externalCompositionResult = await enableExternalSchemaComposition( + const externalCompositionResult = await updateSchemaComposition( { - endpoint: `http://${dockerAddress}/compose`, - // eslint-disable-next-line no-process-env - secret: process.env.EXTERNAL_COMPOSITION_SECRET!, - projectSlug: project.slug, - organizationSlug: organization.slug, + external: { + endpoint: `http://${dockerAddress}/compose`, + // eslint-disable-next-line no-process-env + secret: process.env.EXTERNAL_COMPOSITION_SECRET!, + projectSlug: project.slug, + organizationSlug: organization.slug, + }, }, ownerToken, ).then(r => r.expectNoGraphQLErrors()); expect( - externalCompositionResult.enableExternalSchemaComposition.ok?.externalSchemaComposition - ?.endpoint, + externalCompositionResult.updateSchemaComposition.ok?.externalSchemaComposition?.endpoint, ).toBe(`http://${dockerAddress}/compose`); // Disable Native Federation v2 composition to allow the external composition to take place await setNativeFederation(false); diff --git a/integration-tests/tests/api/schema/delete.spec.ts b/integration-tests/tests/api/schema/delete.spec.ts index a6f9ccffdc..01af9f0dc9 100644 --- a/integration-tests/tests/api/schema/delete.spec.ts +++ b/integration-tests/tests/api/schema/delete.spec.ts @@ -1,6 +1,6 @@ import 'reflect-metadata'; import { parse, print } from 'graphql'; -import { enableExternalSchemaComposition } from 'testkit/flow'; +import { updateSchemaComposition } from 'testkit/flow'; import { ProjectType } from 'testkit/gql/graphql'; import { initSeed } from 'testkit/seed'; import { getServiceHost } from 'testkit/utils'; @@ -235,13 +235,15 @@ test.concurrent( const readToken = await createTargetAccessToken({}); - await enableExternalSchemaComposition( + await updateSchemaComposition( { - endpoint: `http://${await getServiceHost('composition_federation_2', 3069, false)}/compose`, - // eslint-disable-next-line no-process-env - secret: process.env.EXTERNAL_COMPOSITION_SECRET!, - projectSlug: project.slug, - organizationSlug: organization.slug, + external: { + endpoint: `http://${await getServiceHost('composition_federation_2', 3069, false)}/compose`, + // eslint-disable-next-line no-process-env + secret: process.env.EXTERNAL_COMPOSITION_SECRET!, + projectSlug: project.slug, + organizationSlug: organization.slug, + }, }, ownerToken, ).then(r => r.expectNoGraphQLErrors()); diff --git a/integration-tests/tests/api/schema/external-composition.spec.ts b/integration-tests/tests/api/schema/external-composition.spec.ts index 435b084c5f..9fab751841 100644 --- a/integration-tests/tests/api/schema/external-composition.spec.ts +++ b/integration-tests/tests/api/schema/external-composition.spec.ts @@ -1,6 +1,6 @@ import { ProjectType } from 'testkit/gql/graphql'; import { history } from '../../../testkit/external-composition'; -import { enableExternalSchemaComposition } from '../../../testkit/flow'; +import { updateSchemaComposition } from '../../../testkit/flow'; import { initSeed } from '../../../testkit/seed'; import { generateUnique, getServiceHost } from '../../../testkit/utils'; @@ -43,19 +43,20 @@ test.concurrent('call an external service to compose and validate services', asy // so we need to use the name and not resolved host const dockerAddress = await getServiceHost('external_composition', 3012, false); // enable external composition - const externalCompositionResult = await enableExternalSchemaComposition( + const externalCompositionResult = await updateSchemaComposition( { - endpoint: `http://${dockerAddress}/compose`, - // eslint-disable-next-line no-process-env - secret: process.env.EXTERNAL_COMPOSITION_SECRET!, - projectSlug: project.slug, - organizationSlug: organization.slug, + external: { + endpoint: `http://${dockerAddress}/compose`, + // eslint-disable-next-line no-process-env + secret: process.env.EXTERNAL_COMPOSITION_SECRET!, + projectSlug: project.slug, + organizationSlug: organization.slug, + }, }, ownerToken, ).then(r => r.expectNoGraphQLErrors()); expect( - externalCompositionResult.enableExternalSchemaComposition.ok?.externalSchemaComposition - ?.endpoint, + externalCompositionResult.updateSchemaComposition.ok?.externalSchemaComposition?.endpoint, ).toBe(`http://${dockerAddress}/compose`); // set native federation to false to force external composition @@ -126,19 +127,20 @@ test.concurrent( // so we need to use the name and not resolved host const dockerAddress = await getServiceHost('external_composition', 3012, false); // enable external composition - const externalCompositionResult = await enableExternalSchemaComposition( + const externalCompositionResult = await updateSchemaComposition( { - endpoint: `http://${dockerAddress}/fail_on_signature`, - // eslint-disable-next-line no-process-env - secret: process.env.EXTERNAL_COMPOSITION_SECRET!, - projectSlug: project.slug, - organizationSlug: organization.slug, + external: { + endpoint: `http://${dockerAddress}/fail_on_signature`, + // eslint-disable-next-line no-process-env + secret: process.env.EXTERNAL_COMPOSITION_SECRET!, + projectSlug: project.slug, + organizationSlug: organization.slug, + }, }, ownerToken, ).then(r => r.expectNoGraphQLErrors()); expect( - externalCompositionResult.enableExternalSchemaComposition.ok?.externalSchemaComposition - ?.endpoint, + externalCompositionResult.updateSchemaComposition.ok?.externalSchemaComposition?.endpoint, ).toBe(`http://${dockerAddress}/fail_on_signature`); // set native federation to false to force external composition @@ -225,19 +227,20 @@ test.concurrent( // so we need to use the name and not resolved host const dockerAddress = await getServiceHost('external_composition', 3012, false); // enable external composition - const externalCompositionResult = await enableExternalSchemaComposition( + const externalCompositionResult = await updateSchemaComposition( { - endpoint: `http://${dockerAddress}/non-existing-endpoint`, - // eslint-disable-next-line no-process-env - secret: process.env.EXTERNAL_COMPOSITION_SECRET!, - projectSlug: project.slug, - organizationSlug: organization.slug, + external: { + endpoint: `http://${dockerAddress}/non-existing-endpoint`, + // eslint-disable-next-line no-process-env + secret: process.env.EXTERNAL_COMPOSITION_SECRET!, + projectSlug: project.slug, + organizationSlug: organization.slug, + }, }, ownerToken, ).then(r => r.expectNoGraphQLErrors()); expect( - externalCompositionResult.enableExternalSchemaComposition.ok?.externalSchemaComposition - ?.endpoint, + externalCompositionResult.updateSchemaComposition.ok?.externalSchemaComposition?.endpoint, ).toBe(`http://${dockerAddress}/non-existing-endpoint`); // set native federation to false to force external composition await setNativeFederation(false); @@ -321,19 +324,20 @@ test.concurrent('a timeout error should be visible to the user', async ({ expect // so we need to use the name and not resolved host const dockerAddress = await getServiceHost('external_composition', 3012, false); // enable external composition - const externalCompositionResult = await enableExternalSchemaComposition( + const externalCompositionResult = await updateSchemaComposition( { - endpoint: `http://${dockerAddress}/timeout`, - // eslint-disable-next-line no-process-env - secret: process.env.EXTERNAL_COMPOSITION_SECRET!, - projectSlug: project.slug, - organizationSlug: organization.slug, + external: { + endpoint: `http://${dockerAddress}/timeout`, + // eslint-disable-next-line no-process-env + secret: process.env.EXTERNAL_COMPOSITION_SECRET!, + projectSlug: project.slug, + organizationSlug: organization.slug, + }, }, ownerToken, ).then(r => r.expectNoGraphQLErrors()); expect( - externalCompositionResult.enableExternalSchemaComposition.ok?.externalSchemaComposition - ?.endpoint, + externalCompositionResult.updateSchemaComposition.ok?.externalSchemaComposition?.endpoint, ).toBe(`http://${dockerAddress}/timeout`); // set native federation to false to force external composition await setNativeFederation(false); diff --git a/integration-tests/tests/api/schema/publish.spec.ts b/integration-tests/tests/api/schema/publish.spec.ts index a981dd3ef4..3bbd50e240 100644 --- a/integration-tests/tests/api/schema/publish.spec.ts +++ b/integration-tests/tests/api/schema/publish.spec.ts @@ -7,11 +7,7 @@ import { execute } from 'testkit/graphql'; import { getServiceHost } from 'testkit/utils'; // eslint-disable-next-line import/no-extraneous-dependencies import { createStorage } from '@hive/storage'; -import { - createTarget, - enableExternalSchemaComposition, - publishSchema, -} from '../../../testkit/flow'; +import { createTarget, publishSchema, updateSchemaComposition } from '../../../testkit/flow'; import { initSeed } from '../../../testkit/seed'; test.concurrent( @@ -2804,13 +2800,15 @@ test('Composition Error (Federation 2) can be served from the database', async ( ); const readWriteToken = await createTargetAccessToken({}); - await enableExternalSchemaComposition( + await updateSchemaComposition( { - endpoint: `http://${serviceAddress}/compose`, - // eslint-disable-next-line no-process-env - secret: process.env.EXTERNAL_COMPOSITION_SECRET!, - projectSlug: project.slug, - organizationSlug: organization.slug, + external: { + endpoint: `http://${serviceAddress}/compose`, + // eslint-disable-next-line no-process-env + secret: process.env.EXTERNAL_COMPOSITION_SECRET!, + projectSlug: project.slug, + organizationSlug: organization.slug, + }, }, ownerToken, ).then(r => r.expectNoGraphQLErrors()); @@ -2924,13 +2922,15 @@ test('Composition Network Failure (Federation 2)', async () => { ); const readWriteToken = await createTargetAccessToken({}); - await enableExternalSchemaComposition( + await updateSchemaComposition( { - endpoint: `http://${serviceAddress}/compose`, - // eslint-disable-next-line no-process-env - secret: process.env.EXTERNAL_COMPOSITION_SECRET!, - projectSlug: project.slug, - organizationSlug: organization.slug, + external: { + endpoint: `http://${serviceAddress}/compose`, + // eslint-disable-next-line no-process-env + secret: process.env.EXTERNAL_COMPOSITION_SECRET!, + projectSlug: project.slug, + organizationSlug: organization.slug, + }, }, ownerToken, ).then(r => r.expectNoGraphQLErrors()); @@ -2965,12 +2965,14 @@ test('Composition Network Failure (Federation 2)', async () => { return; } - await enableExternalSchemaComposition( + await updateSchemaComposition( { - endpoint: `http://${serviceAddress}/no_compose`, - secret: process.env.EXTERNAL_COMPOSITION_SECRET!, - projectSlug: project.slug, - organizationSlug: organization.slug, + external: { + endpoint: `http://${serviceAddress}/no_compose`, + secret: process.env.EXTERNAL_COMPOSITION_SECRET!, + projectSlug: project.slug, + organizationSlug: organization.slug, + }, }, ownerToken, ).then(r => r.expectNoGraphQLErrors()); diff --git a/packages/services/api/src/modules/schema/module.graphql.ts b/packages/services/api/src/modules/schema/module.graphql.ts index 4600f57aa6..3e1722f634 100644 --- a/packages/services/api/src/modules/schema/module.graphql.ts +++ b/packages/services/api/src/modules/schema/module.graphql.ts @@ -8,13 +8,7 @@ export default gql` schemaCompose(input: SchemaComposeInput!): SchemaComposePayload! updateBaseSchema(input: UpdateBaseSchemaInput!): UpdateBaseSchemaResult! - updateNativeFederation(input: UpdateNativeFederationInput!): UpdateNativeFederationResult! - enableExternalSchemaComposition( - input: EnableExternalSchemaCompositionInput! - ): EnableExternalSchemaCompositionResult! - disableExternalSchemaComposition( - input: DisableExternalSchemaCompositionInput! - ): DisableExternalSchemaCompositionResult! + updateSchemaComposition(input: UpdateSchemaCompositionInput!): UpdateSchemaCompositionResult! """ Approve a failed schema check with breaking changes. """ @@ -43,50 +37,57 @@ export default gql` ): TestExternalSchemaCompositionResult! } - input UpdateNativeFederationInput { + input UpdateSchemaCompositionInput @oneOf { + native: UpdateSchemaCompositionNativeInput + external: UpdateSchemaCompositionExternalInput + legacy: UpdateSchemaCompositionLegacyInput + } + + input UpdateSchemaCompositionNativeInput { + organizationSlug: String! + projectSlug: String! + } + + input UpdateSchemaCompositionExternalInput { + organizationSlug: String! + projectSlug: String! + endpoint: String! + secret: String! + } + + input UpdateSchemaCompositionLegacyInput { organizationSlug: String! projectSlug: String! - enabled: Boolean! } """ @oneOf """ - type UpdateNativeFederationResult { + type UpdateSchemaCompositionResult { ok: Project - error: UpdateNativeFederationError + error: UpdateSchemaCompositionError } - type UpdateNativeFederationError implements Error { + interface UpdateSchemaCompositionError implements Error { message: String! } - input DisableExternalSchemaCompositionInput { - organizationSlug: String! - projectSlug: String! + type UpdateSchemaCompositionNativeError implements UpdateSchemaCompositionError & Error { + message: String! } - """ - @oneOf - """ - type DisableExternalSchemaCompositionResult { - ok: Project - error: String + type UpdateSchemaCompositionLegacyError implements UpdateSchemaCompositionError & Error { + message: String! } - input EnableExternalSchemaCompositionInput { - organizationSlug: String! - projectSlug: String! - endpoint: String! - secret: String! + type UpdateSchemaCompositionExternalError implements UpdateSchemaCompositionError & Error { + message: String! + inputErrors: UpdateSchemaCompositionExternalInputErrors! } - """ - @oneOf - """ - type EnableExternalSchemaCompositionResult { - ok: Project - error: EnableExternalSchemaCompositionError + type UpdateSchemaCompositionExternalInputErrors { + endpoint: String + secret: String } type ExternalSchemaComposition { @@ -154,19 +155,6 @@ export default gql` NOT_APPLICABLE } - type EnableExternalSchemaCompositionError implements Error { - message: String! - """ - The detailed validation error messages for the input fields. - """ - inputErrors: EnableExternalSchemaCompositionInputErrors! - } - - type EnableExternalSchemaCompositionInputErrors { - endpoint: String - secret: String - } - type UpdateBaseSchemaResult { ok: UpdateBaseSchemaOk error: UpdateBaseSchemaError diff --git a/packages/services/api/src/modules/schema/providers/schema-manager.ts b/packages/services/api/src/modules/schema/providers/schema-manager.ts index cb8c6cd643..c411fa2f3a 100644 --- a/packages/services/api/src/modules/schema/providers/schema-manager.ts +++ b/packages/services/api/src/modules/schema/providers/schema-manager.ts @@ -570,82 +570,19 @@ export class SchemaManager { } } - async disableExternalSchemaComposition(input: ProjectSelector) { - this.logger.debug('Disabling external composition (input=%o)', input); - await this.session.assertPerformAction({ - organizationId: input.organizationId, - action: 'project:modifySettings', - params: { - organizationId: input.organizationId, - projectId: input.projectId, - }, - }); - - await this.storage.disableExternalSchemaComposition(input); - - return { - ok: await this.projectManager.getProject({ - organizationId: input.organizationId, - projectId: input.projectId, - }), - }; - } - - async enableExternalSchemaComposition( - input: ProjectSelector & { - endpoint: string; - secret: string; - }, - ) { - this.logger.debug('Enabling external composition (input=%o)', lodash.omit(input, ['secret'])); - await this.session.assertPerformAction({ - organizationId: input.organizationId, - action: 'project:modifySettings', - params: { - organizationId: input.organizationId, - projectId: input.projectId, - }, - }); - const parseResult = ENABLE_EXTERNAL_COMPOSITION_SCHEMA.safeParse({ - endpoint: input.endpoint, - secret: input.secret, - }); - - if (!parseResult.success) { - return { - error: { - message: parseResult.error.message, - inputErrors: { - endpoint: parseResult.error.formErrors.fieldErrors.endpoint?.[0], - secret: parseResult.error.formErrors.fieldErrors.secret?.[0], - }, - }, - }; - } - - const encryptedSecret = this.crypto.encrypt(input.secret); - - await this.storage.enableExternalSchemaComposition({ - projectId: input.projectId, - organizationId: input.organizationId, - endpoint: input.endpoint.trim(), - encryptedSecret, - }); - - return { - ok: await this.projectManager.getProject({ - organizationId: input.organizationId, - projectId: input.projectId, - }), - }; - } - - async updateNativeSchemaComposition( - input: ProjectSelector & { - enabled: boolean; - }, + async updateSchemaComposition( + input: ProjectSelector & + ( + | { mode: 'native' } + | { mode: 'legacy' } + | { + mode: 'external'; + endpoint: string; + secret: string; + } + ), ) { - this.logger.debug('Updating native schema composition (input=%o)', input); + this.logger.debug('Updating schema composition settings (input=%o)', input); await this.session.assertPerformAction({ organizationId: input.organizationId, action: 'project:modifySettings', @@ -661,14 +598,61 @@ export class SchemaManager { }); if (project.type !== ProjectType.FEDERATION) { - throw new HiveError(`Native schema composition is supported only by Federation projects`); + throw new HiveError(`Schema composition is supported only by Federation projects`); } - return this.storage.updateNativeSchemaComposition({ - projectId: input.projectId, - organizationId: input.organizationId, - enabled: input.enabled, - }); + switch (input.mode) { + case 'native': { + return { + ok: await this.storage.updateNativeSchemaComposition({ + projectId: input.projectId, + organizationId: input.organizationId, + enabled: true, + }), + }; + } + case 'legacy': { + return { + ok: await this.storage.updateNativeSchemaComposition({ + projectId: input.projectId, + organizationId: input.organizationId, + enabled: false, + }), + }; + } + case 'external': { + const parseResult = ENABLE_EXTERNAL_COMPOSITION_SCHEMA.safeParse({ + endpoint: input.endpoint, + secret: input.secret, + }); + + if (!parseResult.success) { + return { + error: { + __typename: 'UpdateSchemaCompositionExternalError' as const, + message: parseResult.error.message, + inputErrors: { + endpoint: parseResult.error.formErrors.fieldErrors.endpoint?.[0], + secret: parseResult.error.formErrors.fieldErrors.secret?.[0], + }, + }, + }; + } + + return { + ok: await this.storage.enableExternalSchemaComposition({ + projectId: input.projectId, + organizationId: input.organizationId, + endpoint: parseResult.data.endpoint.trim(), + encryptedSecret: this.crypto.encrypt(parseResult.data.secret), + }), + }; + } + default: { + const _: never = input; + throw new HiveError('Unexpected input'); + } + } } async getPaginatedSchemaChecksForTarget( diff --git a/packages/services/api/src/modules/schema/resolvers/Mutation/disableExternalSchemaComposition.ts b/packages/services/api/src/modules/schema/resolvers/Mutation/disableExternalSchemaComposition.ts deleted file mode 100644 index 626cdfa3c2..0000000000 --- a/packages/services/api/src/modules/schema/resolvers/Mutation/disableExternalSchemaComposition.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { IdTranslator } from '../../../shared/providers/id-translator'; -import { SchemaManager } from '../../providers/schema-manager'; -import type { MutationResolvers } from './../../../../__generated__/types'; - -export const disableExternalSchemaComposition: NonNullable< - MutationResolvers['disableExternalSchemaComposition'] -> = async (_, { input }, { injector }) => { - const translator = injector.get(IdTranslator); - const [organization, project] = await Promise.all([ - translator.translateOrganizationId(input), - translator.translateProjectId(input), - ]); - - return injector.get(SchemaManager).disableExternalSchemaComposition({ - projectId: project, - organizationId: organization, - }); -}; diff --git a/packages/services/api/src/modules/schema/resolvers/Mutation/enableExternalSchemaComposition.ts b/packages/services/api/src/modules/schema/resolvers/Mutation/enableExternalSchemaComposition.ts deleted file mode 100644 index dd29b52bff..0000000000 --- a/packages/services/api/src/modules/schema/resolvers/Mutation/enableExternalSchemaComposition.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { IdTranslator } from '../../../shared/providers/id-translator'; -import { SchemaManager } from '../../providers/schema-manager'; -import type { MutationResolvers } from './../../../../__generated__/types'; - -export const enableExternalSchemaComposition: NonNullable< - MutationResolvers['enableExternalSchemaComposition'] -> = async (_, { input }, { injector }) => { - const translator = injector.get(IdTranslator); - const [organization, project] = await Promise.all([ - translator.translateOrganizationId(input), - translator.translateProjectId(input), - ]); - - return injector.get(SchemaManager).enableExternalSchemaComposition({ - projectId: project, - organizationId: organization, - endpoint: input.endpoint, - secret: input.secret, - }); -}; diff --git a/packages/services/api/src/modules/schema/resolvers/Mutation/updateNativeFederation.ts b/packages/services/api/src/modules/schema/resolvers/Mutation/updateNativeFederation.ts deleted file mode 100644 index 68be554fe0..0000000000 --- a/packages/services/api/src/modules/schema/resolvers/Mutation/updateNativeFederation.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { IdTranslator } from '../../../shared/providers/id-translator'; -import { SchemaManager } from '../../providers/schema-manager'; -import type { MutationResolvers } from './../../../../__generated__/types'; - -export const updateNativeFederation: NonNullable< - MutationResolvers['updateNativeFederation'] -> = async (_, { input }, { injector }) => { - const translator = injector.get(IdTranslator); - const [organization, project] = await Promise.all([ - translator.translateOrganizationId(input), - translator.translateProjectId(input), - ]); - - return { - ok: await injector.get(SchemaManager).updateNativeSchemaComposition({ - projectId: project, - organizationId: organization, - enabled: input.enabled, - }), - }; -}; diff --git a/packages/services/api/src/modules/schema/resolvers/Mutation/updateSchemaComposition.ts b/packages/services/api/src/modules/schema/resolvers/Mutation/updateSchemaComposition.ts new file mode 100644 index 0000000000..c27ef1b32f --- /dev/null +++ b/packages/services/api/src/modules/schema/resolvers/Mutation/updateSchemaComposition.ts @@ -0,0 +1,42 @@ +import { HiveError } from '../../../../shared/errors'; +import { IdTranslator } from '../../../shared/providers/id-translator'; +import { SchemaManager } from '../../providers/schema-manager'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const updateSchemaComposition: NonNullable< + MutationResolvers['updateSchemaComposition'] +> = async (_, { input }, { injector }) => { + const translator = injector.get(IdTranslator); + const commonInput = input.native ?? input.external ?? input.legacy; + const [organization, project] = await Promise.all([ + translator.translateOrganizationId(commonInput), + translator.translateProjectId(commonInput), + ]); + + if (input.native) { + return injector.get(SchemaManager).updateSchemaComposition({ + projectId: project, + organizationId: organization, + mode: 'native', + }); + } + if (input.legacy) { + return injector.get(SchemaManager).updateSchemaComposition({ + projectId: project, + organizationId: organization, + mode: 'legacy', + }); + } + if (input.external) { + return injector.get(SchemaManager).updateSchemaComposition({ + projectId: project, + organizationId: organization, + mode: 'external', + endpoint: input.external.endpoint, + secret: input.external.secret, + }); + } + + const __: never = input; + throw new HiveError('Unexpected input'); +}; diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index 8a5a4841ec..832818d22b 100644 --- a/packages/services/api/src/modules/shared/providers/storage.ts +++ b/packages/services/api/src/modules/shared/providers/storage.ts @@ -258,8 +258,6 @@ export interface Storage { }, ): Promise; - disableExternalSchemaComposition(_: ProjectSelector): Promise; - enableProjectNameInGithubCheck(_: ProjectSelector): Promise; getTargetId(_: { diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 9e580b2d98..b6537ef466 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -1482,7 +1482,10 @@ export async function createStorage( await pool.one(sql`/* updateNativeSchemaComposition */ UPDATE projects SET - native_federation = ${enabled} + native_federation = ${enabled}, + external_composition_enabled = FALSE, + external_composition_endpoint = NULL, + external_composition_secret = NULL WHERE id = ${project} RETURNING * `), @@ -1493,6 +1496,7 @@ export async function createStorage( await pool.one>(sql`/* enableExternalSchemaComposition */ UPDATE projects SET + native_federation = FALSE, external_composition_enabled = TRUE, external_composition_endpoint = ${endpoint}, external_composition_secret = ${encryptedSecret} @@ -1501,19 +1505,6 @@ export async function createStorage( `), ); }, - async disableExternalSchemaComposition({ projectId: project }) { - return transformProject( - await pool.one>(sql`/* disableExternalSchemaComposition */ - UPDATE projects - SET - external_composition_enabled = FALSE, - external_composition_endpoint = NULL, - external_composition_secret = NULL - WHERE id = ${project} - RETURNING * - `), - ); - }, async enableProjectNameInGithubCheck({ projectId: project }) { return transformProject( await pool.one(sql`/* enableProjectNameInGithubCheck */ diff --git a/packages/web/app/src/components/project/settings/composition.tsx b/packages/web/app/src/components/project/settings/composition.tsx index 5cf0a00c2b..ad1633a393 100644 --- a/packages/web/app/src/components/project/settings/composition.tsx +++ b/packages/web/app/src/components/project/settings/composition.tsx @@ -1,10 +1,11 @@ import { useState } from 'react'; -import { useQuery } from 'urql'; +import { useMutation, useQuery } from 'urql'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { CheckIcon } from '@/components/ui/icon'; import { Spinner } from '@/components/ui/spinner'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { FragmentType, graphql, useFragment } from '@/gql'; +import { UpdateSchemaCompositionInput } from '@/gql/graphql'; import { cn } from '@/lib/utils'; import { ExternalCompositionSettings } from './external-composition'; import { LegacyCompositionSettings } from './legacy-composition'; @@ -30,6 +31,7 @@ const CompositionSettings_OrganizationFragment = graphql(` const CompositionSettings_ProjectFragment = graphql(` fragment CompositionSettings_ProjectFragment on Project { + id slug isNativeFederationEnabled externalSchemaComposition { @@ -41,6 +43,19 @@ const CompositionSettings_ProjectFragment = graphql(` } `); +const CompositionSettings_UpdateMutation = graphql(` + mutation CompositionSettings_UpdateMutation($input: UpdateSchemaCompositionInput!) { + updateSchemaComposition(input: $input) { + ok { + ...CompositionSettings_ProjectFragment + } + ...NativeCompositionSettings_UpdateResultFragment + ...ExternalCompositionSettings_UpdateResultFragment + ...LegacyCompositionSettings_UpdateResultFragment + } + } +`); + export const CompositionSettings = (props: { project: FragmentType; organization: FragmentType; @@ -67,6 +82,13 @@ export const CompositionSettings = (props: { : 'legacy'; const [selectedMode, setSelectedMode] = useState(); + const [, mutate] = useMutation(CompositionSettings_UpdateMutation); + const onMutate = async (input: UpdateSchemaCompositionInput) => { + const result = await mutate({ input }); + if (result.error) return result.error; + return result.data!.updateSchemaComposition; + }; + return ( @@ -106,6 +128,7 @@ export const CompositionSettings = (props: { project={project} organization={organization} activeCompositionMode={activeMode} + onMutate={onMutate} /> @@ -113,6 +136,7 @@ export const CompositionSettings = (props: { project={project} organization={organization} activeCompositionMode={activeMode} + onMutate={onMutate} /> @@ -120,6 +144,7 @@ export const CompositionSettings = (props: { project={project} organization={organization} activeCompositionMode={activeMode} + onMutate={onMutate} /> diff --git a/packages/web/app/src/components/project/settings/external-composition.tsx b/packages/web/app/src/components/project/settings/external-composition.tsx index 887f7edbd5..06e455d3be 100644 --- a/packages/web/app/src/components/project/settings/external-composition.tsx +++ b/packages/web/app/src/components/project/settings/external-composition.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; -import { useMutation, useQuery } from 'urql'; +import { CombinedError, useQuery } from 'urql'; import { z } from 'zod'; import { Button } from '@/components/ui/button'; import { ProductUpdatesLink } from '@/components/ui/docs-note'; @@ -16,6 +16,7 @@ import { import { Input } from '@/components/ui/input'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { FragmentType, graphql, useFragment } from '@/gql'; +import { UpdateSchemaCompositionInput } from '@/gql/graphql'; import { useNotifications } from '@/lib/hooks'; import { zodResolver } from '@hookform/resolvers/zod'; import { CheckIcon, Cross2Icon, ReloadIcon, UpdateIcon } from '@radix-ui/react-icons'; @@ -37,43 +38,6 @@ const ExternalCompositionStatus_TestQuery = graphql(` } `); -const ExternalCompositionSettings_EnableMutation = graphql(` - mutation ExternalCompositionSettings_EnableMutation( - $input: EnableExternalSchemaCompositionInput! - ) { - enableExternalSchemaComposition(input: $input) { - ok { - externalSchemaComposition { - endpoint - } - ...CompositionSettings_ProjectFragment - } - error { - message - inputErrors { - endpoint - secret - } - } - } - } -`); - -const ExternalCompositionSettings_UpdateNativeCompositionMutation = graphql(` - mutation ExternalCompositionSettings_UpdateNativeCompositionMutation( - $input: UpdateNativeFederationInput! - ) { - updateNativeFederation(input: $input) { - ok { - ...CompositionSettings_ProjectFragment - } - error { - message - } - } - } -`); - const ExternalCompositionSettings_OrganizationFragment = graphql(` fragment ExternalCompositionSettings_OrganizationFragment on Organization { slug @@ -90,6 +54,25 @@ const ExternalCompositionSettings_ProjectFragment = graphql(` } `); +const ExternalCompositionSettings_UpdateResultFragment = graphql(` + fragment ExternalCompositionSettings_UpdateResultFragment on UpdateSchemaCompositionResult { + ok { + externalSchemaComposition { + endpoint + } + } + error { + message + ... on UpdateSchemaCompositionExternalError { + inputErrors { + endpoint + secret + } + } + } + } +`); + enum TestState { LOADING, ERROR, @@ -219,6 +202,11 @@ export const ExternalCompositionSettings = (props: { project: FragmentType; organization: FragmentType; activeCompositionMode: 'native' | 'external' | 'legacy'; + onMutate: ( + input: UpdateSchemaCompositionInput, + ) => Promise< + FragmentType | CombinedError + >; }) => { const project = useFragment(ExternalCompositionSettings_ProjectFragment, props.project); const organization = useFragment( @@ -226,14 +214,8 @@ export const ExternalCompositionSettings = (props: { props.organization, ); const notify = useNotifications(); - const [enableExternalMutation, enableExternal] = useMutation( - ExternalCompositionSettings_EnableMutation, - ); - const [updateNativeMutation, updateNative] = useMutation( - ExternalCompositionSettings_UpdateNativeCompositionMutation, - ); - const mutationError = enableExternalMutation.error ?? updateNativeMutation.error; - const isMutationFetching = enableExternalMutation.fetching || updateNativeMutation.fetching; + const [error, setError] = useState(); + const [isMutating, setIsMutating] = useState(false); const form = useForm({ resolver: zodResolver(formSchema), @@ -242,82 +224,67 @@ export const ExternalCompositionSettings = (props: { endpoint: project.externalSchemaComposition?.endpoint ?? '', secret: '', }, - disabled: isMutationFetching, + disabled: isMutating, }); function onSubmit(values: FormValues) { - void enableExternal({ - input: { - projectSlug: project.slug, - organizationSlug: organization.slug, - endpoint: values.endpoint, - secret: values.secret, - }, - }) - .then(async result => { - return { - enableExternalResult: result, - updateNativeResult: - result.data?.enableExternalSchemaComposition.ok && project.isNativeFederationEnabled - ? await updateNative({ - input: { - projectSlug: project.slug, - organizationSlug: organization.slug, - enabled: false, - }, - }) - : null, - }; + setError(undefined); + setIsMutating(true); + void props + .onMutate({ + external: { + projectSlug: project.slug, + organizationSlug: organization.slug, + endpoint: values.endpoint, + secret: values.secret, + }, }) - .then(({ enableExternalResult, updateNativeResult }) => { - if ( - enableExternalResult.data?.enableExternalSchemaComposition.ok && - (!updateNativeResult || updateNativeResult.data?.updateNativeFederation.ok) - ) { - const endpoint = - enableExternalResult.data?.enableExternalSchemaComposition.ok.externalSchemaComposition - ?.endpoint; - - notify('External composition enabled.', 'success'); - - if (endpoint) { - form.reset( - { - endpoint, - secret: '', - }, - { - keepDirty: false, - keepDirtyValues: false, - }, - ); - } + .then(result => { + setIsMutating(false); + if (result instanceof CombinedError) { + notify(result.message, 'error'); + setError(result.message); } else { - const error = - enableExternalResult.error ?? - enableExternalResult.data?.enableExternalSchemaComposition.error ?? - updateNativeResult?.error ?? - updateNativeResult?.data?.updateNativeFederation.error; + const updateResult = useFragment( + ExternalCompositionSettings_UpdateResultFragment, + result, + ); + if (updateResult.ok) { + const endpoint = updateResult.ok.externalSchemaComposition?.endpoint; - if (error) { - notify(error.message, 'error'); - } + notify('External composition enabled.', 'success'); - const inputErrors = - enableExternalResult.data?.enableExternalSchemaComposition.error?.inputErrors; + if (endpoint) { + form.reset( + { + endpoint, + secret: '', + }, + { + keepDirty: false, + keepDirtyValues: false, + }, + ); + } + } else if (updateResult.error) { + notify(updateResult.error.message, 'error'); + setError(updateResult.error.message); - if (inputErrors?.endpoint) { - form.setError('endpoint', { - type: 'manual', - message: inputErrors.endpoint, - }); - } + if (updateResult.error.__typename === 'UpdateSchemaCompositionExternalError') { + if (updateResult.error.inputErrors.endpoint) { + form.setError('endpoint', { + type: 'manual', + message: updateResult.error.inputErrors.endpoint, + }); + } - if (inputErrors?.secret) { - form.setError('secret', { - type: 'manual', - message: inputErrors.secret, - }); + if (updateResult.error.inputErrors.secret) { + form.setError('secret', { + type: 'manual', + message: updateResult.error.inputErrors.secret, + }); + } + } } } }); @@ -389,9 +356,7 @@ export const ExternalCompositionSettings = (props: { )} /> - {mutationError && ( -
{mutationError.message}
- )} + {error &&
{error}
}