diff --git a/jest.config.js b/jest.config.js index 63cda1c424f..c2451088759 100644 --- a/jest.config.js +++ b/jest.config.js @@ -45,6 +45,7 @@ module.exports = { // '/packages/amplify-graphiql-explorer', '/packages/amplify-graphql-docs-generator', '/packages/amplify-graphql-function-transformer', + '/packages/amplify-graphql-http-transformer', '/packages/amplify-graphql-types-generator', '/packages/amplify-provider-awscloudformation', '/packages/amplify-storage-simulator', diff --git a/packages/amplify-graphql-http-transformer/.npmignore b/packages/amplify-graphql-http-transformer/.npmignore new file mode 100644 index 00000000000..3ee5d55b0b8 --- /dev/null +++ b/packages/amplify-graphql-http-transformer/.npmignore @@ -0,0 +1,5 @@ +**/__mocks__/** +**/__tests__/** +src +tsconfig.json +tsconfig.tsbuildinfo diff --git a/packages/amplify-graphql-http-transformer/CHANGELOG.md b/packages/amplify-graphql-http-transformer/CHANGELOG.md new file mode 100644 index 00000000000..e4d87c4d45c --- /dev/null +++ b/packages/amplify-graphql-http-transformer/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. diff --git a/packages/amplify-graphql-http-transformer/README.md b/packages/amplify-graphql-http-transformer/README.md new file mode 100644 index 00000000000..bf554501680 --- /dev/null +++ b/packages/amplify-graphql-http-transformer/README.md @@ -0,0 +1,25 @@ +# GraphQL @http Transformer + +# Reference Documentation + +### @http + +The `@http` directive allows you to quickly and easily configure HTTP +resolvers within your AWS AppSync API. + +#### Definition + +```graphql +directive @http(method: HttpMethod = GET, url: String!, headers: [HttpHeader] = []) on FIELD_DEFINITION +enum HttpMethod { + GET + POST + PUT + DELETE + PATCH +} +input HttpHeader { + key: String + value: String +} +``` diff --git a/packages/amplify-graphql-http-transformer/package.json b/packages/amplify-graphql-http-transformer/package.json new file mode 100644 index 00000000000..5eb12ba10c0 --- /dev/null +++ b/packages/amplify-graphql-http-transformer/package.json @@ -0,0 +1,52 @@ +{ + "name": "@aws-amplify/graphql-http-transformer", + "version": "0.1.0", + "description": "Amplify GraphQL @http tranformer", + "repository": { + "type": "git", + "url": "https://github.com/aws-amplify/amplify-cli.git", + "directory": "packages/amplify-graphql-http-transformer" + }, + "author": "Amazon Web Services", + "license": "Apache-2.0", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "keywords": [ + "graphql", + "cloudformation", + "aws", + "amplify" + ], + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "clean": "rimraf ./lib", + "test": "jest" + }, + "dependencies": { + "@aws-amplify/graphql-transformer-core": "0.3.4", + "@aws-amplify/graphql-transformer-interfaces": "1.3.1", + "@aws-cdk/core": "~1.72.0", + "graphql": "^14.5.8", + "graphql-mapping-template": "4.18.1", + "graphql-transformer-common": "4.19.1" + }, + "devDependencies": { + "@aws-cdk/assert": "~1.72.0" + }, + "jest": { + "transform": { + "^.+\\.(ts|tsx)?$": "ts-jest" + }, + "testRegex": "(src/__tests__/.*.test.ts)$", + "moduleFileExtensions": [ + "ts", + "tsx", + "js", + "jsx", + "json", + "node" + ], + "collectCoverage": true + } +} diff --git a/packages/amplify-graphql-http-transformer/src/__tests__/__snapshots__/amplify-graphql-http-transformer.test.ts.snap b/packages/amplify-graphql-http-transformer/src/__tests__/__snapshots__/amplify-graphql-http-transformer.test.ts.snap new file mode 100644 index 00000000000..f40bb4701d7 --- /dev/null +++ b/packages/amplify-graphql-http-transformer/src/__tests__/__snapshots__/amplify-graphql-http-transformer.test.ts.snap @@ -0,0 +1,141 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generates expected VTL 1`] = ` +Object { + "Comment.complexPut.req.vtl": "## [Start] Create request. ** +## START: Manually checking that all non-null arguments are provided either in the query or the body ** +#if( (!$ctx.args.body.userId && !$ctx.args.query.userId) ) +$util.error(\\"An argument you marked as Non-Null is not present in the query nor the body of your request.\\")) +#end +## END: Manually checking that all non-null arguments are provided either in the query or the body ** +#set( $headers = $utils.http.copyHeaders($ctx.request.headers) ) +$util.qr($headers.put(\\"accept-encoding\\", \\"application/json\\")) +$util.qr($headers.put(\\"Content-Type\\", \\"application/json\\")) +$util.qr($headers.put(\\"X-Header\\", \\"X-Header-ValuePut\\")) +{ + \\"version\\": \\"2018-05-29\\", + \\"method\\": \\"PUT\\", + \\"resourcePath\\": \\"/posts/\${ctx.args.params.title}/\${ctx.args.params.id}/\${ctx.source.id}\\", + \\"params\\": { + \\"headers\\": $util.toJson($headers), + \\"query\\": $util.toJson($ctx.args.query), + \\"body\\": $util.toJson($ctx.args.body) + } +} +## [End] Create request. **", + "Comment.complexPut.res.vtl": "## [Start] Process response. ** +#if( $ctx.result.statusCode == 200 || $ctx.result.statusCode == 201 ) + #if( $ctx.result.headers.get(\\"Content-Type\\").toLowerCase().contains(\\"xml\\") ) +$utils.xml.toJsonString($ctx.result.body) + #else +$ctx.result.body + #end +#else +$util.qr($util.appendError($ctx.result.body, $ctx.result.statusCode)) +#end +## [End] Process response. **", + "Comment.content.req.vtl": "## [Start] Create request. ** +#set( $headers = $utils.http.copyHeaders($ctx.request.headers) ) +$util.qr($headers.put(\\"accept-encoding\\", \\"application/json\\")) +$util.qr($headers.put(\\"X-Header\\", \\"X-Header-Value\\")) +{ + \\"version\\": \\"2018-05-29\\", + \\"method\\": \\"GET\\", + \\"resourcePath\\": \\"/ping\\", + \\"params\\": { + \\"headers\\": $util.toJson($headers), + \\"query\\": $util.toJson($ctx.args.query) + } +} +## [End] Create request. **", + "Comment.content.res.vtl": "## [Start] Process response. ** +#if( $ctx.result.statusCode == 200 ) + #if( $ctx.result.headers.get(\\"Content-Type\\").toLowerCase().contains(\\"xml\\") ) +$utils.xml.toJsonString($ctx.result.body) + #else +$ctx.result.body + #end +#else +$util.qr($util.appendError($ctx.result.body, $ctx.result.statusCode)) +#end +## [End] Process response. **", + "Comment.contentDelete.req.vtl": "## [Start] Create request. ** +#set( $headers = $utils.http.copyHeaders($ctx.request.headers) ) +$util.qr($headers.put(\\"accept-encoding\\", \\"application/json\\")) +$util.qr($headers.put(\\"X-Header\\", \\"X-Header-ValueDelete\\")) +{ + \\"version\\": \\"2018-05-29\\", + \\"method\\": \\"DELETE\\", + \\"resourcePath\\": \\"/ping\\", + \\"params\\": { + \\"headers\\": $util.toJson($headers) + } +} +## [End] Create request. **", + "Comment.contentDelete.res.vtl": "## [Start] Process response. ** +#if( $ctx.result.statusCode == 200 ) + #if( $ctx.result.headers.get(\\"Content-Type\\").toLowerCase().contains(\\"xml\\") ) +$utils.xml.toJsonString($ctx.result.body) + #else +$ctx.result.body + #end +#else +$util.qr($util.appendError($ctx.result.body, $ctx.result.statusCode)) +#end +## [End] Process response. **", + "Comment.contentPatch.req.vtl": "## [Start] Create request. ** +#set( $headers = $utils.http.copyHeaders($ctx.request.headers) ) +$util.qr($headers.put(\\"accept-encoding\\", \\"application/json\\")) +$util.qr($headers.put(\\"Content-Type\\", \\"application/json\\")) +$util.qr($headers.put(\\"X-Header\\", \\"X-Header-ValuePatch\\")) +{ + \\"version\\": \\"2018-05-29\\", + \\"method\\": \\"PATCH\\", + \\"resourcePath\\": \\"/ping\\", + \\"params\\": { + \\"headers\\": $util.toJson($headers), + \\"query\\": $util.toJson($ctx.args.query), + \\"body\\": $util.toJson($ctx.args.body) + } +} +## [End] Create request. **", + "Comment.contentPatch.res.vtl": "## [Start] Process response. ** +#if( $ctx.result.statusCode == 200 || $ctx.result.statusCode == 201 ) + #if( $ctx.result.headers.get(\\"Content-Type\\").toLowerCase().contains(\\"xml\\") ) +$utils.xml.toJsonString($ctx.result.body) + #else +$ctx.result.body + #end +#else +$util.qr($util.appendError($ctx.result.body, $ctx.result.statusCode)) +#end +## [End] Process response. **", + "Comment.contentPost.req.vtl": "## [Start] Create request. ** +#set( $headers = $utils.http.copyHeaders($ctx.request.headers) ) +$util.qr($headers.put(\\"accept-encoding\\", \\"application/json\\")) +$util.qr($headers.put(\\"Content-Type\\", \\"application/json\\")) +$util.qr($headers.put(\\"X-Header\\", \\"X-Header-ValuePost\\")) +{ + \\"version\\": \\"2018-05-29\\", + \\"method\\": \\"POST\\", + \\"resourcePath\\": \\"/ping\\", + \\"params\\": { + \\"headers\\": $util.toJson($headers), + \\"query\\": $util.toJson($ctx.args.query), + \\"body\\": $util.toJson($ctx.args.body) + } +} +## [End] Create request. **", + "Comment.contentPost.res.vtl": "## [Start] Process response. ** +#if( $ctx.result.statusCode == 200 || $ctx.result.statusCode == 201 ) + #if( $ctx.result.headers.get(\\"Content-Type\\").toLowerCase().contains(\\"xml\\") ) +$utils.xml.toJsonString($ctx.result.body) + #else +$ctx.result.body + #end +#else +$util.qr($util.appendError($ctx.result.body, $ctx.result.statusCode)) +#end +## [End] Process response. **", +} +`; diff --git a/packages/amplify-graphql-http-transformer/src/__tests__/amplify-graphql-http-transformer.test.ts b/packages/amplify-graphql-http-transformer/src/__tests__/amplify-graphql-http-transformer.test.ts new file mode 100644 index 00000000000..0b2571d3dcd --- /dev/null +++ b/packages/amplify-graphql-http-transformer/src/__tests__/amplify-graphql-http-transformer.test.ts @@ -0,0 +1,398 @@ +'use strict'; +import { anything, countResources, expect as cdkExpect, haveResource } from '@aws-cdk/assert'; +import { GraphQLTransform } from '@aws-amplify/graphql-transformer-core'; +import { parse } from 'graphql'; +import { HttpTransformer } from '..'; + +test('generates expected VTL', () => { + const validSchema = ` + type Comment { + id: ID! + content: String @http(url: "https://www.api.com/ping", headers: [{key: "X-Header", value: "X-Header-Value"}]) + contentDelete: String @http(method: DELETE, url: "https://www.api.com/ping", headers: [{key: "X-Header", value: "X-Header-ValueDelete"}]) + contentPatch: String @http(method: PATCH, url: "https://www.api.com/ping", headers: [{key: "X-Header", value: "X-Header-ValuePatch"}]) + contentPost: String @http(method: POST, url: "https://www.api.com/ping", headers: [{key: "X-Header", value: "X-Header-ValuePost"}]) + complexPut( + id: Int!, + title: String!, + body: String, + userId: Int! + ): String @http(method: PUT, url: "https://jsonplaceholder.typicode.com/posts/:title/:id/\${ctx.source.id}", headers: [{key: "X-Header", value: "X-Header-ValuePut"}]) + } + `; + const transformer = new GraphQLTransform({ + transformers: [new HttpTransformer()], + }); + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + expect(out.stacks).toBeDefined(); + expect(out.pipelineFunctions).toMatchSnapshot(); + parse(out.schema); +}); + +test('it generates the expected resources', () => { + const validSchema = ` + type Comment { + id: ID! + content: String @http(method: POST, url: "http://www.api.com/ping") + content2: String @http(method: PUT, url: "http://www.api.com/ping") + more: String @http(url: "http://api.com/ping/me/2") + evenMore: String @http(method: DELETE, url: "http://www.google.com/query/id") + stillMore: String @http(method: PATCH, url: "https://www.api.com/ping/id") + } + `; + const transformer = new GraphQLTransform({ + transformers: [new HttpTransformer()], + }); + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + expect(out.stacks).toBeDefined(); + parse(out.schema); + const stack = out.stacks.HttpDirectiveStack; + cdkExpect(stack).to( + haveResource('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Statement: [ + { + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + Service: 'appsync.amazonaws.com', + }, + }, + ], + Version: '2012-10-17', + }, + }), + ); + cdkExpect(stack).to(countResources('AWS::AppSync::DataSource', 4)); + cdkExpect(stack).to( + haveResource('AWS::AppSync::DataSource', { + ApiId: { Ref: anything() }, + Name: 'httpwwwapicomDataSource', + Type: 'HTTP', + HttpConfig: { + Endpoint: 'http://www.api.com', + }, + ServiceRoleArn: { + 'Fn::GetAtt': [anything(), 'Arn'], + }, + }), + ); + cdkExpect(stack).to( + haveResource('AWS::AppSync::DataSource', { + ApiId: { Ref: anything() }, + Name: 'httpapicomDataSource', + Type: 'HTTP', + HttpConfig: { + Endpoint: 'http://api.com', + }, + ServiceRoleArn: { + 'Fn::GetAtt': [anything(), 'Arn'], + }, + }), + ); + cdkExpect(stack).to( + haveResource('AWS::AppSync::DataSource', { + ApiId: { Ref: anything() }, + Name: 'httpwwwgooglecomDataSource', + Type: 'HTTP', + HttpConfig: { + Endpoint: 'http://www.google.com', + }, + ServiceRoleArn: { + 'Fn::GetAtt': [anything(), 'Arn'], + }, + }), + ); + cdkExpect(stack).to( + haveResource('AWS::AppSync::DataSource', { + ApiId: { Ref: anything() }, + Name: 'httpswwwapicomDataSource', + Type: 'HTTP', + HttpConfig: { + Endpoint: 'https://www.api.com', + }, + ServiceRoleArn: { + 'Fn::GetAtt': [anything(), 'Arn'], + }, + }), + ); + cdkExpect(stack).to(countResources('AWS::AppSync::Resolver', 5)); + expect(stack.Resources!.commentContentResolver).toBeTruthy(); + cdkExpect(stack).to( + haveResource('AWS::AppSync::Resolver', { + ApiId: { Ref: anything() }, + FieldName: 'content', + TypeName: 'Comment', + DataSourceName: { + 'Fn::GetAtt': [anything(), 'Name'], + }, + Kind: 'UNIT', + RequestMappingTemplateS3Location: { + 'Fn::Join': ['', ['s3://', { Ref: anything() }, '/', { Ref: anything() }, '/pipelineFunctions/Comment.content.req.vtl']], + }, + ResponseMappingTemplateS3Location: { + 'Fn::Join': ['', ['s3://', { Ref: anything() }, '/', { Ref: anything() }, '/pipelineFunctions/Comment.content.res.vtl']], + }, + }), + ); + expect(stack.Resources!.commentContent2Resolver).toBeTruthy(); + cdkExpect(stack).to( + haveResource('AWS::AppSync::Resolver', { + ApiId: { Ref: anything() }, + FieldName: 'content2', + TypeName: 'Comment', + DataSourceName: { + 'Fn::GetAtt': [anything(), 'Name'], + }, + Kind: 'UNIT', + RequestMappingTemplateS3Location: { + 'Fn::Join': ['', ['s3://', { Ref: anything() }, '/', { Ref: anything() }, '/pipelineFunctions/Comment.content2.req.vtl']], + }, + ResponseMappingTemplateS3Location: { + 'Fn::Join': ['', ['s3://', { Ref: anything() }, '/', { Ref: anything() }, '/pipelineFunctions/Comment.content2.res.vtl']], + }, + }), + ); + expect(stack.Resources!.commentMoreResolver).toBeTruthy(); + cdkExpect(stack).to( + haveResource('AWS::AppSync::Resolver', { + ApiId: { Ref: anything() }, + FieldName: 'more', + TypeName: 'Comment', + DataSourceName: { + 'Fn::GetAtt': [anything(), 'Name'], + }, + Kind: 'UNIT', + RequestMappingTemplateS3Location: { + 'Fn::Join': ['', ['s3://', { Ref: anything() }, '/', { Ref: anything() }, '/pipelineFunctions/Comment.more.req.vtl']], + }, + ResponseMappingTemplateS3Location: { + 'Fn::Join': ['', ['s3://', { Ref: anything() }, '/', { Ref: anything() }, '/pipelineFunctions/Comment.more.res.vtl']], + }, + }), + ); + expect(stack.Resources!.commentEvenMoreResolver).toBeTruthy(); + cdkExpect(stack).to( + haveResource('AWS::AppSync::Resolver', { + ApiId: { Ref: anything() }, + FieldName: 'evenMore', + TypeName: 'Comment', + DataSourceName: { + 'Fn::GetAtt': [anything(), 'Name'], + }, + Kind: 'UNIT', + RequestMappingTemplateS3Location: { + 'Fn::Join': ['', ['s3://', { Ref: anything() }, '/', { Ref: anything() }, '/pipelineFunctions/Comment.evenMore.req.vtl']], + }, + ResponseMappingTemplateS3Location: { + 'Fn::Join': ['', ['s3://', { Ref: anything() }, '/', { Ref: anything() }, '/pipelineFunctions/Comment.evenMore.res.vtl']], + }, + }), + ); + expect(stack.Resources!.commentStillMoreResolver).toBeTruthy(); + cdkExpect(stack).to( + haveResource('AWS::AppSync::Resolver', { + ApiId: { Ref: anything() }, + FieldName: 'stillMore', + TypeName: 'Comment', + DataSourceName: { + 'Fn::GetAtt': [anything(), 'Name'], + }, + Kind: 'UNIT', + RequestMappingTemplateS3Location: { + 'Fn::Join': ['', ['s3://', { Ref: anything() }, '/', { Ref: anything() }, '/pipelineFunctions/Comment.stillMore.req.vtl']], + }, + ResponseMappingTemplateS3Location: { + 'Fn::Join': ['', ['s3://', { Ref: anything() }, '/', { Ref: anything() }, '/pipelineFunctions/Comment.stillMore.res.vtl']], + }, + }), + ); +}); + +test('URL params happy path', () => { + const validSchema = ` + type Comment { + id: ID! + title: String + complex: CompObj @http(method: GET, url: "https://jsonplaceholder.typicode.com/posts/1") + complexAgain: CompObj @http(url: "https://jsonplaceholder.typicode.com/posts/2") + complexPost( + id: Int, + title: String, + body: String, + userId: Int + ): CompObj @http(method: POST, url: "https://jsonplaceholder.typicode.com/posts") + complexPut( + id: Int!, + title: String!, + body: String, + userId: Int! + ): CompObj @http(method: PUT, url: "https://jsonplaceholder.typicode.com/posts/:title/:id") + deleter: String @http(method: DELETE, url: "https://jsonplaceholder.typicode.com/posts/3") + complexGet( + id: Int! + ): CompObj @http(url: "https://jsonplaceholder.typicode.com/posts/:id") + complexGet2 ( + id: Int!, + title: String!, + userId: Int! + ): CompObj @http(url: "https://jsonplaceholder.typicode.com/posts/:title/:id") + } + type CompObj { + userId: Int + id: Int + title: String + body: String + } + `; + const transformer = new GraphQLTransform({ + transformers: [new HttpTransformer()], + }); + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + expect(out.stacks).toBeDefined(); + parse(out.schema); + const stack = out.stacks.HttpDirectiveStack; + cdkExpect(stack).to(countResources('AWS::AppSync::DataSource', 1)); + cdkExpect(stack).to(countResources('AWS::AppSync::Resolver', 7)); + expect(stack.Resources!.commentComplexResolver).toBeTruthy(); + expect(stack.Resources!.commentComplexAgainResolver).toBeTruthy(); + expect(stack.Resources!.commentComplexPostResolver).toBeTruthy(); + expect(stack.Resources!.commentComplexPutResolver).toBeTruthy(); + expect(stack.Resources!.commentDeleterResolver).toBeTruthy(); + expect(stack.Resources!.commentComplexGetResolver).toBeTruthy(); + expect(stack.Resources!.commentComplexGet2Resolver).toBeTruthy(); +}); + +test('it throws an error when missing protocol in URL argument', () => { + const validSchema = ` + type Comment { + id: ID! + content: String @http(method: POST, url: "www.api.com/ping") + } + `; + const transformer = new GraphQLTransform({ + transformers: [new HttpTransformer()], + }); + + expect(() => { + transformer.transform(validSchema); + }).toThrow('@http directive at location 56 requires a url parameter that begins with http:// or https://.'); +}); + +test('env on the URI path', () => { + const validSchema = ` + type Comment { + id: ID! + content: String @http(method: POST, url: "http://www.api.com/ping\${env}") + } + `; + const transformer = new GraphQLTransform({ + transformers: [new HttpTransformer()], + }); + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + expect(out.stacks).toBeDefined(); + parse(out.schema); + const stack = out.stacks.HttpDirectiveStack; + const reqTemplate = stack.Resources!.commentContentResolver.Properties.RequestMappingTemplate; + expect(reqTemplate['Fn::Sub']).toBeTruthy(); + expect(reqTemplate['Fn::Sub'][0]).toMatch('"resourcePath": "/ping${env}"'); + expect(reqTemplate['Fn::Sub'][1].env.Ref).toBeTruthy(); +}); + +test('env on the hostname', () => { + const validSchema = ` + type Comment { + id: ID! + content: String @http(method: POST, url: "http://\${env}www.api.com/ping") + content2: String @http(method: PUT, url: "http://\${env}www.api.com/ping") + more: String @http(url: "http://\${env}api.com/ping/me/2") + evenMore: String @http(method: DELETE, url: "http://\${env}www.google.com/query/id") + stillMore: String @http(method: PATCH, url: "https://\${env}www.api.com/ping/id") + } + `; + const transformer = new GraphQLTransform({ + transformers: [new HttpTransformer()], + }); + const out = transformer.transform(validSchema); + expect(out).toBeDefined(); + expect(out.stacks).toBeDefined(); + parse(out.schema); + const stack = out.stacks.HttpDirectiveStack; + cdkExpect(stack).to(countResources('AWS::AppSync::DataSource', 4)); + cdkExpect(stack).to( + haveResource('AWS::AppSync::DataSource', { + Name: 'httpenvwwwapicomDataSource', + Type: 'HTTP', + HttpConfig: { + Endpoint: { + 'Fn::Sub': [ + 'http://${env}www.api.com', + { + env: { + Ref: anything(), + }, + }, + ], + }, + }, + }), + ); + cdkExpect(stack).to( + haveResource('AWS::AppSync::DataSource', { + Name: 'httpenvapicomDataSource', + Type: 'HTTP', + HttpConfig: { + Endpoint: { + 'Fn::Sub': [ + 'http://${env}api.com', + { + env: { + Ref: anything(), + }, + }, + ], + }, + }, + }), + ); + cdkExpect(stack).to( + haveResource('AWS::AppSync::DataSource', { + Name: 'httpenvwwwgooglecomDataSource', + Type: 'HTTP', + HttpConfig: { + Endpoint: { + 'Fn::Sub': [ + 'http://${env}www.google.com', + { + env: { + Ref: anything(), + }, + }, + ], + }, + }, + }), + ); + cdkExpect(stack).to( + haveResource('AWS::AppSync::DataSource', { + Name: 'httpsenvwwwapicomDataSource', + Type: 'HTTP', + HttpConfig: { + Endpoint: { + 'Fn::Sub': [ + 'https://${env}www.api.com', + { + env: { + Ref: anything(), + }, + }, + ], + }, + }, + }), + ); +}); diff --git a/packages/amplify-graphql-http-transformer/src/graphql-http-transformer.ts b/packages/amplify-graphql-http-transformer/src/graphql-http-transformer.ts new file mode 100644 index 00000000000..d1c6524ee4c --- /dev/null +++ b/packages/amplify-graphql-http-transformer/src/graphql-http-transformer.ts @@ -0,0 +1,344 @@ +import { DirectiveWrapper, MappingTemplate, TransformerContractError, TransformerPluginBase } from '@aws-amplify/graphql-transformer-core'; +import { TransformerContextProvider, TransformerSchemaVisitStepContextProvider } from '@aws-amplify/graphql-transformer-interfaces'; +import * as cdk from '@aws-cdk/core'; +import { + DirectiveNode, + FieldDefinitionNode, + InputObjectTypeDefinitionNode, + InputValueDefinitionNode, + InterfaceTypeDefinitionNode, + Kind, + ObjectTypeDefinitionNode, +} from 'graphql'; +import { + HttpResourceIDs, + isScalar, + makeInputValueDefinition, + makeNamedType, + makeNonNullType, + ModelResourceIDs, + ResourceConstants, + unwrapNonNull, +} from 'graphql-transformer-common'; +import { + and, + comment, + compoundExpression, + ifElse, + iff, + obj, + or, + parens, + printBlock, + qref, + raw, + ref, + set, + str, +} from 'graphql-mapping-template'; + +type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + +type HttpHeader = { + key: string; + value: string; +}; + +type HttpDirectiveConfiguration = { + headers: HttpHeader[] | undefined; + method: HttpMethod; + origin: string; + path: string; + queryAndBodyArgs: InputValueDefinitionNode[]; + resolverFieldName: string; + resolverTypeName: string; + supportsBody: boolean; + url: string; +}; + +const SPLIT_URL_REGEX = /(http(s)?:\/\/|www\.)|(\/.*)/g; +const URL_REGEX = /(http(s)?:\/\/)|(\/.*)/g; +const VALID_PROTOCOLS_REGEX = /^http(s)?:\/\//; +const HTTP_DIRECTIVE_STACK = 'HttpDirectiveStack'; +const RESOLVER_VERSION = '2018-05-29'; +const directiveDefinition = /* GraphQL */ ` + directive @http(method: HttpMethod = GET, url: String!, headers: [HttpHeader] = []) on FIELD_DEFINITION + enum HttpMethod { + GET + POST + PUT + DELETE + PATCH + } + input HttpHeader { + key: String + value: String + } +`; + +export class HttpTransformer extends TransformerPluginBase { + private directiveList: HttpDirectiveConfiguration[] = []; + + constructor() { + super('amplify-http-transformer', directiveDefinition); + } + + field = ( + parent: ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode, + definition: FieldDefinitionNode, + directive: DirectiveNode, + context: TransformerSchemaVisitStepContextProvider, + ): void => { + const directiveWrapped = new DirectiveWrapper(directive); + const args = directiveWrapped.getArguments({ + method: 'GET', + path: '', + origin: '', + queryAndBodyArgs: definition.arguments, + resolverTypeName: parent.name.value, + resolverFieldName: definition.name.value, + supportsBody: false, + } as HttpDirectiveConfiguration); + + if (!VALID_PROTOCOLS_REGEX.test(args.url)) { + throw new TransformerContractError( + `@http directive at location ${directive?.loc?.start} requires a url parameter that begins with http:// or https://.`, + ); + } + + args.origin = args.url.replace(URL_REGEX, '$1'); + args.path = args.url.split(SPLIT_URL_REGEX).slice(-2, -1)[0] ?? '/'; + args.supportsBody = args.method === 'POST' || args.method === 'PUT' || args.method === 'PATCH'; + + if (!args.headers) { + args.headers = []; + } else if (!Array.isArray(args.headers)) { + args.headers = [args.headers]; + } + + const newFieldArgsArray: InputValueDefinitionNode[] = []; + let params = args.path.match(/:\w+/g); + + if (params) { + params = params.map(p => p.replace(':', '')); + + // If there are URL parameters, remove them from the array used to + // create the query and body types. + args.queryAndBodyArgs = args.queryAndBodyArgs.filter(arg => { + return isScalar(arg.type) && !(params as string[]).includes(arg.name.value); + }); + + // Replace each URL parameter with $ctx.args.params.parameter_name for + // use in the resolver template. + args.path = args.path.replace(/:\w+/g, (str: string) => { + return `\$\{ctx.args.params.${str.replace(':', '')}\}`; + }); + + const urlParamInputObject = makeUrlParamInputObject(args, params); + context.output.addInput(urlParamInputObject); + newFieldArgsArray.push(makeHttpArgument('params', urlParamInputObject, true)); + } + + if (args.queryAndBodyArgs.length > 0) { + // For GET requests, leave the nullability of the query parameters + // unchanged. For PUT, POST, and PATCH, unwrap any non-nulls. + const name = ModelResourceIDs.HttpQueryInputObjectName(parent.name.value, definition.name.value); + const queryInputObject = makeHttpInputObject(name, args.queryAndBodyArgs, args.method !== 'GET'); + + // If any of the arguments for the query are non-null, then make the + // newly generated type wrapper non-null too (this only really applies + // to GET requests). + const makeNonNull = queryInputObject.fields!.filter(a => a.type.kind === Kind.NON_NULL_TYPE).length > 0; + + context.output.addInput(queryInputObject); + newFieldArgsArray.push(makeHttpArgument('query', queryInputObject, makeNonNull)); + + if (args.supportsBody) { + const name = ModelResourceIDs.HttpBodyInputObjectName(parent.name.value, definition.name.value); + const bodyInputObject = makeHttpInputObject(name, args.queryAndBodyArgs, true); + + context.output.addInput(bodyInputObject); + newFieldArgsArray.push(makeHttpArgument('body', bodyInputObject, makeNonNull)); + } + } + + // Update the field if necessary with the new arguments. + if (newFieldArgsArray.length > 0) { + const updatedField = { + ...definition, + arguments: newFieldArgsArray, + }; + + const mostRecentParent = context.output.getType(parent.name.value) as ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode; + let updatedFieldsInParent = mostRecentParent.fields!.filter(f => f.name.value !== definition.name.value); + updatedFieldsInParent.push(updatedField); + + const updatedParentType = { + ...mostRecentParent, + fields: updatedFieldsInParent, + }; + + context.output.putType(updatedParentType); + } + + this.directiveList.push(args); + }; + + generateResolvers = (context: TransformerContextProvider): void => { + if (this.directiveList.length === 0) { + return; + } + + const stack: cdk.Stack = context.stackManager.createStack(HTTP_DIRECTIVE_STACK); + const env = context.stackManager.getParameter(ResourceConstants.PARAMETERS.Env) as cdk.CfnParameter; + + stack.templateOptions.templateFormatVersion = '2010-09-09'; + stack.templateOptions.description = 'An auto-generated nested stack for the @http directive.'; + + this.directiveList.forEach(directive => { + // Create a new data source if necessary. + const dataSourceId = HttpResourceIDs.HttpDataSourceID(directive.origin); + + if (context.api.getDataSource(dataSourceId) === undefined) { + context.api.addHttpDataSource(dataSourceId, replaceEnv(env, directive.origin), {}, stack); + } + + // Create the GraphQL resolver. + createResolver(stack, dataSourceId, context, directive); + }); + }; +} + +function createResolver(stack: cdk.Stack, dataSourceId: string, context: TransformerContextProvider, config: HttpDirectiveConfiguration) { + const env = context.stackManager.getParameter(ResourceConstants.PARAMETERS.Env) as cdk.CfnParameter; + const { method, supportsBody } = config; + const reqCompoundExpr: any[] = []; + const requestParams: any = { headers: ref('util.toJson($headers)') }; + const parsedHeaders = config.headers!.map(header => qref(`$headers.put("${header.key}", "${header.value}")`)); + + if (method !== 'DELETE') { + requestParams.query = ref('util.toJson($ctx.args.query)'); + } + + if (supportsBody) { + const nonNullArgs = config.queryAndBodyArgs.filter(arg => arg.type.kind === Kind.NON_NULL_TYPE); + + requestParams.body = ref('util.toJson($ctx.args.body)'); + + if (nonNullArgs.length > 0) { + reqCompoundExpr.push( + compoundExpression([ + comment('START: Manually checking that all non-null arguments are provided either in the query or the body'), + iff( + or( + nonNullArgs.map(arg => { + const name = arg.name.value; + + return parens(and([raw(`!$ctx.args.body.${name}`), raw(`!$ctx.args.query.${name}`)])); + }), + ), + ref('util.error("An argument you marked as Non-Null is not present in the query nor the body of your request."))'), + ), + comment('END: Manually checking that all non-null arguments are provided either in the query or the body'), + ]), + ); + } + } + + reqCompoundExpr.push( + set(ref('headers'), ref('utils.http.copyHeaders($ctx.request.headers)')), + qref('$headers.put("accept-encoding", "application/json")'), + ); + + if (supportsBody) { + reqCompoundExpr.push(qref('$headers.put("Content-Type", "application/json")')); + } + + reqCompoundExpr.push( + ...parsedHeaders, + obj({ + version: str(RESOLVER_VERSION), + method: str(method), + resourcePath: str(config.path), + params: obj(requestParams), + }), + ); + + const requestTemplateString = replaceEnv(env, printBlock('Create request')(compoundExpression(reqCompoundExpr))); + const requestMappingTemplate = cdk.Token.isUnresolved(requestTemplateString) + ? MappingTemplate.inlineTemplateFromString(requestTemplateString) + : MappingTemplate.s3MappingTemplateFromString(requestTemplateString, `${config.resolverTypeName}.${config.resolverFieldName}.req.vtl`); + + return context.api.addResolver( + config.resolverTypeName, + config.resolverFieldName, + requestMappingTemplate, + MappingTemplate.s3MappingTemplateFromString( + printBlock('Process response')( + ifElse( + supportsBody ? raw('$ctx.result.statusCode == 200 || $ctx.result.statusCode == 201') : raw('$ctx.result.statusCode == 200'), + ifElse( + ref('ctx.result.headers.get("Content-Type").toLowerCase().contains("xml")'), + ref('utils.xml.toJsonString($ctx.result.body)'), + ref('ctx.result.body'), + ), + ref('util.qr($util.appendError($ctx.result.body, $ctx.result.statusCode))'), + ), + ), + `${config.resolverTypeName}.${config.resolverFieldName}.res.vtl`, + ), + dataSourceId, + undefined, + stack, + ); +} + +function replaceEnv(env: cdk.CfnParameter, value: string): string { + if (!value.includes('${env}')) { + return value; + } + + return cdk.Fn.sub(value, { + env: (env as unknown) as string, + }); +} + +function makeUrlParamInputObject(directive: HttpDirectiveConfiguration, urlParams: string[]): InputObjectTypeDefinitionNode { + return { + kind: 'InputObjectTypeDefinition', + name: { + kind: 'Name', + value: ModelResourceIDs.UrlParamsInputObjectName(directive.resolverTypeName, directive.resolverFieldName), + }, + fields: urlParams.map(param => { + return makeInputValueDefinition(param, makeNonNullType(makeNamedType('String'))); + }), + directives: [], + }; +} + +function makeHttpArgument(name: string, inputType: InputObjectTypeDefinitionNode, makeNonNull: boolean): InputValueDefinitionNode { + const type = makeNonNull ? makeNonNullType(makeNamedType(inputType.name.value)) : makeNamedType(inputType.name.value); + return makeInputValueDefinition(name, type); +} + +function makeHttpInputObject(name: string, argArray: InputValueDefinitionNode[], makeNonNull: boolean): InputObjectTypeDefinitionNode { + // Unwrap all the non-nulls in the argument array if the flag is set. + const fields: InputValueDefinitionNode[] = makeNonNull + ? argArray.map((arg: InputValueDefinitionNode) => { + return { + ...arg, + type: unwrapNonNull(arg.type), + }; + }) + : argArray; + return { + kind: 'InputObjectTypeDefinition', + name: { + kind: 'Name', + value: name, + }, + fields, + directives: [], + }; +} diff --git a/packages/amplify-graphql-http-transformer/src/index.ts b/packages/amplify-graphql-http-transformer/src/index.ts new file mode 100644 index 00000000000..af74a51cceb --- /dev/null +++ b/packages/amplify-graphql-http-transformer/src/index.ts @@ -0,0 +1 @@ +export { HttpTransformer } from './graphql-http-transformer'; diff --git a/packages/amplify-graphql-http-transformer/tsconfig.json b/packages/amplify-graphql-http-transformer/tsconfig.json new file mode 100644 index 00000000000..7dab9a19f90 --- /dev/null +++ b/packages/amplify-graphql-http-transformer/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "references": [ + {"path": "../amplify-graphql-transformer-core"}, + {"path": "../amplify-graphql-transformer-interfaces"}, + {"path": "../graphql-mapping-template"}, + {"path": "../graphql-transformer-common"} + ] +} diff --git a/packages/amplify-graphql-transformer-core/src/index.ts b/packages/amplify-graphql-transformer-core/src/index.ts index 725db4227b0..2627ae4e75d 100644 --- a/packages/amplify-graphql-transformer-core/src/index.ts +++ b/packages/amplify-graphql-transformer-core/src/index.ts @@ -35,3 +35,5 @@ export const getAppSyncServiceExtraDirectives = (): string => { }; export { MappingTemplate } from './cdk-compat'; + +export { TransformerContractError } from './errors'; diff --git a/packages/amplify-provider-awscloudformation/package.json b/packages/amplify-provider-awscloudformation/package.json index 1e69ad140cc..59ad12a9600 100644 --- a/packages/amplify-provider-awscloudformation/package.json +++ b/packages/amplify-provider-awscloudformation/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@aws-amplify/graphql-function-transformer": "0.1.0", + "@aws-amplify/graphql-http-transformer": "0.1.0", "@aws-amplify/graphql-model-transformer": "0.3.4", "@aws-amplify/graphql-transformer-core": "0.3.4", "@aws-amplify/graphql-transformer-interfaces": "1.3.1", diff --git a/packages/amplify-provider-awscloudformation/src/graphql-transformer/transform-graphql-schema.ts b/packages/amplify-provider-awscloudformation/src/graphql-transformer/transform-graphql-schema.ts index ed21e5329ba..65e06ce4b3b 100644 --- a/packages/amplify-provider-awscloudformation/src/graphql-transformer/transform-graphql-schema.ts +++ b/packages/amplify-provider-awscloudformation/src/graphql-transformer/transform-graphql-schema.ts @@ -12,7 +12,7 @@ import { } from '@aws-amplify/graphql-transformer-core'; import { ModelTransformer } from '@aws-amplify/graphql-model-transformer'; import { FunctionTransformer } from '@aws-amplify/graphql-function-transformer'; - +import { HttpTransformer } from '@aws-amplify/graphql-http-transformer'; import { ProviderName as providerName } from '../constants'; import { hashDirectory } from '../upload-appsync-files'; import { writeDeploymentToDisk } from './utils'; @@ -48,6 +48,7 @@ function getTransformerFactory(context, resourceDir) { const transformerList: TransformerPluginProvider[] = [ new ModelTransformer(), new FunctionTransformer(), + new HttpTransformer(), // TODO: initialize transformer plugins ];