diff --git a/doc/general-config.md b/doc/general-config.md index 84dee062..4104afa3 100644 --- a/doc/general-config.md +++ b/doc/general-config.md @@ -50,6 +50,7 @@ appSync: - `logging`: See [Logging](#Logging) - `xrayEnabled`: Boolean. Enable or disable X-Ray tracing. - `tags`: A key-value pair for tagging this AppSync API +- `apiId`: See [ApiId](#ApiId) ## Schema @@ -185,3 +186,33 @@ appSync: - `excludeVerboseContent`: Boolean, Optional. Exclude or not verbose content (headers, response headers, context, and evaluated mapping templates), regardless of field logging level. Defaults to `false`. - `retentionInDays`: Optional. Number of days to retain the logs. Defaults to [`provider.logRetentionInDays`](https://www.serverless.com/framework/docs/providers/aws/guide/serverless.yml#general-function-settings). - `roleArn`: Optional. The role ARN to use for AppSync to write into CloudWatch. If not specified, a new role is created by default. + + +## ApiId +If you want to manage your existing AppSync Api through the serverless, you can specify `apiId.` +This is handy if you +- defined your API in the AWS console +- defined your API through the cloudformation in the current or another stack + +To point your resources into existing AppSync API, you must provide apiId, which can be a string or imported value from another stack. +```yaml +appSync: + name: my-api + apiId: "existing api id" +``` + +The following configuration options are only associated with the creation of a new AppSync endpoint and will be ignored if you provide the apiId parameter: +- name +- authentication +- additionalAuthentications +- schema +- domain +- apiKeys +- xrayEnabled +- logging +- waf +- Tags +> Note: you should never specify this parameter if you're managing your AppSync through this plugin since it results in removing your API. + +### Schema +After specifying this parameter, you need to manually keep your schema up to date or from the main stack where your root AppSync API is defined. The plugin is not taking into account schema property due to AppSync limitation and inability to merge schemas across multiple stacks diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts index 9f8c19b6..e64f230c 100644 --- a/src/__tests__/api.test.ts +++ b/src/__tests__/api.test.ts @@ -213,6 +213,10 @@ describe('Api', () => { expect(api.compileEndpoint()).toMatchSnapshot(); expect(api.functions).toMatchSnapshot(); }); + it('should not compile the Api Resource when apiId is provided', () => { + const api = new Api(given.appSyncConfig({ apiId: '123' }), plugin); + expect(api.compileEndpoint()).toMatchInlineSnapshot(`Object {}`); + }); }); describe('Logs', () => { @@ -239,6 +243,13 @@ describe('Api', () => { ); }); + it('should not compile CloudWatch Resources when apiId is provided', () => { + const api = new Api(given.appSyncConfig({ apiId: '1234' }), plugin); + expect(api.compileCloudWatchLogGroup()).toMatchInlineSnapshot( + `Object {}`, + ); + }); + it('should compile CloudWatch Resources when enaabled', () => { const api = new Api( given.appSyncConfig({ @@ -465,6 +476,20 @@ describe('Api', () => { } `); }); + it('should not generate an api key resources when apiId is provided', () => { + const api = new Api( + given.appSyncConfig({ + apiId: '1234', + }), + plugin, + ); + expect( + api.compileApiKey({ + name: 'Default', + description: 'Default Key', + }), + ).toMatchInlineSnapshot(`Object {}`); + }); }); describe('LambdaAuthorizer', () => { @@ -482,6 +507,18 @@ describe('Api', () => { ); }); + it('should not generate the Lambda Authorizer Resources when apiId is provided', () => { + const api = new Api( + given.appSyncConfig({ + apiId: '123', + }), + plugin, + ); + expect(api.compileLambdaAuthorizerPermission()).toMatchInlineSnapshot( + `Object {}`, + ); + }); + it('should generate the Lambda Authorizer Resources from basic auth', () => { const api = new Api( given.appSyncConfig({ @@ -560,6 +597,11 @@ describe('Caching', () => { expect(api.compileCachingResources()).toEqual({}); }); + it('should not generate Resources when apiId is provided', () => { + const api = new Api(given.appSyncConfig({ apiId: '1234' }), plugin); + expect(api.compileCachingResources()).toEqual({}); + }); + it('should generate Resources with defaults', () => { const api = new Api( given.appSyncConfig({ @@ -723,4 +765,14 @@ describe('Domains', () => { ); expect(api.compileCustomDomain()).toMatchSnapshot(); }); + + it('should not generate domain resources when apiId is provided', () => { + const api = new Api( + given.appSyncConfig({ + apiId: '123', + }), + plugin, + ); + expect(api.compileCustomDomain()).toMatchInlineSnapshot(`Object {}`); + }); }); diff --git a/src/__tests__/schema.test.ts b/src/__tests__/schema.test.ts index deae80fe..78079995 100644 --- a/src/__tests__/schema.test.ts +++ b/src/__tests__/schema.test.ts @@ -51,6 +51,17 @@ describe('schema', () => { `); }); + it('should generate a schema resource if apiId is provided', () => { + const api = new Api( + given.appSyncConfig({ + apiId: '123', + }), + plugin, + ); + + expect(api.compileSchema()).toMatchInlineSnapshot(`Object {}`); + }); + it('should merge the schemas', () => { const api = new Api(given.appSyncConfig(), plugin); const schema = new Schema(api, [ diff --git a/src/__tests__/validation/__snapshots__/base.test.ts.snap b/src/__tests__/validation/__snapshots__/base.test.ts.snap index 3406190c..144f890d 100644 --- a/src/__tests__/validation/__snapshots__/base.test.ts.snap +++ b/src/__tests__/validation/__snapshots__/base.test.ts.snap @@ -53,6 +53,5 @@ exports[`Valdiation Waf Invalid should validate a Throttle limit 1`] = ` exports[`Valdiation should validate 1`] = ` ": must have required property 'name' -: must have required property 'authentication' /unknownPorp: invalid (unknown) property" `; diff --git a/src/__tests__/waf.test.ts b/src/__tests__/waf.test.ts index 9575f66b..14e86be3 100644 --- a/src/__tests__/waf.test.ts +++ b/src/__tests__/waf.test.ts @@ -69,6 +69,20 @@ describe('Waf', () => { }); expect(waf.compile()).toMatchSnapshot(); }); + + it('should not generate waf Resources if api id is provided', () => { + const api = new Api( + given.appSyncConfig({ + waf: { + enabled: false, + name: 'Waf', + rules: [], + }, + }), + plugin, + ); + expect(api.compileWafRules()).toEqual({}); + }); }); describe('Throttle rules', () => { diff --git a/src/getAppSyncConfig.ts b/src/getAppSyncConfig.ts index bdfc2fad..c4260d3b 100644 --- a/src/getAppSyncConfig.ts +++ b/src/getAppSyncConfig.ts @@ -82,6 +82,7 @@ export const getAppSyncConfig = (config: AppSyncConfigInput): AppSyncConfig => { const dataSources: Record = {}; const resolvers: Record = {}; const pipelineFunctions: Record = {}; + const additionalAuthentications = config.additionalAuthentications || []; forEach(flattenMaps(config.dataSources), (ds, name) => { dataSources[name] = { @@ -163,11 +164,10 @@ export const getAppSyncConfig = (config: AppSyncConfigInput): AppSyncConfig => { }; }); - const additionalAuthentications = config.additionalAuthentications || []; let apiKeys: Record | undefined; if ( - config.authentication.type === 'API_KEY' || + config.authentication?.type === 'API_KEY' || additionalAuthentications.some((auth) => auth.type === 'API_KEY') ) { const inputKeys = config.apiKeys || []; diff --git a/src/index.ts b/src/index.ts index 6d93c2ea..9b9696bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,6 +65,7 @@ import { ListCertificatesResponse, } from 'aws-sdk/clients/acm'; import terminalLink from 'terminal-link'; +import { AppSyncConfig } from './types/plugin'; const CONSOLE_BASE_URL = 'https://console.aws.amazon.com'; @@ -86,6 +87,7 @@ class ServerlessAppsyncPlugin { public readonly configurationVariablesSources?: VariablesSourcesDefinition; private api?: Api; private naming?: Naming; + private config?: AppSyncConfig; constructor( public serverless: Serverless, @@ -347,6 +349,11 @@ class ServerlessAppsyncPlugin { async getApiId() { this.loadConfig(); + if (this.config?.apiId) { + return this.config.apiId; + } + + if (!this.naming) { throw new this.serverless.classes.Error( 'Could not find the naming service. This should not happen.', @@ -377,6 +384,10 @@ class ServerlessAppsyncPlugin { async gatherData() { const apiId = await this.getApiId(); + if (typeof apiId !== 'string') { + return; + } + const { graphqlApi } = await this.provider.request< GetGraphqlApiRequest, GetGraphqlApiResponse @@ -410,6 +421,10 @@ class ServerlessAppsyncPlugin { async getIntrospection() { const apiId = await this.getApiId(); + if (typeof apiId !== 'string') { + return; + } + const { schema } = await this.provider.request< GetIntrospectionSchemaRequest, GetIntrospectionSchemaResponse @@ -673,10 +688,16 @@ class ServerlessAppsyncPlugin { } async assocDomain() { - const domain = this.getDomain(); const apiId = await this.getApiId(); + + if (typeof apiId !== 'string') { + return; + } + + const domain = this.getDomain(); const assoc = await this.getApiAssocStatus(domain.name); + if (assoc?.associationStatus !== 'NOT_FOUND' && assoc?.apiId !== apiId) { log.warning( `The domain ${domain.name} is currently associated to another API (${assoc?.apiId})`, @@ -957,9 +978,9 @@ class ServerlessAppsyncPlugin { throw error; } } - const config = getAppSyncConfig(appSync); + this.config = getAppSyncConfig(appSync); this.naming = new Naming(appSync.name); - this.api = new Api(config, this); + this.api = new Api(this.config, this); } validateSchemas() { diff --git a/src/resources/Api.ts b/src/resources/Api.ts index 691d1089..22a9d4d2 100644 --- a/src/resources/Api.ts +++ b/src/resources/Api.ts @@ -25,6 +25,7 @@ import { Resolver } from './Resolver'; import { PipelineFunction } from './PipelineFunction'; import { Schema } from './Schema'; import { Waf } from './Waf'; +import { log } from '@serverless/utils/log'; export class Api { public naming: Naming; @@ -40,6 +41,23 @@ export class Api { compile() { const resources: CfnResources = {}; + if (this.isExistingApi()) { + log.info(` + Updating an existing Graphql API. + The following configuration options are ignored: + - name + - authentication + - additionalAuthentications + - schema + - domain + - apiKeys + - xrayEnabled + - logging + - waf + - tags + `); + } + merge(resources, this.compileEndpoint()); merge(resources, this.compileSchema()); merge(resources, this.compileCustomDomain()); @@ -47,7 +65,6 @@ export class Api { merge(resources, this.compileLambdaAuthorizerPermission()); merge(resources, this.compileWafRules()); merge(resources, this.compileCachingResources()); - forEach(this.config.apiKeys, (key) => { merge(resources, this.compileApiKey(key)); }); @@ -68,6 +85,9 @@ export class Api { } compileEndpoint(): CfnResources { + if (this.isExistingApi()) { + return {}; + } const logicalId = this.naming.getApiLogicalId(); const endpointResource: CfnResource = { @@ -78,16 +98,17 @@ export class Api { Tags: this.getTagsConfig(), }, }; - - merge( - endpointResource.Properties, - this.compileAuthenticationProvider(this.config.authentication), - ); + if (this.config.authentication) { + merge( + endpointResource.Properties, + this.compileAuthenticationProvider(this.config.authentication), + ); + } if (this.config.additionalAuthentications.length > 0) { merge(endpointResource.Properties, { AdditionalAuthenticationProviders: - this.config.additionalAuthentications?.map((provider) => + this.config.additionalAuthentications.map((provider) => this.compileAuthenticationProvider(provider, true), ), }); @@ -113,7 +134,11 @@ export class Api { } compileCloudWatchLogGroup(): CfnResources { - if (!this.config.logging || this.config.logging.enabled === false) { + if ( + !this.config.logging || + this.config.logging.enabled === false || + this.isExistingApi() + ) { return {}; } @@ -183,6 +208,9 @@ export class Api { } compileSchema() { + if (!this.config.schema || this.isExistingApi()) { + return {}; + } const schema = new Schema(this, this.config.schema); return schema.compile(); } @@ -193,7 +221,8 @@ export class Api { if ( !domain || domain.enabled === false || - domain.useCloudFormation === false + domain.useCloudFormation === false || + this.isExistingApi() ) { return {}; } @@ -279,6 +308,10 @@ export class Api { } compileLambdaAuthorizerPermission(): CfnResources { + if (!this.config.authentication || this.isExistingApi()) { + return {}; + } + const lambdaAuth = [ ...this.config.additionalAuthentications, this.config.authentication, @@ -308,6 +341,9 @@ export class Api { } compileApiKey(config: ApiKeyConfig) { + if (this.isExistingApi()) { + return {}; + } const { name, expiresAt, expiresAfter, description, apiKeyId } = config; const startOfHour = DateTime.now().setZone('UTC').startOf('hour'); @@ -356,26 +392,30 @@ export class Api { } compileCachingResources(): CfnResources { - if (this.config.caching && this.config.caching.enabled !== false) { - const cacheConfig = this.config.caching; - const logicalId = this.naming.getCachingLogicalId(); - - return { - [logicalId]: { - Type: 'AWS::AppSync::ApiCache', - Properties: { - ApiCachingBehavior: cacheConfig.behavior, - ApiId: this.getApiId(), - AtRestEncryptionEnabled: cacheConfig.atRestEncryption || false, - TransitEncryptionEnabled: cacheConfig.transitEncryption || false, - Ttl: cacheConfig.ttl || 3600, - Type: cacheConfig.type || 'T2_SMALL', - }, - }, - }; + if ( + !this.config.caching || + this.config.caching?.enabled === false || + this.isExistingApi() + ) { + return {}; } - return {}; + const cacheConfig = this.config.caching; + const logicalId = this.naming.getCachingLogicalId(); + + return { + [logicalId]: { + Type: 'AWS::AppSync::ApiCache', + Properties: { + ApiCachingBehavior: cacheConfig.behavior, + ApiId: this.getApiId(), + AtRestEncryptionEnabled: cacheConfig.atRestEncryption || false, + TransitEncryptionEnabled: cacheConfig.transitEncryption || false, + Ttl: cacheConfig.ttl || 3600, + Type: cacheConfig.type || 'T2_SMALL', + }, + }, + }; } compileDataSource(dsConfig: DataSourceConfig): CfnResources { @@ -396,7 +436,11 @@ export class Api { } compileWafRules() { - if (!this.config.waf || this.config.waf.enabled === false) { + if ( + !this.config.waf || + this.config.waf?.enabled === false || + this.isExistingApi() + ) { return {}; } @@ -405,12 +449,19 @@ export class Api { } getApiId() { + if (this.config.apiId) { + return this.config.apiId; + } const logicalIdGraphQLApi = this.naming.getApiLogicalId(); return { 'Fn::GetAtt': [logicalIdGraphQLApi, 'ApiId'], }; } + isExistingApi() { + return !!this.config?.apiId; + } + getUserPoolConfig(auth: CognitoAuth, isAdditionalAuth = false) { const userPoolConfig = { AwsRegion: auth.config.awsRegion || { 'Fn::Sub': '${AWS::Region}' }, @@ -525,10 +576,10 @@ export class Api { } hasDataSource(name: string) { - return name in this.config.dataSources; + return name in (this.config.dataSources || {}); } hasPipelineFunction(name: string) { - return name in this.config.pipelineFunctions; + return name in (this.config.pipelineFunctions || {}); } } diff --git a/src/resources/Resolver.ts b/src/resources/Resolver.ts index 249777d1..77560835 100644 --- a/src/resources/Resolver.ts +++ b/src/resources/Resolver.ts @@ -125,7 +125,9 @@ export class Resolver { return { [logicalIdResolver]: { Type: 'AWS::AppSync::Resolver', - DependsOn: [logicalIdGraphQLSchema], + ...(!this.api.isExistingApi() && { + DependsOn: [logicalIdGraphQLSchema], + }), Properties, }, }; diff --git a/src/types/cloudFormation.ts b/src/types/cloudFormation.ts index 6100d4e9..7087728f 100644 --- a/src/types/cloudFormation.ts +++ b/src/types/cloudFormation.ts @@ -16,7 +16,11 @@ export type FnSub = { 'Fn::Sub': [string, Record]; }; -export type IntrinsicFunction = FnGetAtt | FnJoin | FnRef | FnSub; +export type FnImportValue = { + 'Fn::ImportValue': string; +}; + +export type IntrinsicFunction = FnGetAtt | FnJoin | FnRef | FnSub | FnImportValue; export type CfnDeltaSyncConfig = { BaseTableTTL: number; diff --git a/src/types/plugin.ts b/src/types/plugin.ts index 84d6debb..dd70cec1 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -2,9 +2,9 @@ import { CfnWafRuleStatement, IntrinsicFunction } from './cloudFormation'; export type AppSyncConfig = { name: string; - schema: string[]; - authentication: Auth; + authentication?: Auth; additionalAuthentications: Auth[]; + schema?: string[]; domain?: DomainConfig; apiKeys?: Record; dataSources: Record; @@ -16,6 +16,7 @@ export type AppSyncConfig = { caching?: CachingConfig; waf?: WafConfig; tags?: Record; + apiId?: string | IntrinsicFunction; }; export type IamStatement = { diff --git a/src/validation.ts b/src/validation.ts index abbf8fbe..8d515907 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -833,8 +833,9 @@ export const appSyncSchema = { ], errorMessage: 'contains invalid pipeline function definitions', }, + apiId: { $ref: '#/definitions/stringOrIntrinsicFunction' }, }, - required: ['name', 'authentication'], + required: ['name'], additionalProperties: { not: true, errorMessage: 'invalid (unknown) property',