diff --git a/doc/general-config.md b/doc/general-config.md index 19c08d17..3a979a5f 100644 --- a/doc/general-config.md +++ b/doc/general-config.md @@ -56,6 +56,7 @@ appSync: - `resolverCountLimit`: Optional. The maximum number of resolvers a query can process. Must be between 1 and 1000. If not specified: unlimited. - `tags`: A key-value pair for tagging this AppSync API - `esbuild`: Custom esbuild options, or `false` See [Esbuild](#Esbuild) +- `apiId`: See [ApiId](#ApiId) ## Schema @@ -210,3 +211,33 @@ appSync: target: 'es2020', sourcemap: false ``` + + +## 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/package-lock.json b/package-lock.json index 06656a6f..0bbb6c37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "esbuild": "^0.17.11", "globby": "^11.1.0", "graphql": "^16.6.0", - "lodash": "^4.17.21", + "lodash-es": "^4.17.21", "luxon": "^2.5.0", "open": "^8.4.0", "terminal-link": "^2.1.1" @@ -8552,6 +8552,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "node_modules/lodash.capitalize": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz", @@ -22248,6 +22253,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "lodash.capitalize": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz", diff --git a/package.json b/package.json index 3b3f0e4b..a93e6445 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "serverless-appsync-plugin", "version": "0.0.0-development", + "type": "module", "description": "AWS AppSync support for the Serverless Framework", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -48,7 +49,7 @@ "esbuild": "^0.17.11", "globby": "^11.1.0", "graphql": "^16.6.0", - "lodash": "^4.17.21", + "lodash-es": "^4.17.21", "luxon": "^2.5.0", "open": "^8.4.0", "terminal-link": "^2.1.1" diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts index 87405c9d..15c926a5 100644 --- a/src/__tests__/api.test.ts +++ b/src/__tests__/api.test.ts @@ -316,6 +316,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', () => { @@ -342,6 +346,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({ @@ -568,6 +579,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', () => { @@ -585,6 +610,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({ @@ -663,6 +700,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({ @@ -826,4 +868,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__/basicConfig.ts b/src/__tests__/basicConfig.ts index 7d8692ff..16fc7710 100644 --- a/src/__tests__/basicConfig.ts +++ b/src/__tests__/basicConfig.ts @@ -1,4 +1,4 @@ -import { AppSyncConfig } from '../types'; +import { AppSyncConfig } from '../types/index.js'; export const basicConfig: AppSyncConfig = { name: 'My Api', diff --git a/src/__tests__/getAppSyncConfig.test.ts b/src/__tests__/getAppSyncConfig.test.ts index db939541..0b444b10 100644 --- a/src/__tests__/getAppSyncConfig.test.ts +++ b/src/__tests__/getAppSyncConfig.test.ts @@ -1,7 +1,7 @@ -import { pick } from 'lodash'; -import { getAppSyncConfig } from '../getAppSyncConfig'; -import { basicConfig } from './basicConfig'; -import { ResolverConfig } from '../types'; +import { pick } from 'lodash-es'; +import { getAppSyncConfig } from '../getAppSyncConfig.js'; +import { basicConfig } from './basicConfig.js'; +import { ResolverConfig } from '../types/index.js'; test('returns basic config', async () => { expect(getAppSyncConfig(basicConfig)).toMatchSnapshot(); @@ -9,53 +9,59 @@ test('returns basic config', async () => { describe('Schema', () => { it('should return the default schema', () => { - expect( - getAppSyncConfig({ ...basicConfig, schema: undefined }).schema, - ).toMatchSnapshot(); + const config = getAppSyncConfig({ ...basicConfig, schema: undefined }); + const schema = 'schema' in config ? config.schema : undefined; + expect(schema).toMatchSnapshot(); }); it('should return a single schema as an array', () => { - expect( - getAppSyncConfig({ ...basicConfig, schema: 'mySchema.graphql' }).schema, - ).toMatchSnapshot(); + const config = getAppSyncConfig({ + ...basicConfig, + schema: 'mySchema.graphql', + }); + const schema = 'schema' in config ? config.schema : undefined; + expect(schema).toMatchSnapshot(); }); it('should return a schema array unchanged', () => { - expect( - getAppSyncConfig({ - ...basicConfig, - schema: ['users.graphql', 'posts.graphql'], - }).schema, - ).toMatchSnapshot(); + const config = getAppSyncConfig({ + ...basicConfig, + schema: ['users.graphql', 'posts.graphql'], + }); + const schema = 'schema' in config ? config.schema : undefined; + expect(schema).toMatchSnapshot(); }); }); describe('Api Keys', () => { it('should not generate a default Api Key when auth is not API_KEY', () => { - expect( - getAppSyncConfig({ ...basicConfig, authentication: { type: 'AWS_IAM' } }) - .apiKeys, - ).toBeUndefined(); + const config = getAppSyncConfig({ + ...basicConfig, + authentication: { type: 'AWS_IAM' }, + }); + const apiKeys = 'apiKeys' in config ? config.apiKeys : undefined; + expect(apiKeys).toBeUndefined(); }); it('should generate api keys', () => { - expect( - getAppSyncConfig({ - ...basicConfig, - apiKeys: [ - { - name: 'John', - description: "John's key", - expiresAt: '2021-03-09T16:00:00+00:00', - }, - { - name: 'Jane', - expiresAfter: '1y', - }, - 'InlineKey', - ], - }).apiKeys, - ).toMatchInlineSnapshot(` + const config = getAppSyncConfig({ + ...basicConfig, + apiKeys: [ + { + name: 'John', + description: "John's key", + expiresAt: '2021-03-09T16:00:00+00:00', + }, + { + name: 'Jane', + expiresAfter: '1y', + }, + 'InlineKey', + ], + }); + const apiKeys = 'apiKeys' in config ? config.apiKeys : undefined; + + expect(apiKeys).toMatchInlineSnapshot(` Object { "InlineKey": Object { "name": "InlineKey", @@ -229,7 +235,7 @@ describe('Resolvers', () => { field: 'getUsers', }, }, - ] as Record[], + ] satisfies Record[], }); expect(config.resolvers).toMatchSnapshot(); }); diff --git a/src/__tests__/given.ts b/src/__tests__/given.ts index 6fbed2d6..2b5c51d5 100644 --- a/src/__tests__/given.ts +++ b/src/__tests__/given.ts @@ -1,7 +1,7 @@ -import { set } from 'lodash'; +import { set } from 'lodash-es'; import Serverless from 'serverless/lib/serverless'; import AwsProvider from 'serverless/lib/plugins/aws/provider.js'; -import { AppSyncConfig } from '../types/plugin'; +import { AppSyncConfig } from '../types/plugin.js'; import ServerlessAppsyncPlugin from '..'; export const createServerless = (): Serverless => { 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__/utils.ts b/src/__tests__/utils.ts index deeaebce..bf015efc 100644 --- a/src/__tests__/utils.ts +++ b/src/__tests__/utils.ts @@ -1,7 +1,7 @@ import runServerlessFixtureEngine from '@serverless/test/setup-run-serverless-fixtures-engine'; -import { merge } from 'lodash'; +import { merge } from 'lodash-es'; import path from 'path'; -import Serverless from 'serverless/lib/Serverless'; +import Serverless from 'serverless'; type RunSlsOptions = { fixture: 'appsync'; diff --git a/src/__tests__/validation/__snapshots__/base.test.ts.snap b/src/__tests__/validation/__snapshots__/base.test.ts.snap index 7afdb44d..a585224b 100644 --- a/src/__tests__/validation/__snapshots__/base.test.ts.snap +++ b/src/__tests__/validation/__snapshots__/base.test.ts.snap @@ -52,8 +52,7 @@ 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' +": must have required property 'authentication' /unknownPorp: invalid (unknown) property /xrayEnabled: must be boolean /visibility: must be \\"GLOBAL\\" or \\"PRIVATE\\" @@ -68,3 +67,5 @@ exports[`Valdiation should validate 2`] = ` "/queryDepthLimit: must be <= 75 /resolverCountLimit: must be <= 1000" `; + +exports[`Valdiation should validate 3`] = `"Invalid configuration: must contain either \\\"apiId\\\" or \\\"name\\\""`; diff --git a/src/__tests__/validation/base.test.ts b/src/__tests__/validation/base.test.ts index 5824bc2d..bddd5bb9 100644 --- a/src/__tests__/validation/base.test.ts +++ b/src/__tests__/validation/base.test.ts @@ -29,6 +29,7 @@ describe('Valdiation', () => { expect(function () { validateConfig({ + name: 'FOO', visibility: 'FOO', introspection: 10, queryDepthLimit: 'foo', @@ -47,6 +48,19 @@ describe('Valdiation', () => { resolverCountLimit: 1001, }); }).toThrowErrorMatchingSnapshot(); + + expect(function () { + validateConfig({ + visibility: 'FOO', + introspection: 10, + queryDepthLimit: 'foo', + resolverCountLimit: 'bar', + xrayEnabled: 'BAR', + unknownPorp: 'foo', + esbuild: 'bad', + environment: 'Bad', + }); + }).toThrowErrorMatchingSnapshot(); }); describe('Log', () => { @@ -69,7 +83,7 @@ describe('Valdiation', () => { level: 'ALL', retentionInDays: 14, excludeVerboseContent: true, - loggingRoleArn: { Ref: 'MyLogGorupArn' }, + // loggingRoleArn: { Ref: 'MyLogGorupArn' }, // TODO : why was it only in the tests ? }, } as AppSyncConfig, }, diff --git a/src/__tests__/waf.test.ts b/src/__tests__/waf.test.ts index 9cdc83c9..deb15647 100644 --- a/src/__tests__/waf.test.ts +++ b/src/__tests__/waf.test.ts @@ -1,8 +1,8 @@ -import { Api } from '../resources/Api'; -import { ApiKeyConfig, WafRule } from '../types/plugin'; -import { each } from 'lodash'; -import { Waf } from '../resources/Waf'; -import * as given from './given'; +import { Api } from '../resources/Api.js'; +import { ApiKeyConfig, WafRule } from '../types/plugin.js'; +import { each } from 'lodash-es'; +import { Waf } from '../resources/Waf.js'; +import * as given from './given.js'; const plugin = given.plugin(); @@ -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 a9427728..77ef73c4 100644 --- a/src/getAppSyncConfig.ts +++ b/src/getAppSyncConfig.ts @@ -1,12 +1,22 @@ -import { AppSyncConfig } from './types'; import { + AppSyncConfig, + isSharedApiConfig, + PipelineResolverConfig, + UnitResolverConfig, +} from './types/index.js'; + +import type { ApiKeyConfig, AppSyncConfig as PluginAppSyncConfig, DataSourceConfig, PipelineFunctionConfig, ResolverConfig, -} from './types/plugin'; -import { forEach, merge } from 'lodash'; + BaseAppSyncConfig, + SharedAppSyncConfig, + FullAppSyncConfig, + Substitutions, +} from './types/plugin.js'; +import { forEach, merge } from 'lodash-es'; const flattenMaps = ( input?: Record | Record[], @@ -20,13 +30,13 @@ const flattenMaps = ( export const isUnitResolver = (resolver: { kind?: 'UNIT' | 'PIPELINE'; -}): resolver is { kind: 'UNIT' } => { +}): resolver is UnitResolverConfig => { return resolver.kind === 'UNIT'; }; export const isPipelineResolver = (resolver: { kind?: 'UNIT' | 'PIPELINE'; -}): resolver is { kind: 'PIPELINE' } => { +}): resolver is PipelineResolverConfig => { return !resolver.kind || resolver.kind === 'PIPELINE'; }; @@ -37,13 +47,56 @@ const toResourceName = (name: string) => { export const getAppSyncConfig = ( config: AppSyncConfig, ): PluginAppSyncConfig => { + const baseConfig = getBaseAppsyncConfig(config); + + // handle shared appsync config + if (isSharedApiConfig(config)) { + const apiId: string = config.apiId; + return { + ...baseConfig, + apiId, + } satisfies SharedAppSyncConfig; + } + + // Handle full appsync config const schema = Array.isArray(config.schema) ? config.schema : [config.schema || 'schema.graphql']; + const additionalAuthentications = config.additionalAuthentications || []; + + let apiKeys: Record | undefined; + if ( + config.authentication?.type === 'API_KEY' || + additionalAuthentications.some((auth) => auth.type === 'API_KEY') + ) { + const inputKeys = config.apiKeys || []; + + apiKeys = inputKeys.reduce((acc, key) => { + if (typeof key === 'string') { + acc[key] = { name: key }; + } else { + acc[key.name] = key; + } + + return acc; + }, {}); + } + + return { + ...config, + ...baseConfig, + additionalAuthentications, + apiKeys, + schema, + } satisfies FullAppSyncConfig; +}; + +function getBaseAppsyncConfig(config: AppSyncConfig): BaseAppSyncConfig { const dataSources: Record = {}; const resolvers: Record = {}; const pipelineFunctions: Record = {}; + const substitutions: Substitutions = {}; forEach(flattenMaps(config.dataSources), (ds, name) => { dataSources[name] = { @@ -126,33 +179,14 @@ export const getAppSyncConfig = ( }; }); - const additionalAuthentications = config.additionalAuthentications || []; - - let apiKeys: Record | undefined; - if ( - config.authentication.type === 'API_KEY' || - additionalAuthentications.some((auth) => auth.type === 'API_KEY') - ) { - const inputKeys = config.apiKeys || []; - - apiKeys = inputKeys.reduce((acc, key) => { - if (typeof key === 'string') { - acc[key] = { name: key }; - } else { - acc[key.name] = key; - } - - return acc; - }, {}); + if (config.substitutions) { + Object.assign(substitutions, config.substitutions); } return { - ...config, - additionalAuthentications, - apiKeys, - schema, dataSources, resolvers, pipelineFunctions, + substitutions, }; -}; +} diff --git a/src/index.ts b/src/index.ts index da62e3e0..7dc94fbc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import Serverless from 'serverless/lib/Serverless'; import Provider from 'serverless/lib/plugins/aws/provider.js'; -import { forEach, last, merge } from 'lodash'; -import { getAppSyncConfig } from './getAppSyncConfig'; +import { forEach, last, merge } from 'lodash-es'; +import { getAppSyncConfig } from './getAppSyncConfig.js'; import { GraphQLError } from 'graphql'; import { DateTime } from 'luxon'; import chalk from 'chalk'; @@ -41,16 +41,16 @@ import { FilterLogEventsResponse, FilterLogEventsRequest, } from 'aws-sdk/clients/cloudwatchlogs'; -import { AppSyncValidationError, validateConfig } from './validation'; +import { AppSyncValidationError, validateConfig } from './validation.js'; import { confirmAction, getHostedZoneName, getWildCardDomainName, parseDateTimeOrDuration, wait, -} from './utils'; -import { Api } from './resources/Api'; -import { Naming } from './resources/Naming'; +} from './utils.js'; +import { Api } from './resources/Api.js'; +import { Naming } from './resources/Naming.js'; import { ChangeResourceRecordSetsRequest, ChangeResourceRecordSetsResponse, @@ -64,6 +64,7 @@ import { ListCertificatesResponse, } from 'aws-sdk/clients/acm'; import terminalLink from 'terminal-link'; +import { AppSyncConfig, isSharedApiConfig } from './types/plugin.js'; const CONSOLE_BASE_URL = 'https://console.aws.amazon.com'; @@ -102,6 +103,7 @@ class ServerlessAppsyncPlugin { public readonly configurationVariablesSources?: VariablesSourcesDefinition; private api?: Api; private naming?: Naming; + private config?: AppSyncConfig; constructor( public serverless: Serverless, @@ -368,6 +370,13 @@ class ServerlessAppsyncPlugin { async getApiId() { this.loadConfig(); + if (!this.config) { + throw new this.serverless.classes.Error('Unable to load the config'); + } + + if (isSharedApiConfig(this.config) && this.config?.apiId) { + return this.config.apiId; + } if (!this.naming) { throw new this.serverless.classes.Error( @@ -397,7 +406,13 @@ class ServerlessAppsyncPlugin { } async gatherData() { + // Don't Gather any data for shared api + if(this.config && isSharedApiConfig(this.config)) return; + const apiId = await this.getApiId(); + if (!apiId) { + throw new this.serverless.classes.Error('Unable to get AppSync Api Id'); + } const { graphqlApi } = await this.provider.request< GetGraphqlApiRequest, @@ -430,8 +445,10 @@ class ServerlessAppsyncPlugin { } async getIntrospection() { + // Never touch the schema for shared api endpoints + if (!this.api?.config || isSharedApiConfig(this.api.config)) return; + const apiId = await this.getApiId(); - const { schema } = await this.provider.request< GetIntrospectionSchemaRequest, GetIntrospectionSchemaResponse @@ -556,6 +573,12 @@ class ServerlessAppsyncPlugin { ); } + if (isSharedApiConfig(this.api.config)) { + throw new this.serverless.classes.Error( + 'AppSync configuration not found', + ); + } + const { domain } = this.api.config; if (!domain) { throw new this.serverless.classes.Error('Domain configuration not found'); @@ -696,8 +719,15 @@ class ServerlessAppsyncPlugin { } async assocDomain() { - const domain = this.getDomain(); + if (this.api?.config && isSharedApiConfig(this.api.config)) + throw new this.serverless.classes.Error('Inpossible to associate a domain to a shared api'); + 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) { @@ -935,27 +965,35 @@ class ServerlessAppsyncPlugin { } displayEndpoints() { + // Don't display endpoints for shared api endpoints + if (!this.api?.config || isSharedApiConfig(this.api.config)) return; + + const endpoints = this.gatheredData.apis.map( ({ type, uri }) => `${type}: ${uri}`, ); - - if (endpoints.length === 0) { - return; - } - - const { name } = this.api?.config?.domain || {}; + + if (endpoints.length === 0) return; + + const { name } = this.api.config?.domain || {}; if (name) { endpoints.push(`graphql: https://${name}/graphql`); endpoints.push(`realtime: wss://${name}/graphql/realtime`); } + + + this.utils.writeText('appsync endpoints:') + for (const uri of endpoints.sort()) { + this.utils.writeText(' '+uri) + } + this.utils.writeText('') - this.serverless.addServiceOutputSection( - 'appsync endpoints', - endpoints.sort(), - ); } displayApiKeys() { + // Never show api keys shared api endpoints + if (!this.api?.config || isSharedApiConfig(this.api.config)) return; + const { conceal } = this.options; const apiKeys = this.gatheredData.apiKeys.map( ({ description, value }) => `${value} (${description})`, @@ -966,7 +1004,11 @@ class ServerlessAppsyncPlugin { } if (!conceal) { - this.serverless.addServiceOutputSection('appsync api keys', apiKeys); + this.utils.writeText('appsync api keys') + for (const key of apiKeys) { + this.utils.writeText(' '+key) + } + this.utils.writeText('') } } @@ -984,12 +1026,15 @@ 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() { + // Never validate schema for shared api endpoints + if (!this.api?.config || isSharedApiConfig(this.api.config)) return; + try { this.utils.log.info('Validating AppSync schema'); if (!this.api) { @@ -1084,4 +1129,4 @@ class ServerlessAppsyncPlugin { } } -export = ServerlessAppsyncPlugin; +export default ServerlessAppsyncPlugin; diff --git a/src/resources/Api.ts b/src/resources/Api.ts index 92a9663a..6c2227b3 100644 --- a/src/resources/Api.ts +++ b/src/resources/Api.ts @@ -1,10 +1,10 @@ import ServerlessAppsyncPlugin from '..'; -import { forEach, isEmpty, merge, set } from 'lodash'; +import { forEach, isEmpty, merge, set } from 'lodash-es'; import { CfnResource, CfnResources, IntrinsicFunction, -} from '../types/cloudFormation'; +} from '../types/cloudFormation.js'; import { ApiKeyConfig, AppSyncConfig, @@ -16,41 +16,46 @@ import { LambdaConfig, OidcAuth, ResolverConfig, -} from '../types/plugin'; -import { getHostedZoneName, parseDuration } from '../utils'; + isSharedApiConfig, +} from '../types/plugin.js'; +import { getHostedZoneName, parseDuration } from '../utils.js'; import { DateTime, Duration } from 'luxon'; -import { Naming } from './Naming'; -import { DataSource } from './DataSource'; -import { Resolver } from './Resolver'; -import { PipelineFunction } from './PipelineFunction'; -import { Schema } from './Schema'; -import { Waf } from './Waf'; +import { Naming } from './Naming.js'; +import { DataSource } from './DataSource.js'; +import { Resolver } from './Resolver.js'; +import { PipelineFunction } from './PipelineFunction.js'; +import { Schema } from './Schema.js'; +import { Waf } from './Waf.js'; +import { log } from '@serverless/utils/log'; export class Api { - public naming: Naming; + public naming?: Naming; public functions: Record> = {}; constructor( public config: AppSyncConfig, public plugin: ServerlessAppsyncPlugin, ) { - this.naming = new Naming(this.config.name); + if ('name' in config) { + this.naming = new Naming(config.name); + } } compile() { const resources: CfnResources = {}; - merge(resources, this.compileEndpoint()); - merge(resources, this.compileSchema()); - merge(resources, this.compileCustomDomain()); - merge(resources, this.compileCloudWatchLogGroup()); - merge(resources, this.compileLambdaAuthorizerPermission()); - merge(resources, this.compileWafRules()); - merge(resources, this.compileCachingResources()); - - forEach(this.config.apiKeys, (key) => { - merge(resources, this.compileApiKey(key)); - }); + if (!isSharedApiConfig(this.config)) { + merge(resources, this.compileEndpoint()); + merge(resources, this.compileSchema()); + merge(resources, this.compileCustomDomain()); + merge(resources, this.compileCloudWatchLogGroup()); + merge(resources, this.compileLambdaAuthorizerPermission()); + merge(resources, this.compileWafRules()); + merge(resources, this.compileCachingResources()); //! requires naming + forEach(this.config.apiKeys, (key) => { + merge(resources, this.compileApiKey(key)); + }); + } forEach(this.config.dataSources, (ds) => { merge(resources, this.compileDataSource(ds)); @@ -68,6 +73,10 @@ export class Api { } compileEndpoint(): CfnResources { + // in a class, the type needs to be cheked every time + if (isSharedApiConfig(this.config)) return {}; + if (!this.naming) + throw new this.plugin.serverless.classes.Error('Unable to load naming'); const logicalId = this.naming.getApiLogicalId(); const endpointResource: CfnResource = { @@ -88,7 +97,7 @@ export class Api { if (this.config.additionalAuthentications.length > 0) { merge(endpointResource.Properties, { AdditionalAuthenticationProviders: - this.config.additionalAuthentications?.map((provider) => + this.config.additionalAuthentications.map((provider) => this.compileAuthenticationProvider(provider, true), ), }); @@ -138,9 +147,15 @@ export class Api { } compileCloudWatchLogGroup(): CfnResources { - if (!this.config.logging || this.config.logging.enabled === false) { + if ( + isSharedApiConfig(this.config) || + !this.config.logging || + this.config.logging.enabled === false + ) { return {}; } + if (!this.naming) + throw new this.plugin.serverless.classes.Error('Unable to load naming'); const logGroupLogicalId = this.naming.getLogGroupLogicalId(); const roleLogicalId = this.naming.getLogGroupRoleLogicalId(); @@ -208,11 +223,17 @@ export class Api { } compileSchema() { + if (isSharedApiConfig(this.config)) return {}; + if (!this.config.schema) return {}; + const schema = new Schema(this, this.config.schema); return schema.compile(); } compileCustomDomain(): CfnResources { + if (isSharedApiConfig(this.config)) return {}; + if (!this.naming) + throw new this.plugin.serverless.classes.Error('Unable to load naming'); const { domain } = this.config; if ( @@ -304,6 +325,12 @@ export class Api { } compileLambdaAuthorizerPermission(): CfnResources { + if (isSharedApiConfig(this.config)) return {}; + if (!this.naming) + throw new this.plugin.serverless.classes.Error('Unable to load naming'); + + if (!this.config.authentication) return {}; + const lambdaAuth = [ ...this.config.additionalAuthentications, this.config.authentication, @@ -333,6 +360,10 @@ export class Api { } compileApiKey(config: ApiKeyConfig) { + if (isSharedApiConfig(this.config)) return {}; + if (!this.naming) + throw new this.plugin.serverless.classes.Error('Unable to load naming'); + const { name, expiresAt, expiresAfter, description, apiKeyId } = config; const startOfHour = DateTime.now().setZone('UTC').startOf('hour'); @@ -360,7 +391,7 @@ export class Api { expires < DateTime.now().plus({ day: 1 }) || expires > DateTime.now().plus({ years: 365 }) ) { - throw new Error( + throw new this.plugin.serverless.classes.Error( `Api Key ${name} must be valid for a minimum of 1 day and a maximum of 365 days.`, ); } @@ -381,26 +412,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(); + if (isSharedApiConfig(this.config)) return {}; + if (!this.naming) + throw new this.plugin.serverless.classes.Error('Unable to load naming'); - 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) { + 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 { @@ -421,7 +456,9 @@ export class Api { } compileWafRules() { - if (!this.config.waf || this.config.waf.enabled === false) { + if (isSharedApiConfig(this.config)) return {}; + + if (!this.config.waf || this.config.waf?.enabled === false) { return {}; } @@ -430,6 +467,11 @@ export class Api { } getApiId() { + if (isSharedApiConfig(this.config) && this.config.apiId) { + return this.config.apiId; + } + if (!this.naming) + throw new this.plugin.serverless.classes.Error('Unable to load naming'); const logicalIdGraphQLApi = this.naming.getApiLogicalId(); return { 'Fn::GetAtt': [logicalIdGraphQLApi, 'ApiId'], @@ -469,6 +511,8 @@ export class Api { } getLambdaAuthorizerConfig(auth: LambdaAuth) { + if (!this.naming) + throw new this.plugin.serverless.classes.Error('Unable to load naming'); if (!auth.config) { return; } @@ -486,9 +530,8 @@ export class Api { } getTagsConfig() { - if (!this.config.tags || isEmpty(this.config.tags)) { - return undefined; - } + if (isSharedApiConfig(this.config)) return; + if (!this.config.tags || isEmpty(this.config.tags)) return; const tags = this.config.tags; return Object.keys(this.config.tags).map((key) => ({ @@ -530,7 +573,7 @@ export class Api { return this.generateLambdaArn(embededFunctionName); } - throw new Error( + throw new this.plugin.serverless.classes.Error( 'You must specify either `functionArn`, `functionName` or `function` for lambda definitions.', ); } @@ -549,6 +592,7 @@ export class Api { : lambdaArn; } + // Todo: [cleanup] Same syntax for apiId ? hasDataSource(name: string) { return name in this.config.dataSources; } diff --git a/src/resources/DataSource.ts b/src/resources/DataSource.ts index 5e3fd5cd..01c94d17 100644 --- a/src/resources/DataSource.ts +++ b/src/resources/DataSource.ts @@ -1,9 +1,9 @@ -import { merge } from 'lodash'; +import { merge } from 'lodash-es'; import { CfnDataSource, CfnResources, IntrinsicFunction, -} from '../types/cloudFormation'; +} from '../types/cloudFormation.js'; import { DataSourceConfig, DsDynamoDBConfig, @@ -12,8 +12,9 @@ import { DsRelationalDbConfig, IamStatement, DsEventBridgeConfig, -} from '../types/plugin'; -import { Api } from './Api'; +} from '../types/plugin.js'; +import { Api } from './Api.js'; +import { Naming } from './Naming.js'; export class DataSource { constructor(private api: Api, private config: DataSourceConfig) {} @@ -33,7 +34,7 @@ export class DataSource { resource.Properties.LambdaConfig = { LambdaFunctionArn: this.api.getLambdaArn( this.config.config, - this.api.naming.getDataSourceEmbeddedLambdaResolverName(this.config), + Naming.getDataSourceEmbeddedLambdaResolverName(this.config), ), }; } else if (this.config.type === 'AMAZON_DYNAMODB') { @@ -54,7 +55,7 @@ export class DataSource { ); } - const logicalId = this.api.naming.getDataSourceLogicalId(this.config.name); + const logicalId = Naming.getDataSourceLogicalId(this.config.name); const resources = { [logicalId]: resource, @@ -65,7 +66,7 @@ export class DataSource { } else { const role = this.compileDataSourceIamRole(); if (role) { - const roleLogicalId = this.api.naming.getDataSourceRoleLogicalId( + const roleLogicalId = Naming.getDataSourceRoleLogicalId( this.config.name, ); resource.Properties.ServiceRoleArn = { @@ -127,7 +128,9 @@ export class DataSource { }); // FIXME: can we validate this and make TS infer mutually eclusive types? if (!endpoint) { - throw new Error('Specify eithe rendpoint or domain'); + throw new this.api.plugin.serverless.classes.Error( + 'Specify eithe rendpoint or domain', + ); } return { AwsRegion: config.config.region || { Ref: 'AWS::Region' }, @@ -204,7 +207,7 @@ export class DataSource { this.config.config.authorizationConfig.authorizationType === 'AWS_IAM' && !this.config.config.iamRoleStatements ) { - throw new Error( + throw new this.api.plugin.serverless.classes.Error( `${this.config.name}: When using AWS_IAM signature, you must also specify the required iamRoleStatements`, ); } @@ -220,9 +223,7 @@ export class DataSource { return; } - const logicalId = this.api.naming.getDataSourceRoleLogicalId( - this.config.name, - ); + const logicalId = Naming.getDataSourceRoleLogicalId(this.config.name); return { [logicalId]: { @@ -259,7 +260,7 @@ export class DataSource { case 'AWS_LAMBDA': { const lambdaArn = this.api.getLambdaArn( this.config.config, - this.api.naming.getDataSourceEmbeddedLambdaResolverName(this.config), + Naming.getDataSourceEmbeddedLambdaResolverName(this.config), ); // Allow "invoke" for the Datasource's function and its aliases/versions @@ -371,7 +372,7 @@ export class DataSource { /^https:\/\/([a-z0-9-]+\.(\w{2}-[a-z]+-\d)\.es\.amazonaws\.com)$/; const result = rx.exec(this.config.config.endpoint); if (!result) { - throw new Error( + throw new this.api.plugin.serverless.classes.Error( `Invalid AWS OpenSearch endpoint: '${this.config.config.endpoint}`, ); } @@ -396,7 +397,7 @@ export class DataSource { ], }; } else { - throw new Error( + throw new this.api.plugin.serverless.classes.Error( `Could not determine the Arn for dataSource '${this.config.name}`, ); } diff --git a/src/resources/JsResolver.ts b/src/resources/JsResolver.ts index 0cc78480..58498c24 100644 --- a/src/resources/JsResolver.ts +++ b/src/resources/JsResolver.ts @@ -1,7 +1,7 @@ -import { IntrinsicFunction } from '../types/cloudFormation'; +import { IntrinsicFunction } from '../types/cloudFormation.js'; import fs from 'fs'; -import { Substitutions } from '../types/plugin'; -import { Api } from './Api'; +import { isSharedApiConfig, Substitutions } from '../types/plugin.js'; +import { Api } from './Api.js'; import { buildSync } from 'esbuild'; type JsResolverConfig = { @@ -23,6 +23,11 @@ export class JsResolver { } getResolverContent(): string { + if (isSharedApiConfig(this.api.config)) { + // Todo : [feature] handle js resolvers with config from the parent stack + console.warn('esbuild config is ignored for shared appsync'); + return fs.readFileSync(this.config.path, 'utf8'); + } if (this.api.config.esbuild === false) { return fs.readFileSync(this.config.path, 'utf8'); } diff --git a/src/resources/MappingTemplate.ts b/src/resources/MappingTemplate.ts index 36bc78c6..8f3fc408 100644 --- a/src/resources/MappingTemplate.ts +++ b/src/resources/MappingTemplate.ts @@ -1,7 +1,7 @@ -import { IntrinsicFunction } from '../types/cloudFormation'; +import { IntrinsicFunction } from '../types/cloudFormation.js'; import fs from 'fs'; -import { Substitutions } from '../types/plugin'; -import { Api } from './Api'; +import { Substitutions } from '../types/plugin.js'; +import { Api } from './Api.js'; type MappingTemplateConfig = { path: string; diff --git a/src/resources/Naming.ts b/src/resources/Naming.ts index d3b3cacb..adf4178d 100644 --- a/src/resources/Naming.ts +++ b/src/resources/Naming.ts @@ -2,94 +2,92 @@ import { DataSourceConfig, PipelineFunctionConfig, ResolverConfig, -} from '../types/plugin'; +} from '../types/plugin.js'; export class Naming { constructor(private apiName: string) {} - getCfnName(name: string) { + static getCfnName(name: string) { return name.replace(/[^a-zA-Z0-9]/g, ''); } - getLogicalId(name: string): string { - return this.getCfnName(name); + static getLogicalId(name: string): string { + return Naming.getCfnName(name); } getApiLogicalId() { - return this.getLogicalId(`GraphQlApi`); + return Naming.getLogicalId(`GraphQlApi`); } getSchemaLogicalId() { - return this.getLogicalId(`GraphQlSchema`); + return Naming.getLogicalId(`GraphQlSchema`); } getDomainNameLogicalId() { - return this.getLogicalId(`GraphQlDomainName`); + return Naming.getLogicalId(`GraphQlDomainName`); } getDomainCertificateLogicalId() { - return this.getLogicalId(`GraphQlDomainCertificate`); + return Naming.getLogicalId(`GraphQlDomainCertificate`); } getDomainAssociationLogicalId() { - return this.getLogicalId(`GraphQlDomainAssociation`); + return Naming.getLogicalId(`GraphQlDomainAssociation`); } getDomainReoute53RecordLogicalId() { - return this.getLogicalId(`GraphQlDomainRoute53Record`); + return Naming.getLogicalId(`GraphQlDomainRoute53Record`); } getLogGroupLogicalId() { - return this.getLogicalId(`GraphQlApiLogGroup`); + return Naming.getLogicalId(`GraphQlApiLogGroup`); } getLogGroupRoleLogicalId() { - return this.getLogicalId(`GraphQlApiLogGroupRole`); + return Naming.getLogicalId(`GraphQlApiLogGroupRole`); } getLogGroupPolicyLogicalId() { - return this.getLogicalId(`GraphQlApiLogGroupPolicy`); + return Naming.getLogicalId(`GraphQlApiLogGroupPolicy`); } getCachingLogicalId() { - return this.getLogicalId(`GraphQlCaching`); + return Naming.getLogicalId(`GraphQlCaching`); } getLambdaAuthLogicalId() { - return this.getLogicalId(`LambdaAuthorizerPermission`); + return Naming.getLogicalId(`LambdaAuthorizerPermission`); } getApiKeyLogicalId(name: string) { - return this.getLogicalId(`GraphQlApi${name}`); + return Naming.getLogicalId(`GraphQlApi${name}`); } - // Warning: breaking change. - // api name added - getDataSourceLogicalId(name: string) { - return `GraphQlDs${this.getLogicalId(name)}`; + static getDataSourceLogicalId(name: string) { + return `GraphQlDs${Naming.getLogicalId(name)}`; } - getDataSourceRoleLogicalId(name: string) { - return this.getDataSourceLogicalId(`${name}Role`); + static getDataSourceRoleLogicalId(name: string) { + return Naming.getDataSourceLogicalId(`${name}Role`); } - getResolverLogicalId(type: string, field: string) { - return this.getLogicalId(`GraphQlResolver${type}${field}`); + static getResolverLogicalId(type: string, field: string) { + return Naming.getLogicalId(`GraphQlResolver${type}${field}`); } - getPipelineFunctionLogicalId(name: string) { - return this.getLogicalId(`GraphQlFunctionConfiguration${name}`); + static getPipelineFunctionLogicalId(name: string) { + return Naming.getLogicalId(`GraphQlFunctionConfiguration${name}`); } getWafLogicalId() { - return this.getLogicalId('GraphQlWaf'); + return Naming.getLogicalId('GraphQlWaf'); } getWafAssociationLogicalId() { - return this.getLogicalId('GraphQlWafAssoc'); + return Naming.getLogicalId('GraphQlWafAssoc'); } - getDataSourceEmbeddedLambdaResolverName(config: DataSourceConfig) { + static getDataSourceEmbeddedLambdaResolverName(config: DataSourceConfig) { return config.name; } diff --git a/src/resources/PipelineFunction.ts b/src/resources/PipelineFunction.ts index 0b2ab2a1..422451b3 100644 --- a/src/resources/PipelineFunction.ts +++ b/src/resources/PipelineFunction.ts @@ -2,36 +2,43 @@ import { CfnFunctionResolver, CfnResources, IntrinsicFunction, -} from '../types/cloudFormation'; -import { PipelineFunctionConfig } from '../types/plugin'; -import { Api } from './Api'; +} from '../types/cloudFormation.js'; +import { isSharedApiConfig, PipelineFunctionConfig } from '../types/plugin.js'; +import { Api } from './Api.js'; import path from 'path'; -import { MappingTemplate } from './MappingTemplate'; -import { SyncConfig } from './SyncConfig'; -import { JsResolver } from './JsResolver'; +import { MappingTemplate } from './MappingTemplate.js'; +import { SyncConfig } from './SyncConfig.js'; +import { JsResolver } from './JsResolver.js'; +import { Naming } from './Naming.js'; export class PipelineFunction { constructor(private api: Api, private config: PipelineFunctionConfig) {} compile(): CfnResources { const { dataSource, code } = this.config; - if (!this.api.hasDataSource(dataSource)) { + if ( + !isSharedApiConfig(this.api.config) && + !this.api.hasDataSource(dataSource) + ) { throw new this.api.plugin.serverless.classes.Error( `Pipeline Function '${this.config.name}' references unknown DataSource '${dataSource}'`, ); } - const logicalId = this.api.naming.getPipelineFunctionLogicalId( - this.config.name, - ); - const logicalIdDataSource = this.api.naming.getDataSourceLogicalId( - this.config.dataSource, - ); + const logicalId = Naming.getPipelineFunctionLogicalId(this.config.name); + const logicalIdDataSource = Naming.getDataSourceLogicalId(dataSource); + + const dataSourceName = + isSharedApiConfig(this.api.config) && !this.api.hasDataSource(dataSource) + ? dataSource + : ({ + 'Fn::GetAtt': [logicalIdDataSource, 'Name'], + } satisfies IntrinsicFunction); const Properties: CfnFunctionResolver['Properties'] = { ApiId: this.api.getApiId(), Name: this.config.name, - DataSourceName: { 'Fn::GetAtt': [logicalIdDataSource, 'Name'] }, + DataSourceName: dataSourceName, Description: this.config.description, FunctionVersion: '2018-05-29', MaxBatchSize: this.config.maxBatchSize, diff --git a/src/resources/Resolver.ts b/src/resources/Resolver.ts index e7f11029..b8b99db3 100644 --- a/src/resources/Resolver.ts +++ b/src/resources/Resolver.ts @@ -1,14 +1,16 @@ import { CfnResolver, + CfnResource, CfnResources, IntrinsicFunction, -} from '../types/cloudFormation'; -import { ResolverConfig } from '../types/plugin'; -import { Api } from './Api'; +} from '../types/cloudFormation.js'; +import { isSharedApiConfig, ResolverConfig } from '../types/plugin.js'; +import { Api } from './Api.js'; import path from 'path'; -import { MappingTemplate } from './MappingTemplate'; -import { SyncConfig } from './SyncConfig'; -import { JsResolver } from './JsResolver'; +import { MappingTemplate } from './MappingTemplate.js'; +import { SyncConfig } from './SyncConfig.js'; +import { JsResolver } from './JsResolver.js'; +import { Naming } from './Naming.js'; // A decent default for pipeline JS resolvers const DEFAULT_JS_RESOLVERS = ` @@ -58,39 +60,60 @@ export class Resolver { } } - if (this.config.caching) { - if (this.config.caching === true) { - // Use defaults - Properties.CachingConfig = { - Ttl: this.api.config.caching?.ttl || 3600, - }; - } else if (typeof this.config.caching === 'object') { - Properties.CachingConfig = { - CachingKeys: this.config.caching.keys, - Ttl: this.config.caching.ttl || this.api.config.caching?.ttl || 3600, - }; + if (isSharedApiConfig(this.api.config)) { + // Todo : [feature] handle resolvers caching & sync with config from the parent stack + this.api.plugin.utils.log.warning( + 'caching and sync config are ignored for shared appsync', + ); + } else { + if (this.config.caching) { + if (this.config.caching === true) { + // Use defaults + Properties.CachingConfig = { + Ttl: this.api.config.caching?.ttl || 3600, + }; + } else if (typeof this.config.caching === 'object') { + Properties.CachingConfig = { + CachingKeys: this.config.caching.keys, + Ttl: + this.config.caching.ttl || this.api.config.caching?.ttl || 3600, + }; + } } - } - if (this.config.sync) { - const asyncConfig = new SyncConfig(this.api, this.config); - Properties.SyncConfig = asyncConfig.compile(); + if (this.config.sync) { + const asyncConfig = new SyncConfig(this.api, this.config); + Properties.SyncConfig = asyncConfig.compile(); + } } if (this.config.kind === 'UNIT') { const { dataSource } = this.config; - if (!this.api.hasDataSource(dataSource)) { + + if ( + !isSharedApiConfig(this.api.config) && + !this.api.hasDataSource(dataSource) + ) { throw new this.api.plugin.serverless.classes.Error( `Resolver '${this.config.type}.${this.config.field}' references unknown DataSource '${dataSource}'`, ); } - const logicalIdDataSource = - this.api.naming.getDataSourceLogicalId(dataSource); + // Handle datasources defined in existing appsync config + // if the datasource is not found in the current config, use the datasource name instead of the logical id. + const logicalIdDataSource = Naming.getDataSourceLogicalId(dataSource); + const dataSourceName = + isSharedApiConfig(this.api.config) && + !this.api.hasDataSource(dataSource) + ? dataSource + : ({ + 'Fn::GetAtt': [logicalIdDataSource, 'Name'], + } satisfies IntrinsicFunction); + Properties = { ...Properties, Kind: 'UNIT', - DataSourceName: { 'Fn::GetAtt': [logicalIdDataSource, 'Name'] }, + DataSourceName: dataSourceName, MaxBatchSize: this.config.maxBatchSize, }; } else { @@ -106,25 +129,34 @@ export class Resolver { } const logicalIdDataSource = - this.api.naming.getPipelineFunctionLogicalId(name); + Naming.getPipelineFunctionLogicalId(name); return { 'Fn::GetAtt': [logicalIdDataSource, 'FunctionId'] }; }), }, }; } - const logicalIdResolver = this.api.naming.getResolverLogicalId( + const logicalResolver: CfnResource = { + Type: 'AWS::AppSync::Resolver', + Properties, + }; + + // Add dependacy to the schema for the full appsync configs + if (!isSharedApiConfig(this.api.config)) { + if (!this.api.naming) + throw new this.api.plugin.serverless.classes.Error( + 'Unable to load the naming module', + ); + logicalResolver.DependsOn = [this.api.naming.getSchemaLogicalId()]; + } + + const logicalIdResolver = Naming.getResolverLogicalId( this.config.type, this.config.field, ); - const logicalIdGraphQLSchema = this.api.naming.getSchemaLogicalId(); return { - [logicalIdResolver]: { - Type: 'AWS::AppSync::Resolver', - DependsOn: [logicalIdGraphQLSchema], - Properties, - }, + [logicalIdResolver]: logicalResolver, }; } diff --git a/src/resources/Schema.ts b/src/resources/Schema.ts index 1c958d13..853dbe40 100644 --- a/src/resources/Schema.ts +++ b/src/resources/Schema.ts @@ -1,13 +1,13 @@ import globby from 'globby'; import fs from 'fs'; import path from 'path'; -import { CfnResources } from '../types/cloudFormation'; -import { Api } from './Api'; -import { flatten } from 'lodash'; +import { CfnResources } from '../types/cloudFormation.js'; +import { Api } from './Api.js'; +import { flatten } from 'lodash-es'; import { parse, print } from 'graphql'; -import ServerlessError from 'serverless/lib/serverless-error'; -import { validateSDL } from 'graphql/validation/validate'; +import { validateSDL } from 'graphql/validation/validate.js'; import { mergeTypeDefs } from '@graphql-tools/merge'; +import { isSharedApiConfig } from '../types/plugin.js'; const AWS_TYPES = ` directive @aws_iam on FIELD_DEFINITION | OBJECT @@ -34,6 +34,16 @@ export class Schema { constructor(private api: Api, private schemas: string[]) {} compile(): CfnResources { + if (isSharedApiConfig(this.api.config)) { + throw new this.api.plugin.serverless.classes.Error( + 'Unable to override shared api schemas', + ); + } + if (!this.api.naming) { + throw new this.api.plugin.serverless.classes.Error( + 'Unable to load the naming module', + ); + } const logicalId = this.api.naming.getSchemaLogicalId(); return { @@ -58,15 +68,20 @@ export class Schema { } generateSchema() { - const schemaFiles = flatten(globby.sync(this.schemas)); + const cwd = this.api.plugin.serverless.config.servicePath; + const schemaFiles = flatten(globby.sync(this.schemas, { cwd })); + this.api.plugin.utils.log.info('loading schema from :') + this.api.plugin.utils.log.info(schemaFiles.join('\n')) const schemas = schemaFiles.map((file) => { - return fs.readFileSync( - path.join(this.api.plugin.serverless.config.servicePath, file), - 'utf8', - ); + return fs.readFileSync(path.join(cwd, file), 'utf8'); }); + if (schemas.join('\n').length < 1) { + throw new this.api.plugin.serverless.classes.Error( + `AppSync schema should not be empty - cwd: ${cwd}` + ) + } this.valdiateSchema(AWS_TYPES + '\n' + schemas.join('\n')); // Return single files as-is. diff --git a/src/resources/SyncConfig.ts b/src/resources/SyncConfig.ts index 893d2485..f830be5e 100644 --- a/src/resources/SyncConfig.ts +++ b/src/resources/SyncConfig.ts @@ -1,5 +1,9 @@ -import { PipelineFunctionConfig, ResolverConfig } from '../types/plugin'; -import { Api } from './Api'; +import { + isSharedApiConfig, + PipelineFunctionConfig, + ResolverConfig, +} from '../types/plugin.js'; +import { Api } from './Api.js'; export class SyncConfig { constructor( @@ -8,6 +12,16 @@ export class SyncConfig { ) {} compile() { + if (isSharedApiConfig(this.api.config)) { + throw new this.api.plugin.serverless.classes.Error( + 'Unable to set the sync config for a Shared AppsyncApi', + ); + } + if (!this.api.naming) { + throw new this.api.plugin.serverless.classes.Error( + 'Unable to Load the naming module', + ); + } if (!this.config.sync) { return undefined; } diff --git a/src/resources/Waf.ts b/src/resources/Waf.ts index 2d2e0bc7..ca7a3ad7 100644 --- a/src/resources/Waf.ts +++ b/src/resources/Waf.ts @@ -1,28 +1,37 @@ -import { isEmpty, reduce } from 'lodash'; +import { isEmpty, reduce } from 'lodash-es'; import { CfnResources, CfnWafAction, CfnWafRule, CfnWafRuleStatement, -} from '../types/cloudFormation'; +} from '../types/cloudFormation.js'; import { ApiKeyConfig, + isSharedApiConfig, WafConfig, WafRule, WafRuleAction, WafRuleDisableIntrospection, WafThrottleConfig, -} from '../types/plugin'; -import { Api } from './Api'; -import { toCfnKeys } from '../utils'; +} from '../types/plugin.js'; +import { Api } from './Api.js'; +import { toCfnKeys } from '../utils.js'; export class Waf { constructor(private api: Api, private config: WafConfig) {} compile(): CfnResources { const wafConfig = this.config; - if (wafConfig.enabled === false) { - return {}; + if (wafConfig.enabled === false) return {}; + if (isSharedApiConfig(this.api.config)) { + throw new this.api.plugin.serverless.classes.Error( + 'WAF cannot be specified on existing appsync apis', + ); + } + if (!this.api.naming) { + throw new this.api.plugin.serverless.classes.Error( + 'Unable to load the naming module', + ); } const apiLogicalId = this.api.naming.getApiLogicalId(); const wafAssocLogicalId = this.api.naming.getWafAssociationLogicalId(); @@ -129,6 +138,11 @@ export class Waf { } buildApiKeysWafRules(): CfnWafRule[] { + if (isSharedApiConfig(this.api.config)) { + throw new this.api.plugin.serverless.classes.Error( + 'WAF cannot be specified on existing appsync apis', + ); + } return ( reduce( this.api.config.apiKeys, @@ -139,11 +153,22 @@ export class Waf { } buildApiKeyRules(key: ApiKeyConfig) { - const rules = key.wafRules; + if (isSharedApiConfig(this.api.config)) { + throw new this.api.plugin.serverless.classes.Error( + 'WAF cannot be specified on existing appsync apis', + ); + } + if (!this.api.naming) { + // I needed to change the loop to a forof loop to avoid making this check at every loop cycle + throw new this.api.plugin.serverless.classes.Error( + 'Unable to load the naming module', + ); + } + const rules = key.wafRules ?? []; // Build the rule and add a matching rule for the X-Api-Key header // for the given api key const allRules: CfnWafRule[] = []; - rules?.forEach((keyRule) => { + for (const keyRule of rules) { const builtRule = this.buildWafRule(keyRule, key.name); const logicalIdApiKey = this.api.naming.getApiKeyLogicalId(key.name); const { Statement: baseStatement } = builtRule; @@ -198,7 +223,7 @@ export class Waf { ...builtRule, Statement: statement, }); - }); + } return allRules; } 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/index.ts b/src/types/index.ts index e52f8388..2b300c20 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,6 @@ -import { BuildOptions } from 'esbuild'; -import { +//* External typing : Used in serverless.ts +import type { BuildOptions } from 'esbuild'; +import type { ApiKeyConfig, Auth, Substitutions, @@ -16,24 +17,26 @@ import { DsRelationalDbConfig, SyncConfig, EnvironmentVariables, -} from './common'; -export * from './common'; +} from './common.js'; +export * from './common.js'; -export type AppSyncConfig = { +type BaseAppSyncConfig = { + dataSources: + | Record[] + | Record; + resolvers?: Record[] | Record; + pipelineFunctions?: + | Record[] + | Record; + substitutions?: Substitutions; +}; +export type FullAppSyncConfig = BaseAppSyncConfig & { name: string; schema?: string | string[]; authentication: Auth; additionalAuthentications?: Auth[]; domain?: DomainConfig; apiKeys?: (ApiKeyConfig | string)[]; - resolvers?: Record[] | Record; - pipelineFunctions?: - | Record[] - | Record; - dataSources: - | Record[] - | Record; - substitutions?: Substitutions; environment?: EnvironmentVariables; xrayEnabled?: boolean; logging?: LoggingConfig; @@ -47,6 +50,18 @@ export type AppSyncConfig = { resolverCountLimit?: number; }; +export type SharedAppSyncConfig = BaseAppSyncConfig & { + apiId: string; +}; + +export function isSharedApiConfig( + config: AppSyncConfig, +): config is SharedAppSyncConfig { + return 'apiId' in config; +} + +export type AppSyncConfig = FullAppSyncConfig | SharedAppSyncConfig; + export type BaseResolverConfig = { field?: string; type?: string; diff --git a/src/types/plugin.ts b/src/types/plugin.ts index 0b8bf8f0..6709513e 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -1,5 +1,6 @@ -import { BuildOptions } from 'esbuild'; -import { +//* Internal typing : Used in the plugin exclusively +import type { BuildOptions } from 'esbuild'; +import type { Auth, DomainConfig, ApiKeyConfig, @@ -16,20 +17,22 @@ import { DsNone, Substitutions, EnvironmentVariables, -} from './common'; -export * from './common'; +} from './common.js'; +export * from './common.js'; -export type AppSyncConfig = { +export type BaseAppSyncConfig = { + dataSources: Record; + resolvers: Record; + pipelineFunctions: Record; + substitutions?: Substitutions; +}; +export type FullAppSyncConfig = BaseAppSyncConfig & { name: string; schema: string[]; authentication: Auth; additionalAuthentications: Auth[]; domain?: DomainConfig; apiKeys?: Record; - dataSources: Record; - resolvers: Record; - pipelineFunctions: Record; - substitutions?: Substitutions; environment?: EnvironmentVariables; xrayEnabled?: boolean; logging?: LoggingConfig; @@ -42,6 +45,16 @@ export type AppSyncConfig = { queryDepthLimit?: number; resolverCountLimit?: number; }; +export type SharedAppSyncConfig = BaseAppSyncConfig & { + apiId: string; +}; +export type AppSyncConfig = FullAppSyncConfig | SharedAppSyncConfig; + +export function isSharedApiConfig( + config: AppSyncConfig, +): config is SharedAppSyncConfig { + return 'apiId' in config; +} export type BaseResolverConfig = { field: string; diff --git a/src/utils.ts b/src/utils.ts index 5c6e110f..e688655f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,5 @@ -import { upperFirst, transform, values } from 'lodash'; -import { TransformKeysToCfnCase } from './typeHelpers'; +import { upperFirst, transform, values } from 'lodash-es'; +import { TransformKeysToCfnCase } from './typeHelpers.js'; import { DateTime, Duration } from 'luxon'; import { promisify } from 'util'; import * as readline from 'readline'; diff --git a/src/validation.ts b/src/validation.ts index 0a961928..ff493062 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -1,865 +1,85 @@ -import Ajv from 'ajv'; +import Ajv, { type ValidateFunction } from 'ajv'; import ajvErrors from 'ajv-errors'; import ajvMergePatch from 'ajv-merge-patch'; import addFormats from 'ajv-formats'; -import { timeUnits } from './utils'; +import * as def from './validation/definitions.js'; +import * as prop from './validation/properties.js'; -const AUTH_TYPES = [ - 'AMAZON_COGNITO_USER_POOLS', - 'AWS_LAMBDA', - 'OPENID_CONNECT', - 'AWS_IAM', - 'API_KEY', -] as const; +const commonProperties = { + substitutions: prop.substitutions, + dataSources: prop.dataSources, + resolvers: prop.resolvers, + pipelineFunctions: prop.pipelineFunctions, +}; -const DATASOURCE_TYPES = [ - 'AMAZON_DYNAMODB', - 'AMAZON_OPENSEARCH_SERVICE', - 'AWS_LAMBDA', - 'HTTP', - 'NONE', - 'RELATIONAL_DATABASE', - 'AMAZON_EVENTBRIDGE', -] as const; +const definitions = { + stringOrIntrinsicFunction: def.stringOrIntrinsicFunction, + substitutions: def.substitutions, + lambdaFunctionConfig: def.lambdaFunctionConfig, + dataSource: def.dataSource, + resolverConfig: def.resolverConfig, + resolverConfigMap: def.resolverConfigMap, + pipelineFunctionConfig: def.pipelineFunctionConfig, + pipelineFunction: def.pipelineFunction, + pipelineFunctionConfigMap: def.pipelineFunctionConfigMap, + resolverCachingConfig: def.resolverCachingConfig, + iamRoleStatements: def.iamRoleStatements, + dataSourceConfig: def.dataSourceConfig, + dataSourceHttpConfig: def.dataSourceHttpConfig, + dataSourceDynamoDb: def.dataSourceDynamoDb, + datasourceRelationalDbConfig: def.datasourceRelationalDbConfig, + datasourceLambdaConfig: def.datasourceLambdaConfig, + datasourceEsConfig: def.datasourceEsConfig, + datasourceEventBridgeConfig: def.datasourceEventBridgeConfig, + auth: def.auth, + cognitoAuth: def.cognitoAuth, + lambdaAuth: def.lambdaAuth, + oidcAuth: def.oidcAuth, + iamAuth: def.iamAuth, + apiKeyAuth: def.apiKeyAuth, + visibilityConfig: def.visibilityConfig, + wafRule: def.wafRule, + customWafRule: def.customWafRule, + environment: def.environment, + syncConfig: def.syncConfig, +}; -export const appSyncSchema = { +export const sharedAppSyncSchema = { type: 'object', - definitions: { - stringOrIntrinsicFunction: { - oneOf: [ - { type: 'string' }, - { - type: 'object', - required: [], - additionalProperties: true, - }, - ], - errorMessage: 'must be a string or a CloudFormation intrinsic function', - }, - lambdaFunctionConfig: { - oneOf: [ - { - type: 'object', - properties: { - functionName: { type: 'string' }, - functionAlias: { type: 'string' }, - }, - required: ['functionName'], - }, - { - type: 'object', - properties: { - functionArn: { - $ref: '#/definitions/stringOrIntrinsicFunction', - }, - }, - required: ['functionArn'], - }, - { - type: 'object', - properties: { - function: { type: 'object' }, - }, - required: ['function'], - }, - ], - errorMessage: - 'must specify functionName, functionArn or function (all exclusives)', - }, - auth: { - type: 'object', - title: 'Authentication', - description: 'Authentication type and definition', - properties: { - type: { - type: 'string', - enum: AUTH_TYPES, - errorMessage: `must be one of ${AUTH_TYPES.join(', ')}`, - }, - }, - if: { properties: { type: { const: 'AMAZON_COGNITO_USER_POOLS' } } }, - then: { - properties: { config: { $ref: '#/definitions/cognitoAuth' } }, - required: ['config'], - }, - else: { - if: { properties: { type: { const: 'AWS_LAMBDA' } } }, - then: { - properties: { config: { $ref: '#/definitions/lambdaAuth' } }, - required: ['config'], - }, - else: { - if: { properties: { type: { const: 'OPENID_CONNECT' } } }, - then: { - properties: { config: { $ref: '#/definitions/oidcAuth' } }, - required: ['config'], - }, - }, - }, - required: ['type'], - }, - cognitoAuth: { - type: 'object', - properties: { - userPoolId: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - awsRegion: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - defaultAction: { - type: 'string', - enum: ['ALLOW', 'DENY'], - errorMessage: 'must be "ALLOW" or "DENY"', - }, - appIdClientRegex: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - }, - required: ['userPoolId'], - }, - lambdaAuth: { - type: 'object', - oneOf: [{ $ref: '#/definitions/lambdaFunctionConfig' }], - properties: { - // Note: functionName and functionArn are already defined in #/definitions/lambdaFunctionConfig - // But if not also defined here, TypeScript shows an error. - functionName: { type: 'string' }, - functionArn: { type: 'string' }, - identityValidationExpression: { type: 'string' }, - authorizerResultTtlInSeconds: { type: 'number' }, - }, - required: [], - }, - oidcAuth: { - type: 'object', - properties: { - issuer: { type: 'string' }, - clientId: { type: 'string' }, - iatTTL: { type: 'number' }, - authTTL: { type: 'number' }, - }, - required: ['issuer'], - }, - iamAuth: { - type: 'object', - properties: { - type: { - type: 'string', - const: 'AWS_IAM', - }, - }, - required: ['type'], - errorMessage: 'must be a valid IAM config', - }, - apiKeyAuth: { - type: 'object', - properties: { - type: { - type: 'string', - const: 'API_KEY', - }, - }, - required: ['type'], - errorMessage: 'must be a valid API_KEY config', - }, - visibilityConfig: { - type: 'object', - properties: { - cloudWatchMetricsEnabled: { type: 'boolean' }, - name: { type: 'string' }, - sampledRequestsEnabled: { type: 'boolean' }, - }, - required: [], - }, - wafRule: { - anyOf: [ - { type: 'string', enum: ['throttle', 'disableIntrospection'] }, - { - type: 'object', - properties: { - disableIntrospection: { - type: 'object', - properties: { - name: { type: 'string' }, - priority: { type: 'integer' }, - visibilityConfig: { $ref: '#/definitions/visibilityConfig' }, - }, - }, - }, - required: ['disableIntrospection'], - }, - { - type: 'object', - properties: { - throttle: { - oneOf: [ - { type: 'integer', minimum: 100 }, - { - type: 'object', - properties: { - name: { type: 'string' }, - action: { - type: 'string', - enum: ['Allow', 'Block'], - }, - aggregateKeyType: { - type: 'string', - enum: ['IP', 'FORWARDED_IP'], - }, - limit: { type: 'integer', minimum: 100 }, - priority: { type: 'integer' }, - scopeDownStatement: { type: 'object' }, - forwardedIPConfig: { - type: 'object', - properties: { - headerName: { - type: 'string', - pattern: '^[a-zA-Z0-9-]+$', - }, - fallbackBehavior: { - type: 'string', - enum: ['MATCH', 'NO_MATCH'], - }, - }, - required: ['headerName', 'fallbackBehavior'], - }, - visibilityConfig: { - $ref: '#/definitions/visibilityConfig', - }, - }, - required: [], - }, - ], - }, - }, - required: ['throttle'], - }, - { $ref: '#/definitions/customWafRule' }, - ], - errorMessage: 'must be a valid WAF rule', - }, - customWafRule: { - type: 'object', - properties: { - name: { type: 'string' }, - priority: { type: 'number' }, - action: { - type: 'string', - enum: ['Allow', 'Block', 'Count', 'Captcha'], - }, - statement: { type: 'object', required: [] }, - visibilityConfig: { $ref: '#/definitions/visibilityConfig' }, - }, - required: ['name', 'statement'], - }, - substitutions: { - type: 'object', - additionalProperties: { - $ref: '#/definitions/stringOrIntrinsicFunction', - }, - required: [], - errorMessage: 'must be a valid substitutions definition', - }, - environment: { - type: 'object', - additionalProperties: { - $ref: '#/definitions/stringOrIntrinsicFunction', - }, - required: [], - errorMessage: 'must be a valid environment definition', - }, - dataSource: { - if: { type: 'object' }, - then: { $ref: '#/definitions/dataSourceConfig' }, - else: { - type: 'string', - errorMessage: 'must be a string or data source definition', - }, - }, - resolverConfig: { - type: 'object', - properties: { - kind: { - type: 'string', - enum: ['UNIT', 'PIPELINE'], - errorMessage: 'must be "UNIT" or "PIPELINE"', - }, - type: { type: 'string' }, - field: { type: 'string' }, - maxBatchSize: { type: 'number', minimum: 1, maximum: 2000 }, - code: { type: 'string' }, - request: { type: 'string' }, - response: { type: 'string' }, - sync: { $ref: '#/definitions/syncConfig' }, - substitutions: { $ref: '#/definitions/substitutions' }, - caching: { $ref: '#/definitions/resolverCachingConfig' }, - }, - if: { properties: { kind: { const: 'UNIT' } }, required: ['kind'] }, - then: { - properties: { - dataSource: { $ref: '#/definitions/dataSource' }, - }, - required: ['dataSource'], - }, - else: { - properties: { - functions: { - type: 'array', - items: { $ref: '#/definitions/pipelineFunction' }, - }, - }, - required: ['functions'], - }, - required: [], - }, - resolverConfigMap: { - type: 'object', - patternProperties: { - // Type.field keys, type and field are not required - '^[_A-Za-z][_0-9A-Za-z]*\\.[_A-Za-z][_0-9A-Za-z]*$': { - $ref: '#/definitions/resolverConfig', - }, - }, - additionalProperties: { - // Other keys, type and field are required - $merge: { - source: { $ref: '#/definitions/resolverConfig' }, - with: { required: ['type', 'field'] }, - }, - errorMessage: { - required: { - type: 'resolver definitions that do not specify Type.field in the key must specify the type and field as properties', - field: - 'resolver definitions that do not specify Type.field in the key must specify the type and field as properties', - }, - }, - }, - required: [], - }, - pipelineFunctionConfig: { - type: 'object', - properties: { - dataSource: { $ref: '#/definitions/dataSource' }, - description: { type: 'string' }, - request: { type: 'string' }, - response: { type: 'string' }, - sync: { $ref: '#/definitions/syncConfig' }, - maxBatchSize: { type: 'number', minimum: 1, maximum: 2000 }, - substitutions: { $ref: '#/definitions/substitutions' }, - }, - required: ['dataSource'], - }, - pipelineFunction: { - if: { type: 'object' }, - then: { $ref: '#/definitions/pipelineFunctionConfig' }, - else: { - type: 'string', - errorMessage: 'must be a string or function definition', - }, - }, - pipelineFunctionConfigMap: { - type: 'object', - additionalProperties: { - if: { type: 'object' }, - then: { $ref: '#/definitions/pipelineFunctionConfig' }, - else: { - type: 'string', - errorMessage: 'must be a string or an object', - }, - }, - required: [], - }, - resolverCachingConfig: { - oneOf: [ - { type: 'boolean' }, - { - type: 'object', - properties: { - ttl: { type: 'integer', minimum: 1, maximum: 3600 }, - keys: { - type: 'array', - items: { type: 'string' }, - }, - }, - required: [], - }, - ], - errorMessage: 'must be a valid resolver caching config', - }, - syncConfig: { - type: 'object', - if: { properties: { conflictHandler: { const: ['LAMBDA'] } } }, - then: { $ref: '#/definitions/lambdaFunctionConfig' }, - properties: { - functionArn: { type: 'string' }, - functionName: { type: 'string' }, - conflictDetection: { type: 'string', enum: ['VERSION', 'NONE'] }, - conflictHandler: { - type: 'string', - enum: ['LAMBDA', 'OPTIMISTIC_CONCURRENCY', 'AUTOMERGE'], - }, - }, - required: [], - }, - iamRoleStatements: { - type: 'array', - items: { - type: 'object', - properties: { - Effect: { type: 'string', enum: ['Allow', 'Deny'] }, - Action: { type: 'array', items: { type: 'string' } }, - Resource: { - oneOf: [ - { $ref: '#/definitions/stringOrIntrinsicFunction' }, - { - type: 'array', - items: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - }, - ], - errorMessage: 'contains invalid resolver definitions', - }, - }, - required: ['Effect', 'Action', 'Resource'], - errorMessage: 'must be a valid IAM role statement', - }, - }, - dataSourceConfig: { - type: 'object', - properties: { - type: { - type: 'string', - enum: DATASOURCE_TYPES, - errorMessage: `must be one of ${DATASOURCE_TYPES.join(', ')}`, - }, - description: { type: 'string' }, - }, - if: { properties: { type: { const: 'AMAZON_DYNAMODB' } } }, - then: { - properties: { config: { $ref: '#/definitions/dataSourceDynamoDb' } }, - required: ['config'], - }, - else: { - if: { properties: { type: { const: 'AWS_LAMBDA' } } }, - then: { - properties: { - config: { $ref: '#/definitions/datasourceLambdaConfig' }, - }, - required: ['config'], - }, - else: { - if: { properties: { type: { const: 'HTTP' } } }, - then: { - properties: { - config: { $ref: '#/definitions/dataSourceHttpConfig' }, - }, - required: ['config'], - }, - else: { - if: { - properties: { - type: { const: 'AMAZON_OPENSEARCH_SERVICE' }, - }, - }, - then: { - properties: { - config: { $ref: '#/definitions/datasourceEsConfig' }, - }, - required: ['config'], - }, - else: { - if: { properties: { type: { const: 'RELATIONAL_DATABASE' } } }, - then: { - properties: { - config: { - $ref: '#/definitions/datasourceRelationalDbConfig', - }, - }, - required: ['config'], - }, - else: { - if: { properties: { type: { const: 'AMAZON_EVENTBRIDGE' } } }, - then: { - properties: { - config: { - $ref: '#/definitions/datasourceEventBridgeConfig', - }, - }, - required: ['config'], - }, - }, - }, - }, - }, - }, - required: ['type'], - }, - dataSourceHttpConfig: { - type: 'object', - properties: { - endpoint: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - serviceRoleArn: { - $ref: '#/definitions/stringOrIntrinsicFunction', - }, - iamRoleStatements: { - $ref: '#/definitions/iamRoleStatements', - }, - authorizationConfig: { - type: 'object', - properties: { - authorizationType: { - type: 'string', - enum: ['AWS_IAM'], - errorMessage: 'must be AWS_IAM', - }, - awsIamConfig: { - type: 'object', - properties: { - signingRegion: { - $ref: '#/definitions/stringOrIntrinsicFunction', - }, - signingServiceName: { - $ref: '#/definitions/stringOrIntrinsicFunction', - }, - }, - required: ['signingRegion'], - }, - }, - required: ['authorizationType', 'awsIamConfig'], - }, - }, - required: ['endpoint'], - }, - dataSourceDynamoDb: { - type: 'object', - properties: { - tableName: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - useCallerCredentials: { type: 'boolean' }, - serviceRoleArn: { - $ref: '#/definitions/stringOrIntrinsicFunction', - }, - region: { - $ref: '#/definitions/stringOrIntrinsicFunction', - }, - iamRoleStatements: { - $ref: '#/definitions/iamRoleStatements', - }, - versioned: { type: 'boolean' }, - deltaSyncConfig: { - type: 'object', - properties: { - deltaSyncTableName: { type: 'string' }, - baseTableTTL: { type: 'integer' }, - deltaSyncTableTTL: { type: 'integer' }, - }, - required: ['deltaSyncTableName'], - }, - }, - required: ['tableName'], - }, - datasourceRelationalDbConfig: { - type: 'object', - properties: { - region: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - relationalDatabaseSourceType: { - type: 'string', - enum: ['RDS_HTTP_ENDPOINT'], - }, - serviceRoleArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - dbClusterIdentifier: { - $ref: '#/definitions/stringOrIntrinsicFunction', - }, - databaseName: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - schema: { type: 'string' }, - awsSecretStoreArn: { - $ref: '#/definitions/stringOrIntrinsicFunction', - }, - iamRoleStatements: { - $ref: '#/definitions/iamRoleStatements', - }, - }, - required: ['awsSecretStoreArn', 'dbClusterIdentifier'], - }, - datasourceLambdaConfig: { - type: 'object', - oneOf: [ - { - $ref: '#/definitions/lambdaFunctionConfig', - }, - ], - properties: { - functionName: { type: 'string' }, - functionArn: { - $ref: '#/definitions/stringOrIntrinsicFunction', - }, - serviceRoleArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - iamRoleStatements: { $ref: '#/definitions/iamRoleStatements' }, - }, - required: [], - }, - datasourceEsConfig: { - type: 'object', - oneOf: [ - { - oneOf: [ - { - type: 'object', - properties: { - endpoint: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - }, - required: ['endpoint'], - }, - { - type: 'object', - properties: { - domain: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - }, - required: ['domain'], - }, - ], - errorMessage: 'must have a endpoint or domain (but not both)', - }, - ], - properties: { - endpoint: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - domain: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - region: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - serviceRoleArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - iamRoleStatements: { $ref: '#/definitions/iamRoleStatements' }, - }, - required: [], - }, - datasourceEventBridgeConfig: { - type: 'object', - properties: { - eventBusArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - }, - required: ['eventBusArn'], - }, + definitions, + properties: { + ...commonProperties, + apiId: { type: 'string' }, }, + required: ['apiId'], + additionalProperties: { + not: true, + errorMessage: 'invalid (unknown) property', + }, +}; + +export const fullAppSyncSchema = { + type: 'object', + definitions, properties: { - name: { type: 'string' }, - authentication: { $ref: '#/definitions/auth' }, - schema: { - anyOf: [ - { - type: 'string', - }, - { - type: 'array', - items: { type: 'string' }, - }, - ], - errorMessage: 'must be a valid schema config', - }, - domain: { - type: 'object', - properties: { - enabled: { type: 'boolean' }, - useCloudFormation: { type: 'boolean' }, - retain: { type: 'boolean' }, - name: { - type: 'string', - pattern: '^([a-z][a-z0-9+-]*\\.)+[a-z][a-z0-9]*$', - errorMessage: 'must be a valid domain name', - }, - certificateArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - hostedZoneId: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - hostedZoneName: { - type: 'string', - pattern: '^([a-z][a-z0-9+-]*\\.)+[a-z][a-z0-9]*\\.$', - errorMessage: - 'must be a valid zone name. Note: you must include a trailing dot (eg: `example.com.`)', - }, - route53: { type: 'boolean' }, - }, - required: ['name'], - if: { - anyOf: [ - { - not: { properties: { useCloudFormation: { const: false } } }, - }, - { not: { required: ['useCloudFormation'] } }, - ], - }, - then: { - anyOf: [ - { required: ['certificateArn'] }, - { required: ['hostedZoneId'] }, - ], - errorMessage: - 'when using CloudFormation, you must provide either certificateArn or hostedZoneId.', - }, - }, - xrayEnabled: { type: 'boolean' }, - visibility: { - type: 'string', - enum: ['GLOBAL', 'PRIVATE'], - errorMessage: 'must be "GLOBAL" or "PRIVATE"', - }, - introspection: { type: 'boolean' }, - queryDepthLimit: { type: 'integer', minimum: 1, maximum: 75 }, - resolverCountLimit: { type: 'integer', minimum: 1, maximum: 1000 }, - substitutions: { $ref: '#/definitions/substitutions' }, - environment: { $ref: '#/definitions/environment' }, - waf: { - type: 'object', - properties: { - enabled: { type: 'boolean' }, - }, - if: { - required: ['arn'], - }, - then: { - properties: { - arn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - }, - }, - else: { - properties: { - name: { type: 'string' }, - defaultAction: { - type: 'string', - enum: ['Allow', 'Block'], - errorMessage: "must be 'Allow' or 'Block'", - }, - description: { type: 'string' }, - rules: { - type: 'array', - items: { $ref: '#/definitions/wafRule' }, - }, - }, - required: ['rules'], - }, - }, - tags: { - type: 'object', - additionalProperties: { type: 'string' }, - }, - caching: { - type: 'object', - properties: { - enabled: { type: 'boolean' }, - behavior: { - type: 'string', - enum: ['FULL_REQUEST_CACHING', 'PER_RESOLVER_CACHING'], - errorMessage: - "must be one of 'FULL_REQUEST_CACHING', 'PER_RESOLVER_CACHING'", - }, - type: { - enum: [ - 'SMALL', - 'MEDIUM', - 'LARGE', - 'XLARGE', - 'LARGE_2X', - 'LARGE_4X', - 'LARGE_8X', - 'LARGE_12X', - ], - errorMessage: - "must be one of 'SMALL', 'MEDIUM', 'LARGE', 'XLARGE', 'LARGE_2X', 'LARGE_4X', 'LARGE_8X', 'LARGE_12X'", - }, - ttl: { type: 'integer', minimum: 1, maximum: 3600 }, - atRestEncryption: { type: 'boolean' }, - transitEncryption: { type: 'boolean' }, - }, - required: ['behavior'], - }, - additionalAuthentications: { - type: 'array', - items: { $ref: '#/definitions/auth' }, - }, - apiKeys: { - type: 'array', - items: { - if: { type: 'object' }, - then: { - type: 'object', - properties: { - name: { type: 'string' }, - description: { type: 'string' }, - expiresAfter: { - type: ['string', 'number'], - pattern: `^(\\d+)(${Object.keys(timeUnits).join('|')})?$`, - errorMessage: 'must be a valid duration.', - }, - expiresAt: { - type: 'string', - format: 'date-time', - errorMessage: 'must be a valid date-time', - }, - wafRules: { - type: 'array', - items: { $ref: '#/definitions/wafRule' }, - }, - }, - required: ['name'], - }, - else: { - type: 'string', - }, - }, - }, - logging: { - type: 'object', - properties: { - roleArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - level: { - type: 'string', - enum: ['ALL', 'INFO', 'DEBUG', 'ERROR', 'NONE'], - errorMessage: - "must be one of 'ALL', 'INFO', 'DEBUG', 'ERROR' or 'NONE'", - }, - retentionInDays: { type: 'integer' }, - excludeVerboseContent: { type: 'boolean' }, - enabled: { type: 'boolean' }, - }, - required: ['level'], - }, - dataSources: { - oneOf: [ - { - type: 'object', - additionalProperties: { $ref: '#/definitions/dataSourceConfig' }, - }, - { - type: 'array', - items: { - type: 'object', - additionalProperties: { $ref: '#/definitions/dataSourceConfig' }, - }, - }, - ], - errorMessage: 'contains invalid data source definitions', - }, - resolvers: { - oneOf: [ - { $ref: '#/definitions/resolverConfigMap' }, - { - type: 'array', - items: { $ref: '#/definitions/resolverConfigMap' }, - }, - ], - errorMessage: 'contains invalid resolver definitions', - }, - pipelineFunctions: { - oneOf: [ - { - $ref: '#/definitions/pipelineFunctionConfigMap', - }, - { - type: 'array', - items: { - $ref: '#/definitions/pipelineFunctionConfigMap', - }, - }, - ], - errorMessage: 'contains invalid pipeline function definitions', - }, - esbuild: { - oneOf: [ - { - type: 'object', - }, - { const: false }, - ], - errorMessage: 'must be an esbuild config object or false', - }, + ...commonProperties, + name: prop.name, + authentication: prop.authentication, + schema: prop.schema, + domain: prop.domain, + xrayEnabled: prop.xrayEnabled, + visibility: prop.visibility, + introspection: prop.introspection, + queryDepthLimit: prop.queryDepthLimit, + resolverCountLimit: prop.resolverCountLimit, + environment: prop.environment, + waf: prop.waf, + tags: prop.tags, + caching: prop.caching, + additionalAuthentications: prop.additionalAuthentications, + apiKeys: prop.apiKeys, + logging: prop.logging, + esbuild: prop.esbuild, }, required: ['name', 'authentication'], additionalProperties: { @@ -868,14 +88,38 @@ export const appSyncSchema = { }, }; -const ajv = new Ajv({ allErrors: true, allowUnionTypes: true }); -ajvMergePatch(ajv); -ajvErrors(ajv); -addFormats(ajv); +// const appSyncSchema = { + +// oneOf: [sharedAppSyncSchema, fullAppSyncSchema], +// }; + +const createValidator = (schema: object) => { + const ajv = new Ajv({ allErrors: true, allowUnionTypes: true }); + ajvMergePatch(ajv); + ajvErrors(ajv); + addFormats(ajv); + return ajv.compile(schema); +}; + +const sharedValidator = createValidator(sharedAppSyncSchema); +const fullValidator = createValidator(fullAppSyncSchema); -const validator = ajv.compile(appSyncSchema); export const validateConfig = (data: Record) => { - const isValid = validator(data); + let isValid: boolean; + let validator: ValidateFunction; + + if ('apiId' in data) { + isValid = sharedValidator(data); + validator = sharedValidator; + } else if ('name' in data) { + isValid = fullValidator(data); + validator = fullValidator; + } else { + throw new Error( + 'Invalid configuration: must contain either "apiId" or "name"', + ); + } + if (isValid === false && validator.errors) { throw new AppSyncValidationError( validator.errors diff --git a/src/validation/definitions.ts b/src/validation/definitions.ts new file mode 100644 index 00000000..a8731a44 --- /dev/null +++ b/src/validation/definitions.ts @@ -0,0 +1,656 @@ +export const stringOrIntrinsicFunction = { + oneOf: [ + { type: 'string' }, + { + type: 'object', + required: [], + additionalProperties: true, + }, + ], + errorMessage: 'must be a string or a CloudFormation intrinsic function', +}; +// Depends on stringOrIntrinsicFunction +export const substitutions = { + type: 'object', + additionalProperties: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + required: [], + errorMessage: 'must be a valid substitutions definition', +}; + +export const AUTH_TYPES = [ + 'AMAZON_COGNITO_USER_POOLS', + 'AWS_LAMBDA', + 'OPENID_CONNECT', + 'AWS_IAM', + 'API_KEY', +] as const; + +export const DATASOURCE_TYPES = [ + 'AMAZON_DYNAMODB', + 'AMAZON_OPENSEARCH_SERVICE', + 'AWS_LAMBDA', + 'HTTP', + 'NONE', + 'RELATIONAL_DATABASE', + 'AMAZON_EVENTBRIDGE', +] as const; + +// Depends on stringOrIntrinsicFunction +export const lambdaFunctionConfig = { + oneOf: [ + { + type: 'object', + properties: { + functionName: { type: 'string' }, + functionAlias: { type: 'string' }, + }, + required: ['functionName'], + }, + { + type: 'object', + properties: { + functionArn: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + }, + required: ['functionArn'], + }, + { + type: 'object', + properties: { + function: { type: 'object' }, + }, + required: ['function'], + }, + ], + errorMessage: + 'must specify functionName, functionArn or function (all exclusives)', +}; + +//depends on cognitoAuth, lambdaAuth and oidcAuth +export const auth = { + type: 'object', + title: 'Authentication', + description: 'Authentication type and definition', + properties: { + type: { + type: 'string', + enum: AUTH_TYPES, + errorMessage: `must be one of ${AUTH_TYPES.join(', ')}`, + }, + }, + if: { properties: { type: { const: 'AMAZON_COGNITO_USER_POOLS' } } }, + then: { + properties: { config: { $ref: '#/definitions/cognitoAuth' } }, + required: ['config'], + }, + else: { + if: { properties: { type: { const: 'AWS_LAMBDA' } } }, + then: { + properties: { config: { $ref: '#/definitions/lambdaAuth' } }, + required: ['config'], + }, + else: { + if: { properties: { type: { const: 'OPENID_CONNECT' } } }, + then: { + properties: { config: { $ref: '#/definitions/oidcAuth' } }, + required: ['config'], + }, + }, + }, + required: ['type'], +}; + +// Depends on stringOrIntrinsicFunction +export const cognitoAuth = { + type: 'object', + properties: { + userPoolId: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + awsRegion: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + defaultAction: { + type: 'string', + enum: ['ALLOW', 'DENY'], + errorMessage: 'must be "ALLOW" or "DENY"', + }, + appIdClientRegex: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + }, + required: ['userPoolId'], +}; + +// Depends on lambdaFunctionConfig +export const lambdaAuth = { + type: 'object', + oneOf: [{ $ref: '#/definitions/lambdaFunctionConfig' }], + properties: { + // Note: functionName and functionArn are already defined in #/definitions/lambdaFunctionConfig + // But if not also defined here, TypeScript shows an error. + functionName: { type: 'string' }, + functionArn: { type: 'string' }, + identityValidationExpression: { type: 'string' }, + authorizerResultTtlInSeconds: { type: 'number' }, + }, + required: [], +}; + +export const oidcAuth = { + type: 'object', + properties: { + issuer: { type: 'string' }, + clientId: { type: 'string' }, + iatTTL: { type: 'number' }, + authTTL: { type: 'number' }, + }, + required: ['issuer'], +}; + +export const iamAuth = { + type: 'object', + properties: { + type: { + type: 'string', + const: 'AWS_IAM', + }, + }, + required: ['type'], + errorMessage: 'must be a valid IAM config', +}; + +export const apiKeyAuth = { + type: 'object', + properties: { + type: { + type: 'string', + const: 'API_KEY', + }, + }, + required: ['type'], + errorMessage: 'must be a valid API_KEY config', +}; + +export const visibilityConfig = { + type: 'object', + properties: { + cloudWatchMetricsEnabled: { type: 'boolean' }, + name: { type: 'string' }, + sampledRequestsEnabled: { type: 'boolean' }, + }, + required: [], +}; + +// Depends on visibilityConfig and customWafRule +export const wafRule = { + anyOf: [ + { type: 'string', enum: ['throttle', 'disableIntrospection'] }, + { + type: 'object', + properties: { + disableIntrospection: { + type: 'object', + properties: { + name: { type: 'string' }, + priority: { type: 'integer' }, + visibilityConfig: { $ref: '#/definitions/visibilityConfig' }, + }, + }, + }, + required: ['disableIntrospection'], + }, + { + type: 'object', + properties: { + throttle: { + oneOf: [ + { type: 'integer', minimum: 100 }, + { + type: 'object', + properties: { + name: { type: 'string' }, + action: { + type: 'string', + enum: ['Allow', 'Block'], + }, + aggregateKeyType: { + type: 'string', + enum: ['IP', 'FORWARDED_IP'], + }, + limit: { type: 'integer', minimum: 100 }, + priority: { type: 'integer' }, + scopeDownStatement: { type: 'object' }, + forwardedIPConfig: { + type: 'object', + properties: { + headerName: { + type: 'string', + pattern: '^[a-zA-Z0-9-]+$', + }, + fallbackBehavior: { + type: 'string', + enum: ['MATCH', 'NO_MATCH'], + }, + }, + required: ['headerName', 'fallbackBehavior'], + }, + visibilityConfig: { + $ref: '#/definitions/visibilityConfig', + }, + }, + required: [], + }, + ], + }, + }, + required: ['throttle'], + }, + { $ref: '#/definitions/customWafRule' }, + ], + errorMessage: 'must be a valid WAF rule', +}; +// Depends on visibilityConfig +export const customWafRule = { + type: 'object', + properties: { + name: { type: 'string' }, + priority: { type: 'number' }, + action: { + type: 'string', + enum: ['Allow', 'Block', 'Count', 'Captcha'], + }, + statement: { type: 'object', required: [] }, + visibilityConfig: { $ref: '#/definitions/visibilityConfig' }, + }, + required: ['name', 'statement'], +}; + +// Depends on stringOrIntrinsicFunction +export const environment = { + type: 'object', + additionalProperties: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + required: [], + errorMessage: 'must be a valid environment definition', +}; +// Depends on dataSourceConfig +export const dataSource = { + if: { type: 'object' }, + then: { $ref: '#/definitions/dataSourceConfig' }, + else: { + type: 'string', + errorMessage: 'must be a string or data source definition', + }, +}; +// Depends on substitutions, resolverCachingConfig, dataSource, pipelineFunction +export const resolverConfig = { + type: 'object', + properties: { + kind: { + type: 'string', + enum: ['UNIT', 'PIPELINE'], + errorMessage: 'must be "UNIT" or "PIPELINE"', + }, + type: { type: 'string' }, + field: { type: 'string' }, + maxBatchSize: { type: 'number', minimum: 1, maximum: 2000 }, + code: { type: 'string' }, + request: { type: 'string' }, + response: { type: 'string' }, + sync: { $ref: '#/definitions/syncConfig' }, + substitutions: { $ref: '#/definitions/substitutions' }, + caching: { $ref: '#/definitions/resolverCachingConfig' }, + }, + if: { properties: { kind: { const: 'UNIT' } }, required: ['kind'] }, + then: { + properties: { + dataSource: { $ref: '#/definitions/dataSource' }, + }, + required: ['dataSource'], + }, + else: { + properties: { + functions: { + type: 'array', + items: { $ref: '#/definitions/pipelineFunction' }, + }, + }, + required: ['functions'], + }, + required: [], +}; +// Depends on resolverConfig +export const resolverConfigMap = { + type: 'object', + patternProperties: { + // Type.field keys, type and field are not required + '^[_A-Za-z][_0-9A-Za-z]*\\.[_A-Za-z][_0-9A-Za-z]*$': { + $ref: '#/definitions/resolverConfig', + }, + }, + additionalProperties: { + // Other keys, type and field are required + $merge: { + source: { $ref: '#/definitions/resolverConfig' }, + with: { required: ['type', 'field'] }, + }, + errorMessage: { + required: { + type: 'resolver definitions that do not specify Type.field in the key must specify the type and field as properties', + field: + 'resolver definitions that do not specify Type.field in the key must specify the type and field as properties', + }, + }, + }, + required: [], +}; +// Depends on dataSource +export const pipelineFunctionConfig = { + type: 'object', + properties: { + dataSource: { $ref: '#/definitions/dataSource' }, + description: { type: 'string' }, + request: { type: 'string' }, + response: { type: 'string' }, + sync: { $ref: '#/definitions/syncConfig' }, + maxBatchSize: { type: 'number', minimum: 1, maximum: 2000 }, + substitutions: { $ref: '#/definitions/substitutions' }, + }, + required: ['dataSource'], +}; +// Depends on pipelineFunctionConfig +export const pipelineFunction = { + if: { type: 'object' }, + then: { $ref: '#/definitions/pipelineFunctionConfig' }, + else: { + type: 'string', + errorMessage: 'must be a string or function definition', + }, +}; +// Depends on pipelineFunctionConfig +export const pipelineFunctionConfigMap = { + type: 'object', + additionalProperties: { + if: { type: 'object' }, + then: { $ref: '#/definitions/pipelineFunctionConfig' }, + else: { + type: 'string', + errorMessage: 'must be a string or an object', + }, + }, + required: [], +}; +export const resolverCachingConfig = { + oneOf: [ + { type: 'boolean' }, + { + type: 'object', + properties: { + ttl: { type: 'integer', minimum: 1, maximum: 3600 }, + keys: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: [], + }, + ], + errorMessage: 'must be a valid resolver caching config', +}; +// Depends on lambdaFunctionConfig +export const syncConfig = { + type: 'object', + if: { properties: { conflictHandler: { const: ['LAMBDA'] } } }, + then: { $ref: '#/definitions/lambdaFunctionConfig' }, + properties: { + functionArn: { type: 'string' }, + functionName: { type: 'string' }, + conflictDetection: { type: 'string', enum: ['VERSION', 'NONE'] }, + conflictHandler: { + type: 'string', + enum: ['LAMBDA', 'OPTIMISTIC_CONCURRENCY', 'AUTOMERGE'], + }, + }, + required: [], +}; +// Depends on stringOrIntrinsicFunction +export const iamRoleStatements = { + type: 'array', + items: { + type: 'object', + properties: { + Effect: { type: 'string', enum: ['Allow', 'Deny'] }, + Action: { type: 'array', items: { type: 'string' } }, + Resource: { + oneOf: [ + { $ref: '#/definitions/stringOrIntrinsicFunction' }, + { + type: 'array', + items: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + }, + ], + errorMessage: 'contains invalid resolver definitions', + }, + }, + required: ['Effect', 'Action', 'Resource'], + errorMessage: 'must be a valid IAM role statement', + }, +}; +// Depends on dataSourceDynamoDb, datasourceLambdaConfig, dataSourceHttpConfig, datasourceEsConfig, datasourceRelationalDbConfig and datasourceEventBridgeConfig +export const dataSourceConfig = { + type: 'object', + properties: { + type: { + type: 'string', + enum: DATASOURCE_TYPES, + errorMessage: `must be one of ${DATASOURCE_TYPES.join(', ')}`, + }, + description: { type: 'string' }, + }, + if: { properties: { type: { const: 'AMAZON_DYNAMODB' } } }, + then: { + properties: { config: { $ref: '#/definitions/dataSourceDynamoDb' } }, + required: ['config'], + }, + else: { + if: { properties: { type: { const: 'AWS_LAMBDA' } } }, + then: { + properties: { + config: { $ref: '#/definitions/datasourceLambdaConfig' }, + }, + required: ['config'], + }, + else: { + if: { properties: { type: { const: 'HTTP' } } }, + then: { + properties: { + config: { $ref: '#/definitions/dataSourceHttpConfig' }, + }, + required: ['config'], + }, + else: { + if: { + properties: { + type: { const: 'AMAZON_OPENSEARCH_SERVICE' }, + }, + }, + then: { + properties: { + config: { $ref: '#/definitions/datasourceEsConfig' }, + }, + required: ['config'], + }, + else: { + if: { properties: { type: { const: 'RELATIONAL_DATABASE' } } }, + then: { + properties: { + config: { + $ref: '#/definitions/datasourceRelationalDbConfig', + }, + }, + required: ['config'], + }, + else: { + if: { properties: { type: { const: 'AMAZON_EVENTBRIDGE' } } }, + then: { + properties: { + config: { + $ref: '#/definitions/datasourceEventBridgeConfig', + }, + }, + required: ['config'], + }, + }, + }, + }, + }, + }, + required: ['type'], +}; +// Depends on stringOrIntrinsicFunction and iamRoleStatements +export const dataSourceHttpConfig = { + type: 'object', + properties: { + endpoint: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + serviceRoleArn: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + iamRoleStatements: { + $ref: '#/definitions/iamRoleStatements', + }, + authorizationConfig: { + type: 'object', + properties: { + authorizationType: { + type: 'string', + enum: ['AWS_IAM'], + errorMessage: 'must be AWS_IAM', + }, + awsIamConfig: { + type: 'object', + properties: { + signingRegion: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + signingServiceName: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + }, + required: ['signingRegion'], + }, + }, + required: ['authorizationType', 'awsIamConfig'], + }, + }, + required: ['endpoint'], +}; +// Depends on stringOrIntrinsicFunction and iamRoleStatements +export const dataSourceDynamoDb = { + type: 'object', + properties: { + tableName: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + useCallerCredentials: { type: 'boolean' }, + serviceRoleArn: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + region: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + iamRoleStatements: { + $ref: '#/definitions/iamRoleStatements', + }, + versioned: { type: 'boolean' }, + deltaSyncConfig: { + type: 'object', + properties: { + deltaSyncTableName: { type: 'string' }, + baseTableTTL: { type: 'integer' }, + deltaSyncTableTTL: { type: 'integer' }, + }, + required: ['deltaSyncTableName'], + }, + }, + required: ['tableName'], +}; +// Depends on stringOrIntrinsicFunction and iamRoleStatements +export const datasourceRelationalDbConfig = { + type: 'object', + properties: { + region: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + relationalDatabaseSourceType: { + type: 'string', + enum: ['RDS_HTTP_ENDPOINT'], + }, + serviceRoleArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + dbClusterIdentifier: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + databaseName: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + schema: { type: 'string' }, + awsSecretStoreArn: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + iamRoleStatements: { + $ref: '#/definitions/iamRoleStatements', + }, + }, + required: ['awsSecretStoreArn', 'dbClusterIdentifier'], +}; +// Depends on lambdaFunctionConfig, stringOrIntrinsicFunction and iamRoleStatements +export const datasourceLambdaConfig = { + type: 'object', + oneOf: [ + { + $ref: '#/definitions/lambdaFunctionConfig', + }, + ], + properties: { + functionName: { type: 'string' }, + functionArn: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + serviceRoleArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + iamRoleStatements: { $ref: '#/definitions/iamRoleStatements' }, + }, + required: [], +}; +// Depends on stringOrIntrinsicFunction and iamRoleStatements +export const datasourceEsConfig = { + type: 'object', + oneOf: [ + { + oneOf: [ + { + type: 'object', + properties: { + endpoint: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + }, + required: ['endpoint'], + }, + { + type: 'object', + properties: { + domain: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + }, + required: ['domain'], + }, + ], + errorMessage: 'must have a endpoint or domain (but not both)', + }, + ], + properties: { + endpoint: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + domain: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + region: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + serviceRoleArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + iamRoleStatements: { $ref: '#/definitions/iamRoleStatements' }, + }, + required: [], +}; +// Depends on stringOrIntrinsicFunction +export const datasourceEventBridgeConfig = { + type: 'object', + properties: { + eventBusArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + }, + required: ['eventBusArn'], +}; diff --git a/src/validation/properties.ts b/src/validation/properties.ts new file mode 100644 index 00000000..93f39a1a --- /dev/null +++ b/src/validation/properties.ts @@ -0,0 +1,243 @@ +import { timeUnits } from '../utils.js'; + +export const name = { type: 'string' }; +// Depends on auth +export const authentication = { $ref: '#/definitions/auth' }; +export const schema = { + anyOf: [ + { + type: 'string', + }, + { + type: 'array', + items: { type: 'string' }, + }, + ], + errorMessage: 'must be a valid schema config', +}; +//Depends on stringOrIntrinsicFunction +export const domain = { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + useCloudFormation: { type: 'boolean' }, + retain: { type: 'boolean' }, + name: { + type: 'string', + pattern: '^([a-z][a-z0-9+-]*\\.)+[a-z][a-z0-9]*$', + errorMessage: 'must be a valid domain name', + }, + certificateArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + hostedZoneId: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + hostedZoneName: { + type: 'string', + pattern: '^([a-z][a-z0-9+-]*\\.)+[a-z][a-z0-9]*\\.$', + errorMessage: + 'must be a valid zone name. Note: you must include a trailing dot (eg: `example.com.`)', + }, + route53: { type: 'boolean' }, + }, + required: ['name'], + if: { + anyOf: [ + { + not: { properties: { useCloudFormation: { const: false } } }, + }, + { not: { required: ['useCloudFormation'] } }, + ], + }, + then: { + anyOf: [{ required: ['certificateArn'] }, { required: ['hostedZoneId'] }], + errorMessage: + 'when using CloudFormation, you must provide either certificateArn or hostedZoneId.', + }, +}; +export const xrayEnabled = { type: 'boolean' }; +export const visibility = { + type: 'string', + enum: ['GLOBAL', 'PRIVATE'], + errorMessage: 'must be "GLOBAL" or "PRIVATE"', +}; +export const introspection = { type: 'boolean' }; +export const queryDepthLimit = { type: 'integer', minimum: 1, maximum: 75 }; +export const resolverCountLimit = { + type: 'integer', + minimum: 1, + maximum: 1000, +}; +// Depends on substitutions +export const substitutions = { $ref: '#/definitions/substitutions' }; +// Depends on environment +export const environment = { $ref: '#/definitions/environment' }; +// Depends on stringOrIntrinsicFunction and wafRule +export const waf = { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + }, + if: { + required: ['arn'], + }, + then: { + properties: { + arn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + }, + }, + else: { + properties: { + name: { type: 'string' }, + defaultAction: { + type: 'string', + enum: ['Allow', 'Block'], + errorMessage: "must be 'Allow' or 'Block'", + }, + description: { type: 'string' }, + rules: { + type: 'array', + items: { $ref: '#/definitions/wafRule' }, + }, + }, + required: ['rules'], + }, +}; +export const tags = { + type: 'object', + additionalProperties: { type: 'string' }, +}; +export const caching = { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + behavior: { + type: 'string', + enum: ['FULL_REQUEST_CACHING', 'PER_RESOLVER_CACHING'], + errorMessage: + "must be one of 'FULL_REQUEST_CACHING', 'PER_RESOLVER_CACHING'", + }, + type: { + enum: [ + 'SMALL', + 'MEDIUM', + 'LARGE', + 'XLARGE', + 'LARGE_2X', + 'LARGE_4X', + 'LARGE_8X', + 'LARGE_12X', + ], + errorMessage: + "must be one of 'SMALL', 'MEDIUM', 'LARGE', 'XLARGE', 'LARGE_2X', 'LARGE_4X', 'LARGE_8X', 'LARGE_12X'", + }, + ttl: { type: 'integer', minimum: 1, maximum: 3600 }, + atRestEncryption: { type: 'boolean' }, + transitEncryption: { type: 'boolean' }, + }, + required: ['behavior'], +}; +// Depends on auth +export const additionalAuthentications = { + type: 'array', + items: { $ref: '#/definitions/auth' }, +}; +// Depends on wafRule +export const apiKeys = { + type: 'array', + items: { + if: { type: 'object' }, + then: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + expiresAfter: { + type: ['string', 'number'], + pattern: `^(\\d+)(${Object.keys(timeUnits).join('|')})?$`, + errorMessage: 'must be a valid duration.', + }, + expiresAt: { + type: 'string', + format: 'date-time', + errorMessage: 'must be a valid date-time', + }, + wafRules: { + type: 'array', + items: { $ref: '#/definitions/wafRule' }, + }, + }, + required: ['name'], + }, + else: { + type: 'string', + }, + }, +}; +// Depends on stringOrIntrinsicFunction +export const logging = { + type: 'object', + properties: { + roleArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + level: { + type: 'string', + enum: ['ALL', 'INFO', 'DEBUG', 'ERROR', 'NONE'], + errorMessage: "must be one of 'ALL', 'INFO', 'DEBUG', 'ERROR' or 'NONE'", + }, + retentionInDays: { type: 'integer' }, + excludeVerboseContent: { type: 'boolean' }, + enabled: { type: 'boolean' }, + }, + required: ['level'], +}; +// Depends on dataSourceConfig +export const dataSources = { + oneOf: [ + { + type: 'object', + additionalProperties: { $ref: '#/definitions/dataSourceConfig' }, + }, + { + type: 'array', + items: { + type: 'object', + additionalProperties: { $ref: '#/definitions/dataSourceConfig' }, + }, + }, + ], + errorMessage: 'contains invalid data source definitions', +}; +// Depends on resolverConfigMap +export const resolvers = { + oneOf: [ + { $ref: '#/definitions/resolverConfigMap' }, + { + type: 'array', + items: { $ref: '#/definitions/resolverConfigMap' }, + }, + ], + errorMessage: 'contains invalid resolver definitions', +}; +// Depends on pipelineFunctionConfigMap +export const pipelineFunctions = { + oneOf: [ + { + $ref: '#/definitions/pipelineFunctionConfigMap', + }, + { + type: 'array', + items: { + $ref: '#/definitions/pipelineFunctionConfigMap', + }, + }, + ], + errorMessage: 'contains invalid pipeline function definitions', +}; +// Depends on stringOrIntrinsicFunction +export const esbuild = { + oneOf: [ + { + type: 'object', + }, + { const: false }, + ], + errorMessage: 'must be an esbuild config object or false', +}; +export const apiId = { $ref: '#/definitions/stringOrIntrinsicFunction' }; diff --git a/tsconfig.json b/tsconfig.json index 84c86304..8cf63f7b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,8 +2,8 @@ "compilerOptions": { "esModuleInterop": true, "allowSyntheticDefaultImports": true, - "target": "es5", - "module": "commonjs", + "target": "es2018", + "module": "ESNext", "moduleResolution": "node", "sourceMap": true, "strict": true,