diff --git a/__snapshots__/get-config.test.js.snap b/__snapshots__/get-config.test.js.snap index cc71af15..8aa08cfa 100644 --- a/__snapshots__/get-config.test.js.snap +++ b/__snapshots__/get-config.test.js.snap @@ -5,29 +5,27 @@ exports[`authenticationType is missing 1`] = `"appSync property \`authentication exports[`authenticationType is missing 2`] = `"appSync property \`authenticationType\` is missing or invalid."`; exports[`returns valid config 1`] = ` -Array [ - Object { - "apiId": undefined, - "apiKey": undefined, - "authenticationType": "AWS_IAM", - "dataSources": Array [ - Object { - "name": "users", - "type": "AMAZON_DYNAMODB", - }, - Object { - "name": "tweets", - "type": "AMAZON_DYNAMODB", - }, - ], - "isSingleConfig": true, - "logConfig": undefined, - "mappingTemplates": Array [], - "mappingTemplatesLocation": "mapping-templates", - "name": "api", - "openIdConnectConfig": undefined, - "region": "us-east-1", - "schema": "type Mutation { +Object { + "apiId": undefined, + "apiKey": undefined, + "authenticationType": "AWS_IAM", + "dataSources": Array [ + Object { + "name": "users", + "type": "AMAZON_DYNAMODB", + }, + Object { + "name": "tweets", + "type": "AMAZON_DYNAMODB", + }, + ], + "logConfig": undefined, + "mappingTemplates": Array [], + "mappingTemplatesLocation": "mapping-templates", + "name": "api", + "openIdConnectConfig": undefined, + "region": "us-east-1", + "schema": "type Mutation { # Create a tweet for a user # consumer keys and tokens are not required for dynamo integration createTweet( @@ -121,8 +119,7 @@ schema { subscription: Subscription } ", - "substitutions": Object {}, - "userPoolConfig": undefined, - }, -] + "substitutions": Object {}, + "userPoolConfig": undefined, +} `; diff --git a/get-config.js b/get-config.js index 50581444..5a982dbc 100644 --- a/get-config.js +++ b/get-config.js @@ -9,7 +9,7 @@ const objectToArrayWithNameProp = pipe( values, ); -const getConfig = (config, provider, servicePath) => { +module.exports = (config, provider, servicePath) => { if ( !( config.authenticationType === 'API_KEY' || @@ -65,15 +65,3 @@ const getConfig = (config, provider, servicePath) => { substitutions: config.substitutions || {}, }; }; - -module.exports = (config, provider, servicePath) => { - if (!config) { - return []; - } else if (config.constructor === Array) { - return config.map(apiConfig => getConfig(apiConfig, provider, servicePath)); - } else { - const singleConfig = getConfig(config, provider, servicePath); - singleConfig.isSingleConfig = true; - return [singleConfig]; - } -}; diff --git a/index.js b/index.js index 0167b6c9..d01a388a 100644 --- a/index.js +++ b/index.js @@ -7,14 +7,6 @@ const runPlayground = require('./graphql-playground'); const getConfig = require('./get-config'); const MIGRATION_DOCS = 'https://github.com/sid88in/serverless-appsync-plugin/blob/master/README.md#cfn-migration'; -const RESOURCE_API = "GraphQLApi"; -const RESOURCE_API_CLOUDWATCH_LOGS_ROLE = "GraphQlApiCloudWatchLogsRole"; -const RESOURCE_API_KEY = "GraphQlApiKey"; -const RESOURCE_SCHEMA = "GraphQlSchema"; -const RESOURCE_URL = "GraphQlApiUrl"; -const RESOURCE_RESOLVER = "GraphQlResolver"; -const RESOURCE_DATASOURCE = "GraphQlDs"; -const RESOURCE_DATASOURCE_ROLE = "GraphQlDsRole"; class ServerlessAppsyncPlugin { constructor(serverless, options) { @@ -73,7 +65,7 @@ class ServerlessAppsyncPlugin { + `is no longer supported. See ${MIGRATION_DOCS} for more information`); }; this.hooks = { - 'before:deploy:initialize': () => this.validateSchemas(), + 'before:deploy:initialize': () => this.validateSchema(), 'delete-appsync:delete': () => this.deleteGraphQLEndpoint(), 'graphql-playground:run': () => this.runGraphqlPlayground(), 'deploy-appsync:deploy': generateMigrationErrorMessage('deploy-appsync'), @@ -90,8 +82,8 @@ class ServerlessAppsyncPlugin { ); } - getSchemas() { - const config = this.loadConfig(); + getSchema() { + const { schema } = this.loadConfig(); const awsTypes = ` scalar AWSDate @@ -105,20 +97,13 @@ class ServerlessAppsyncPlugin { scalar AWSIPAddress `; - return config.map(apiConfig => `${apiConfig.schema} ${awsTypes}`); + return `${schema} ${awsTypes}`; } - validateSchemas() { - const schemas = this.getSchemas(); - const asts = schemas.map(schema => buildASTSchema(parse(schema))); - const errors = asts.reduce((accumulatedErrors, currentAst) => { - const currentErrors = validateSchema(currentAst); - if (!currentErrors.length) { - return accumulatedErrors; - } else { - return accumulatedErrors.concat(currentErrors || []); - } - }, []); + validateSchema() { + const schema = this.getSchema(); + const ast = buildASTSchema(parse(schema)); + const errors = validateSchema(ast); if (!errors.length) { return; } @@ -126,36 +111,32 @@ class ServerlessAppsyncPlugin { errors.forEach((error) => { this.serverless.cli.log(printError(error)); }); - throw new this.serverless.classes.Error('Cannot proceed invalid graphql SDL in one or more schemas.'); + throw new this.serverless.classes.Error('Cannot proceed invalid graphql SDL'); } deleteGraphQLEndpoint() { const config = this.loadConfig(); - return Promise.all(config.map(apiConfig => { - const { apiId } = apiConfig; - if (!apiId) { - throw new this.serverless.classes.Error('serverless-appsync: no apiId is defined. If you are not ' - + `migrating from a previous version of the plugin this is expected. See ${MIGRATION_DOCS} ` - + 'for more information'); - } + const { apiId } = config; + if (!apiId) { + throw new this.serverless.classes.Error('serverless-appsync: no apiId is defined. If you are not ' + + `migrating from a previous version of the plugin this is expected. See ${MIGRATION_DOCS} ' + + 'for more information`); + } - this.serverless.cli.log('Deleting GraphQL Endpoint...'); - return this.provider - .request('AppSync', 'deleteGraphqlApi', { - apiId, - }) - .then((data) => { - if (data) { - this.serverless.cli.log(`Successfully deleted GraphQL Endpoint: ${apiId}`); - } - }); - })); + this.serverless.cli.log('Deleting GraphQL Endpoint...'); + return this.provider + .request('AppSync', 'deleteGraphqlApi', { + apiId, + }) + .then((data) => { + if (data) { + this.serverless.cli.log(`Successfully deleted GraphQL Endpoint: ${apiId}`); + } + }); } runGraphqlPlayground() { - // Use the first config or config map - const config = this.loadConfig()[0]; - runPlayground(this.serverless.service, this.provider, config, this.options).then((url) => { + runPlayground(this.serverless.service, this.provider, this.loadConfig(), this.options).then((url) => { this.serverless.cli.log(`Graphql Playground Server Running at: ${url}`); }); } @@ -163,35 +144,31 @@ class ServerlessAppsyncPlugin { addResources() { const config = this.loadConfig(); - const resources = this.serverless.service.provider.compiledCloudFormationTemplate.Resources; - const outputs = this.serverless.service.provider.compiledCloudFormationTemplate.Outputs; - - config.forEach(apiConfig => { - if (apiConfig.apiId) { - this.serverless.cli.log('WARNING: serverless-appsync has been updated in a breaking way and your ' - + 'service is configured using a reference to an existing apiKey in ' - + '`custom.appSync` which is used in the legacy deploy scripts. This deploy will create ' - + `new graphql resources and WILL NOT update your existing api. See ${MIGRATION_DOCS} for ` - + 'more information'); - } + if (config.apiId) { + this.serverless.cli.log('WARNING: serverless-appsync has been updated in a breaking way and your ' + + 'service is configured using a reference to an existing apiKey in ' + + '`custom.appSync` which is used in the legacy deploy scripts. This deploy will create ' + + `new graphql resources and WILL NOT update your existing api. See ${MIGRATION_DOCS} for ` + + 'more information'); + } - Object.assign(resources, this.getGraphQlApiEndpointResource(apiConfig)); - Object.assign(resources, this.getApiKeyResources(apiConfig)); - Object.assign(resources, this.getGraphQLSchemaResource(apiConfig)); - Object.assign(resources, this.getCloudWatchLogsRole(apiConfig)); - Object.assign(resources, this.getDataSourceIamRolesResouces(apiConfig)); - Object.assign(resources, this.getDataSourceResources(apiConfig)); - Object.assign(resources, this.getResolverResources(apiConfig)); + const resources = this.serverless.service.provider.compiledCloudFormationTemplate.Resources; + Object.assign(resources, this.getGraphQlApiEndpointResource(config)); + Object.assign(resources, this.getApiKeyResources(config)); + Object.assign(resources, this.getGraphQLSchemaResource(config)); + Object.assign(resources, this.getCloudWatchLogsRole(config)); + Object.assign(resources, this.getDataSourceIamRolesResouces(config)); + Object.assign(resources, this.getDataSourceResources(config)); + Object.assign(resources, this.getResolverResources(config)); - Object.assign(outputs, this.getGraphQlApiOutputs(apiConfig)); - Object.assign(outputs, this.getApiKeyOutputs(apiConfig)); - }); + const outputs = this.serverless.service.provider.compiledCloudFormationTemplate.Outputs; + Object.assign(outputs, this.getGraphQlApiOutputs(config)); + Object.assign(outputs, this.getApiKeyOutputs(config)); } getGraphQlApiEndpointResource(config) { - const cloudWatchLogsRoleLogicalId = this.getLogicalId(config, RESOURCE_API_CLOUDWATCH_LOGS_ROLE); return { - [this.getLogicalId(config, RESOURCE_API)]: { + GraphQlApi: { Type: 'AWS::AppSync::GraphQLApi', Properties: { Name: config.name, @@ -209,8 +186,7 @@ class ServerlessAppsyncPlugin { AuthTTL: config.openIdConnectConfig.authTTL, }, LogConfig: !config.logConfig ? undefined : { - CloudWatchLogsRoleArn: - config.logConfig.loggingRoleArn || { "Fn::GetAtt": [cloudWatchLogsRoleLogicalId, "Arn"] }, + CloudWatchLogsRoleArn: config.logConfig.loggingRoleArn || { "Fn::GetAtt": ["GraphQlApiCloudWatchLogsRole", "Arn"] }, FieldLogLevel: config.logConfig.level, }, }, @@ -223,11 +199,11 @@ class ServerlessAppsyncPlugin { return {}; } return { - [this.getLogicalId(config, RESOURCE_API_KEY)]: { + GraphQlApiKeyDefault: { Type: 'AWS::AppSync::ApiKey', Properties: { - ApiId: { 'Fn::GetAtt': [this.getLogicalId(config, RESOURCE_API), 'ApiId'] }, - Description: `serverless-appsync-plugin: AppSync API Key for ${this.getLogicalId(config, RESOURCE_API_KEY)}`, + ApiId: { 'Fn::GetAtt': ['GraphQlApi', 'ApiId'] }, + Description: 'serverless-appsync-plugin: Default', Expires: Math.floor(Date.now() / 1000) + (365 * 24 * 60 * 60), }, }, @@ -240,7 +216,7 @@ class ServerlessAppsyncPlugin { } return { - [this.getLogicalId(config, RESOURCE_API_CLOUDWATCH_LOGS_ROLE)]: { + "GraphQlApiCloudWatchLogsRole": { Type: 'AWS::IAM::Role', Properties: { "AssumeRolePolicyDocument": { @@ -333,7 +309,6 @@ class ServerlessAppsyncPlugin { }, Policies: [ { - // TODO: Why are inline policies getting fine-grained policy names? There is no benefit to this. PolicyName: this.getDataSourceCfnName(ds.name) + "Policy", PolicyDocument: { Version: "2012-10-17", @@ -343,8 +318,8 @@ class ServerlessAppsyncPlugin { ] } }; - // NOTE: No two AppSync APIs should share datasources. For potential fine-grain access implementation. - return Object.assign({}, acc, { [this.getLogicalId(config, this.getLogicalId(ds, RESOURCE_DATASOURCE_ROLE))]: resource }); + + return Object.assign({}, acc, { [this.getDataSourceCfnName(ds.name) + "Role"]: resource }); }, {}); } @@ -441,22 +416,22 @@ class ServerlessAppsyncPlugin { const resource = { Type: 'AWS::AppSync::DataSource', Properties: { - ApiId: { 'Fn::GetAtt': [this.getLogicalId(config, RESOURCE_API), 'ApiId'] }, + ApiId: { 'Fn::GetAtt': ['GraphQlApi', 'ApiId'] }, Name: ds.name, Description: ds.description, Type: ds.type, }, }; - // If a serviceRoleArn was given for this DataSource, use it + // If a serviceRoleArn was given for this DataAsouce, use it if (ds.config && ds.config.serviceRoleArn) { resource.Properties.ServiceRoleArn = ds.config.serviceRoleArn; } else { - const dataSourceRoleLogicalId = this.getLogicalId(config, this.getLogicalId(ds, RESOURCE_DATASOURCE_ROLE)); + const roleResouceName = this.getDataSourceCfnName(ds.name) + "Role"; // If a Role Resource was generated for this DataSource, use it - const role = this.serverless.service.provider.compiledCloudFormationTemplate.Resources[dataSourceRoleLogicalId]; + const role = this.serverless.service.provider.compiledCloudFormationTemplate.Resources[roleResouceName]; if (role) { - resource.Properties.ServiceRoleArn = { 'Fn::GetAtt': [dataSourceRoleLogicalId, 'Arn'] } + resource.Properties.ServiceRoleArn = { 'Fn::GetAtt': [roleResouceName, 'Arn'] } } } @@ -482,18 +457,17 @@ class ServerlessAppsyncPlugin { } else if (ds.type !== 'NONE') { throw new this.serverless.classes.Error(`Data Source Type not supported: '${ds.type}`); } - // NOTE: No two AppSync APIs should share datasources. For potential fine-grain access implementation. - return Object.assign({}, acc, { [this.getLogicalId(config, this.getLogicalId(ds, RESOURCE_DATASOURCE))]: resource }); + return Object.assign({}, acc, { [this.getDataSourceCfnName(ds.name)]: resource }); }, {}); } getGraphQLSchemaResource(config) { return { - [this.getLogicalId(config, RESOURCE_SCHEMA)]: { + GraphQlSchema: { Type: 'AWS::AppSync::GraphQLSchema', Properties: { Definition: config.schema, - ApiId: { 'Fn::GetAtt': [this.getLogicalId(config, RESOURCE_API), 'ApiId'] }, + ApiId: { 'Fn::GetAtt': ['GraphQlApi', 'ApiId'] }, }, }, }; @@ -507,14 +481,14 @@ class ServerlessAppsyncPlugin { const responseTemplate = fs.readFileSync(respTemplPath, 'utf8'); return Object.assign({}, acc, { - [`${this.getLogicalId(config, `${RESOURCE_RESOLVER}${tpl.type}${tpl.field}`)}`]: { + [`GraphQlResolver${this.getCfnName(tpl.type)}${this.getCfnName(tpl.field)}`]: { Type: 'AWS::AppSync::Resolver', - DependsOn: this.getLogicalId(config, RESOURCE_SCHEMA), + DependsOn: 'GraphQlSchema', Properties: { - ApiId: { 'Fn::GetAtt': [this.getLogicalId(config, RESOURCE_API), 'ApiId'] }, + ApiId: { 'Fn::GetAtt': ['GraphQlApi', 'ApiId'] }, TypeName: tpl.type, FieldName: tpl.field, - DataSourceName: { 'Fn::GetAtt': [this.getLogicalId({name: tpl.dataSource}, RESOURCE_DATASOURCE), 'Name'] }, + DataSourceName: { 'Fn::GetAtt': [this.getDataSourceCfnName(tpl.dataSource), 'Name'] }, RequestMappingTemplate: this.processTemplate(requestTemplate, config), ResponseMappingTemplate: this.processTemplate(responseTemplate, config), }, @@ -523,23 +497,10 @@ class ServerlessAppsyncPlugin { }, {}); } - getLogicalId(config, resourceType) { - // Similar to serverless' implementation of functions - // (e.g. getUser becomes GetUserLambdaFunction for CloudFormation logical ID, - // myService becomes MyServiceGraphQLApi or `MyService${resourceType}`) - if (config.isSingleConfig) { - // This will ensure people with CloudFormation stack dependencies on the previous - // version of the plugin doesn't break their {@code deleteGraphQLEndpoint} functionality - return this.getCfnName(resourceType); - } else { - return this.getCfnName(config.name[0].toUpperCase() + config.name.slice(1) + resourceType); - } - } - - getGraphQlApiOutputs(config) { + getGraphQlApiOutputs() { return { - [this.getLogicalId(config, RESOURCE_URL)]: { - Value: { 'Fn::GetAtt': [this.getLogicalId(config, RESOURCE_API), 'GraphQLUrl'] }, + GraphQlApiUrl: { + Value: { 'Fn::GetAtt': ['GraphQlApi', 'GraphQLUrl'] }, }, }; } @@ -549,8 +510,8 @@ class ServerlessAppsyncPlugin { return {}; } return { - [this.getLogicalId(config, RESOURCE_API_KEY)]: { - Value: { 'Fn::GetAtt': [this.getLogicalId(config, RESOURCE_API_KEY), 'ApiKey'] }, + GraphQlApiKeyDefault: { + Value: { 'Fn::GetAtt': ['GraphQlApiKeyDefault', 'ApiKey'] }, }, }; } diff --git a/index.test.js b/index.test.js index 2598e599..2cbed0c4 100644 --- a/index.test.js +++ b/index.test.js @@ -9,7 +9,7 @@ beforeEach(() => { serverless = new Serverless(); plugin = new ServerlessAppsyncPlugin(serverless, {}); config = { - name: 'myApi', + name: 'api', dataSources: [], region: 'us-east-1', }; @@ -29,7 +29,7 @@ describe("appsync config", () => { const role = plugin.getCloudWatchLogsRole(config); expect(role).toEqual( { - "MyApiGraphQlApiCloudWatchLogsRole": { + "GraphQlApiCloudWatchLogsRole": { Type: 'AWS::IAM::Role', Properties: { "AssumeRolePolicyDocument": { @@ -173,7 +173,7 @@ describe("iamRoleStatements", () => { const roles = plugin.getDataSourceIamRolesResouces(config); expect(roles).toEqual( { - "MyApiLambdaSourceGraphQlDsRole": { + "GraphQlDsLambdaSourceRole": { "Type": "AWS::IAM::Role", "Properties": { "AssumeRolePolicyDocument": { @@ -213,7 +213,7 @@ describe("iamRoleStatements", () => { ], }, }, - "MyApiDynamoDbSourceGraphQlDsRole": { + "GraphQlDsDynamoDbSourceRole": { "Type": "AWS::IAM::Role", "Properties": { "AssumeRolePolicyDocument": { @@ -255,7 +255,7 @@ describe("iamRoleStatements", () => { ], }, }, - "MyApiElasticSearchSourceGraphQlDsRole": { + "GraphQlDsElasticSearchSourceRole": { "Type": "AWS::IAM::Role", "Properties": { "AssumeRolePolicyDocument": { @@ -339,7 +339,7 @@ describe("iamRoleStatements", () => { const roles = plugin.getDataSourceIamRolesResouces(config); expect(roles).toEqual( { - "MyApiLambdaSourceGraphQlDsRole": { + "GraphQlDsLambdaSourceRole": { "Type": "AWS::IAM::Role", "Properties": { "AssumeRolePolicyDocument": { @@ -388,7 +388,7 @@ describe("iamRoleStatements", () => { ], }, }, - "MyApiDynamoDbSourceGraphQlDsRole": { + "GraphQlDsDynamoDbSourceRole": { "Type": "AWS::IAM::Role", "Properties": { "AssumeRolePolicyDocument": { @@ -453,7 +453,7 @@ describe("iamRoleStatements", () => { ], }, }, - "MyApiElasticSearchSourceGraphQlDsRole": { + "GraphQlDsElasticSearchSourceRole": { "Type": "AWS::IAM::Role", "Properties": { "AssumeRolePolicyDocument": {