diff --git a/packages/amplify-category-api/src/index.ts b/packages/amplify-category-api/src/index.ts index d0b0786de09..156259d1e38 100644 --- a/packages/amplify-category-api/src/index.ts +++ b/packages/amplify-category-api/src/index.ts @@ -4,6 +4,7 @@ import { AmplifyCategories, AmplifySupportedService, buildOverrideDir, + exitOnNextTick, pathManager, stateManager, } from 'amplify-cli-core'; @@ -18,6 +19,8 @@ import { ApigwStackTransform } from './provider-utils/awscloudformation/cdk-stac import { getCfnApiArtifactHandler } from './provider-utils/awscloudformation/cfn-api-artifact-handler'; import { askAuthQuestions } from './provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough'; import { authConfigToAppSyncAuthType } from './provider-utils/awscloudformation/utils/auth-config-to-app-sync-auth-type-bi-di-mapper'; +import { checkAppsyncApiResourceMigration } from './provider-utils/awscloudformation/utils/check-appsync-api-migration'; +import { getAppSyncApiResourceName } from './provider-utils/awscloudformation/utils/getAppSyncApiName'; export { NETWORK_STACK_LOGICAL_ID } from './category-constants'; export { addAdminQueriesApi, updateAdminQueriesApi } from './provider-utils/awscloudformation/'; export { DEPLOYMENT_MECHANISM } from './provider-utils/awscloudformation/base-api-stack'; @@ -235,6 +238,11 @@ export const executeAmplifyHeadlessCommand = async (context: $TSContext, headles await getCfnApiArtifactHandler(context).createArtifacts(await validateAddApiRequest(headlessPayload)); break; case 'update': + const resourceName = await getAppSyncApiResourceName(context); + if (!(await checkAppsyncApiResourceMigration(context, resourceName, true))) { + printer.error('Update operations only work on migrated projects. Run "amplify update api" and opt for migration.'); + exitOnNextTick(0); + } await getCfnApiArtifactHandler(context).updateArtifacts(await validateUpdateApiRequest(headlessPayload)); break; default: diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/getAppSyncApiName.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/getAppSyncApiName.ts new file mode 100644 index 00000000000..2e28ce65d4f --- /dev/null +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/getAppSyncApiName.ts @@ -0,0 +1,15 @@ +import { $TSContext, AmplifySupportedService } from 'amplify-cli-core'; + +export const getAppSyncApiResourceName = async (context: $TSContext): Promise => { + const { allResources } = await context.amplify.getResourceStatus(); + const apiResource = allResources.filter((resource: { service: string }) => resource.service === AmplifySupportedService.APPSYNC); + let apiResourceName; + + if (apiResource.length > 0) { + const resource = apiResource[0]; + apiResourceName = resource.resourceName; + } else { + throw new Error(`${AmplifySupportedService.APPSYNC} API does not exist. To add an api, use "amplify update api".`); + } + return apiResourceName; +}; diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/push-resources.ts b/packages/amplify-cli/src/extensions/amplify-helpers/push-resources.ts index 4c451608129..24e2fe7eda0 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/push-resources.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/push-resources.ts @@ -57,7 +57,7 @@ export async function pushResources( // building all CFN stacks here to get the resource Changes await generateDependentResourcesType(context); const resourcesToBuild: IAmplifyResource[] = await getResources(context); - context.amplify.executeProviderUtils(context, 'awscloudformation', 'buildOverrides', { + await context.amplify.executeProviderUtils(context, 'awscloudformation', 'buildOverrides', { resourcesToBuild, forceCompile: true, }); @@ -102,7 +102,9 @@ export async function pushResources( } if (!retryPush) { if (isAuthError) { - printer.warn(`You defined authorization rules (@auth) but haven't enabled their authorization providers on your GraphQL API. Run "amplify update api" to configure your GraphQL API to include the appropriate authorization providers as an authorization mode.`); + printer.warn( + `You defined authorization rules (@auth) but haven't enabled their authorization providers on your GraphQL API. Run "amplify update api" to configure your GraphQL API to include the appropriate authorization providers as an authorization mode.`, + ); printer.error(err.message); } throw err; diff --git a/packages/amplify-e2e-core/src/categories/api.ts b/packages/amplify-e2e-core/src/categories/api.ts index 21f3243e9fc..b0f79e71acc 100644 --- a/packages/amplify-e2e-core/src/categories/api.ts +++ b/packages/amplify-e2e-core/src/categories/api.ts @@ -264,9 +264,13 @@ export function updateApiSchema(cwd: string, projectName: string, schemaName: st export function updateApiWithMultiAuth(cwd: string, settings: any) { return new Promise((resolve, reject) => { - spawn(getCLIPath(settings.testingWithLatestCodebase), ['update', 'api'], { cwd, stripColors: true }) - .wait('Select from one of the below mentioned services:') - .sendCarriageReturn() + const testingWithLatestCodebase = settings?.testingWithLatestCodebase ?? false; + const chain = spawn(getCLIPath(testingWithLatestCodebase), ['update', 'api'], { cwd, stripColors: true }); + chain.wait('Select from one of the below mentioned services:').sendCarriageReturn(); + if (testingWithLatestCodebase === true) { + chain.wait('Do you want to migrate api resource').sendConfirmYes(); + } + chain .wait(/.*Select a setting to edit.*/) .sendCarriageReturn() .wait(/.*Choose the default authorization type for the API.*/) @@ -378,9 +382,13 @@ export function updateAPIWithResolutionStrategyWithoutModels(cwd: string, settin export function updateAPIWithResolutionStrategyWithModels(cwd: string, settings: any) { return new Promise((resolve, reject) => { - spawn(getCLIPath(settings.testingWithLatestCodebase), ['update', 'api'], { cwd, stripColors: true }) - .wait('Select from one of the below mentioned services:') - .sendCarriageReturn() + const testingWithLatestCodebase = settings?.testingWithLatestCodebase ?? false; + const chain = spawn(getCLIPath(testingWithLatestCodebase), ['update', 'api'], { cwd, stripColors: true }); + chain.wait('Select from one of the below mentioned services:').sendCarriageReturn(); + if (testingWithLatestCodebase === true) { + chain.wait('Do you want to migrate api resource').sendYes(); + } + chain .wait(/.*Select a setting to edit.*/) .sendKeyDown() .sendCarriageReturn() diff --git a/packages/amplify-e2e-core/src/utils/headless.ts b/packages/amplify-e2e-core/src/utils/headless.ts index cd767247155..6cd83ad4ad9 100644 --- a/packages/amplify-e2e-core/src/utils/headless.ts +++ b/packages/amplify-e2e-core/src/utils/headless.ts @@ -12,8 +12,12 @@ import { import execa, { ExecaChildProcess } from 'execa'; import { getCLIPath } from '..'; -export const addHeadlessApi = async (cwd: string, request: AddApiRequest): Promise> => { - return await executeHeadlessCommand(cwd, 'api', 'add', request); +export const addHeadlessApi = async (cwd: string, request: AddApiRequest, settings?: any): Promise> => { + const allowDestructiveUpdates = settings?.allowDestructiveUpdates ?? false; + const testingWithLatestCodebase = settings?.testingWithLatestCodebase ?? false; + return executeHeadlessCommand(cwd, 'api', 'add', request, true, allowDestructiveUpdates, { + testingWithLatestCodebase: testingWithLatestCodebase, + }); }; export const updateHeadlessApi = async ( diff --git a/packages/amplify-migration-tests/src/__tests__/migration_tests/overrides/__snapshots__/api-migration.test.ts.snap b/packages/amplify-migration-tests/src/__tests__/migration_tests/overrides/__snapshots__/api-migration.test.ts.snap new file mode 100644 index 00000000000..8b931ffad4c --- /dev/null +++ b/packages/amplify-migration-tests/src/__tests__/migration_tests/overrides/__snapshots__/api-migration.test.ts.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`api migration update test updates AppSync API in headless mode 1`] = ` +Object { + "ElasticsearchWarning": true, + "ResolverConfig": Object { + "project": Object { + "ConflictDetection": "VERSION", + "ConflictHandler": "OPTIMISTIC_CONCURRENCY", + }, + }, + "Version": 5, +} +`; + +exports[`api migration update test updates AppSync API in headless mode 2`] = ` +Object { + "additionalAuthenticationProviders": Array [ + Object { + "apiKeyConfig": Object {}, + "authenticationType": "API_KEY", + }, + ], + "defaultAuthentication": Object { + "authenticationType": "AWS_IAM", + }, +} +`; + +exports[`api migration update test updates AppSync API in headless mode 3`] = ` +"type Todo @model { + id: ID! + content: String + override: String +} +" +`; diff --git a/packages/amplify-migration-tests/src/__tests__/migration_tests/overrides/api-migration.test.ts b/packages/amplify-migration-tests/src/__tests__/migration_tests/overrides/api-migration.test.ts new file mode 100644 index 00000000000..b54af00aa10 --- /dev/null +++ b/packages/amplify-migration-tests/src/__tests__/migration_tests/overrides/api-migration.test.ts @@ -0,0 +1,190 @@ +import { + addApiWithoutSchema, + addApiWithBlankSchemaAndConflictDetection, + amplifyPush, + amplifyPushUpdate, + createNewProjectDir, + deleteProject, + deleteProjectDir, + getAppSyncApi, + getProjectMeta, + getTransformConfig, + updateApiSchema, + updateApiWithMultiAuth, + updateAPIWithResolutionStrategyWithModels, + addHeadlessApi, + updateHeadlessApi, + getProjectSchema, + getSchemaPath, + getCLIInputs, + initJSProjectWithProfile, +} from 'amplify-e2e-core'; +import { AddApiRequest, UpdateApiRequest } from 'amplify-headless-interface'; +import * as fs from 'fs-extra'; +import { TRANSFORM_BASE_VERSION, TRANSFORM_CURRENT_VERSION } from 'graphql-transformer-core'; +import { join } from 'path'; + +describe('api migration update test', () => { + let projRoot: string; + beforeEach(async () => { + projRoot = await createNewProjectDir('graphql-api'); + }); + + afterEach(async () => { + const metaFilePath = join(projRoot, 'amplify', '#current-cloud-backend', 'amplify-meta.json'); + if (fs.existsSync(metaFilePath)) { + await deleteProject(projRoot); + } + deleteProjectDir(projRoot); + }); + + it('api update migration with multiauth', async () => { + // init and add api with installed CLI + await initJSProjectWithProfile(projRoot, { name: 'simplemodelmultiauth' }); + await addApiWithoutSchema(projRoot); + await updateApiSchema(projRoot, 'simplemodelmultiauth', 'simple_model.graphql'); + await amplifyPush(projRoot); + // update and push with codebase + await updateApiWithMultiAuth(projRoot, { testingWithLatestCodebase: true }); + // cli-inputs should exist + expect(getCLIInputs(projRoot, 'api', 'simplemodelmultiauth')).toBeDefined(); + await amplifyPushUpdate(projRoot, undefined, true, true); + + const meta = getProjectMeta(projRoot); + const { output } = meta.api.simplemodelmultiauth; + const { GraphQLAPIIdOutput, GraphQLAPIEndpointOutput, GraphQLAPIKeyOutput } = output; + const { graphqlApi } = await getAppSyncApi(GraphQLAPIIdOutput, meta.providers.awscloudformation.Region); + + expect(graphqlApi).toBeDefined(); + expect(graphqlApi.authenticationType).toEqual('API_KEY'); + expect(graphqlApi.additionalAuthenticationProviders).toHaveLength(3); + expect(graphqlApi.additionalAuthenticationProviders).toHaveLength(3); + + const cognito = graphqlApi.additionalAuthenticationProviders.filter(a => a.authenticationType === 'AMAZON_COGNITO_USER_POOLS')[0]; + + expect(cognito).toBeDefined(); + expect(cognito.userPoolConfig).toBeDefined(); + + const iam = graphqlApi.additionalAuthenticationProviders.filter(a => a.authenticationType === 'AWS_IAM')[0]; + + expect(iam).toBeDefined(); + + const oidc = graphqlApi.additionalAuthenticationProviders.filter(a => a.authenticationType === 'OPENID_CONNECT')[0]; + + expect(oidc).toBeDefined(); + expect(oidc.openIDConnectConfig).toBeDefined(); + expect(oidc.openIDConnectConfig.issuer).toEqual('https://facebook.com/'); + expect(oidc.openIDConnectConfig.clientId).toEqual('clientId'); + expect(oidc.openIDConnectConfig.iatTTL).toEqual(1000); + expect(oidc.openIDConnectConfig.authTTL).toEqual(2000); + + expect(GraphQLAPIIdOutput).toBeDefined(); + expect(GraphQLAPIEndpointOutput).toBeDefined(); + expect(GraphQLAPIKeyOutput).toBeDefined(); + + expect(graphqlApi).toBeDefined(); + expect(graphqlApi.apiId).toEqual(GraphQLAPIIdOutput); + }); + + it('init a sync enabled project and update conflict resolution strategy', async () => { + const name = `syncenabled`; + // init and add api with locally installed cli + await initJSProjectWithProfile(projRoot, { name }); + await addApiWithBlankSchemaAndConflictDetection(projRoot); + await updateApiSchema(projRoot, name, 'simple_model.graphql'); + await amplifyPush(projRoot); + let transformConfig = getTransformConfig(projRoot, name); + expect(transformConfig).toBeDefined(); + expect(transformConfig.ResolverConfig).toBeDefined(); + expect(transformConfig.ResolverConfig.project).toBeDefined(); + expect(transformConfig.ResolverConfig.project.ConflictDetection).toEqual('VERSION'); + expect(transformConfig.ResolverConfig.project.ConflictHandler).toEqual('AUTOMERGE'); + + //update and push with codebase + await updateAPIWithResolutionStrategyWithModels(projRoot, { testingWithLatestCodebase: true }); + expect(getCLIInputs(projRoot, 'api', 'syncenabled')).toBeDefined(); + transformConfig = getTransformConfig(projRoot, name); + expect(transformConfig).toBeDefined(); + expect(transformConfig.Version).toBeDefined(); + expect(transformConfig.Version).toEqual(TRANSFORM_CURRENT_VERSION); + expect(transformConfig.ResolverConfig).toBeDefined(); + expect(transformConfig.ResolverConfig.project).toBeDefined(); + expect(transformConfig.ResolverConfig.project.ConflictDetection).toEqual('VERSION'); + expect(transformConfig.ResolverConfig.project.ConflictHandler).toEqual('OPTIMISTIC_CONCURRENCY'); + + await amplifyPushUpdate(projRoot, undefined, true, true); + const meta = getProjectMeta(projRoot); + const { output } = meta.api[name]; + const { GraphQLAPIIdOutput, GraphQLAPIEndpointOutput, GraphQLAPIKeyOutput } = output; + const { graphqlApi } = await getAppSyncApi(GraphQLAPIIdOutput, meta.providers.awscloudformation.Region); + + expect(GraphQLAPIIdOutput).toBeDefined(); + expect(GraphQLAPIEndpointOutput).toBeDefined(); + expect(GraphQLAPIKeyOutput).toBeDefined(); + + expect(graphqlApi).toBeDefined(); + expect(graphqlApi.apiId).toEqual(GraphQLAPIIdOutput); + }); + + const addApiRequest: AddApiRequest = { + version: 1, + serviceConfiguration: { + serviceName: 'AppSync', + apiName: 'myApiName', + transformSchema: fs.readFileSync(getSchemaPath('simple_model.graphql'), 'utf8'), + defaultAuthType: { + mode: 'API_KEY', + }, + }, + }; + + const updateApiRequest: UpdateApiRequest = { + version: 1, + serviceModification: { + serviceName: 'AppSync', + transformSchema: fs.readFileSync(getSchemaPath('simple_model_override.graphql'), 'utf8'), + defaultAuthType: { + mode: 'AWS_IAM', + }, + additionalAuthTypes: [ + { + mode: 'API_KEY', + }, + ], + conflictResolution: { + defaultResolutionStrategy: { + type: 'OPTIMISTIC_CONCURRENCY', + }, + }, + }, + }; + it('updates AppSync API in headless mode', async () => { + const name = `simplemodelv${TRANSFORM_BASE_VERSION}`; + await initJSProjectWithProfile(projRoot, {}); + await addHeadlessApi(projRoot, addApiRequest, { + allowDestructiveUpdates: false, + testingWithLatestCodebase: false, + }); + await amplifyPush(projRoot); + await updateHeadlessApi(projRoot, updateApiRequest, true); + expect(getCLIInputs(projRoot, 'api', 'myApiName')).toBeDefined(); + await amplifyPushUpdate(projRoot, undefined, undefined, true); + + //verify + const meta = getProjectMeta(projRoot); + const { output } = meta.api.myApiName; + const { GraphQLAPIIdOutput, GraphQLAPIEndpointOutput, GraphQLAPIKeyOutput } = output; + const { graphqlApi } = await getAppSyncApi(GraphQLAPIIdOutput, meta.providers.awscloudformation.Region); + + expect(GraphQLAPIIdOutput).toBeDefined(); + expect(GraphQLAPIEndpointOutput).toBeDefined(); + expect(GraphQLAPIKeyOutput).toBeDefined(); + + expect(graphqlApi).toBeDefined(); + expect(graphqlApi.apiId).toEqual(GraphQLAPIIdOutput); + + expect(getTransformConfig(projRoot, 'myApiName')).toMatchSnapshot(); + expect(output.authConfig).toMatchSnapshot(); + expect(getProjectSchema(projRoot, 'myApiName')).toMatchSnapshot(); + }); +});