Skip to content

Commit

Permalink
feat: Nodejs graphql IAM template (#10997)
Browse files Browse the repository at this point in the history
  • Loading branch information
ykethan authored Nov 3, 2022
1 parent 899fe22 commit 880f7eb
Show file tree
Hide file tree
Showing 9 changed files with 344 additions and 1 deletion.
2 changes: 1 addition & 1 deletion .circleci/enable_api.exp
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ send -- "\r"
expect "Choose the function runtime that you want to use:"
send -- "\r"
expect "Choose the function template that you want to use:"
send -- "1\r"
send -- "2\r"
expect "Choose a DynamoDB data source option"
send -- "j\r"
expect "Provide a friendly name"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const nodeJSTemplateChoices = [
'Hello World',
'Lambda trigger',
'Serverless ExpressJS function (Integration with API Gateway)',
'AppSync - GraphQL API request (with IAM)',
];

const pythonTemplateChoices = ['Hello World'];
Expand Down
175 changes: 175 additions & 0 deletions packages/amplify-e2e-tests/src/__tests__/function_11.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import {
addFunction,
amplifyPush,
createNewProjectDir,
deleteProject,
deleteProjectDir,
getBackendConfig,
initJSProjectWithProfile,
getProjectMeta,
getFunction,
functionBuild,
invokeFunction,
updateApiSchema,
generateRandomShortId,
addApi,

} from '@aws-amplify/amplify-e2e-core';

describe('Lambda AppSync nodejs:', () => {
let projRoot: string;

beforeEach(async () => {
projRoot = await createNewProjectDir('lambda-appsync-nodejs');
});

afterEach(async () => {
await deleteProject(projRoot);
deleteProjectDir(projRoot);
});

it('Test case for when API is not present', async () => {
const projName = `iammodel${generateRandomShortId()}`;

await initJSProjectWithProfile(projRoot, { name: projName });
await addFunction(
projRoot,
{
functionTemplate: 'AppSync - GraphQL API request (with IAM)',
expectFailure: true,
additionalPermissions: {
permissions: ['api'],
choices: ['api'],
resources: ['Test_API'],
operations: ['Query'],
},
},
'nodejs',
);
});

it('Test case for when IAM Auth is not present', async () => {
const projName = `iammodel${generateRandomShortId()}`;

await initJSProjectWithProfile(projRoot, { name: projName });

await addApi(projRoot, {
'API key': {},
});

expect(getBackendConfig(projRoot)).toBeDefined();

await addFunction(
projRoot,
{
functionTemplate: 'AppSync - GraphQL API request (with IAM)',
expectFailure: true,
},
'nodejs',
);
});

it('Test case when IAM is set as default auth', async () => {
const projName = `iammodel${generateRandomShortId()}`;

await initJSProjectWithProfile(projRoot, { name: projName });

await addApi(projRoot, { IAM: {}, transformerVersion: 2 });
await updateApiSchema(projRoot, projName, 'iam_simple_model.graphql');

expect(getBackendConfig(projRoot)).toBeDefined();

const beforeMeta = getBackendConfig(projRoot);
const apiName = Object.keys(beforeMeta.api)[0];

expect(apiName).toBeDefined();

await addFunction(
projRoot,
{
functionTemplate: 'AppSync - GraphQL API request (with IAM)',
additionalPermissions: {
permissions: ['api'],
choices: ['api'],
resources: [apiName],
operations: ['Query'],
},
},
'nodejs',
);

await functionBuild(projRoot, {});
await amplifyPush(projRoot);

const meta = getProjectMeta(projRoot);
const { Arn: functionArn, Name: functionName, Region: region } = Object.keys(meta.function).map(key => meta.function[key])[0].output;

expect(functionArn).toBeDefined();
expect(functionName).toBeDefined();
expect(region).toBeDefined();

const cloudFunction = await getFunction(functionName, region);
expect(cloudFunction.Configuration.FunctionArn).toEqual(functionArn);

const payloadObj = { test: 'test' };
const fnResponse = await invokeFunction(functionName, JSON.stringify(payloadObj), region);

expect(fnResponse.StatusCode).toBe(200);
expect(fnResponse.Payload).toBeDefined();

const gqlResponse = JSON.parse(fnResponse.Payload as string);

expect(gqlResponse.body).toBeDefined();
});

it('Test case for when IAM auth is set as secondary auth type', async () => {
const projName = `iammodel${generateRandomShortId()}`;
await initJSProjectWithProfile(projRoot, { name: projName });
await addApi(projRoot, { transformerVersion: 2, 'API key': {}, IAM: {} });
await updateApiSchema(projRoot, projName, 'iam_simple_model.graphql');

expect(getBackendConfig(projRoot)).toBeDefined();

const beforeMeta = getBackendConfig(projRoot);
const apiName = Object.keys(beforeMeta.api)[0];

expect(apiName).toBeDefined();

await addFunction(
projRoot,
{
functionTemplate: 'AppSync - GraphQL API request (with IAM)',
additionalPermissions: {
permissions: ['api'],
choices: ['api'],
resources: [apiName],
operations: ['Query'],
},
},
'nodejs',
);

await functionBuild(projRoot, {});
await amplifyPush(projRoot);

const meta = getProjectMeta(projRoot);
const { Arn: functionArn, Name: functionName, Region: region } = Object.keys(meta.function).map(key => meta.function[key])[0].output;

expect(functionArn).toBeDefined();
expect(functionName).toBeDefined();
expect(region).toBeDefined();

const cloudFunction = await getFunction(functionName, region);
expect(cloudFunction.Configuration.FunctionArn).toEqual(functionArn);

const payloadObj = { test: 'test' };
const fnResponse = await invokeFunction(functionName, JSON.stringify(payloadObj), region);

expect(fnResponse.StatusCode).toBe(200);
expect(fnResponse.Payload).toBeDefined();

const gqlResponse = JSON.parse(fnResponse.Payload as string);

expect(gqlResponse.body).toBeDefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
{
"name": "GraphQL Lambda Authorizer",
"value": "lambda-auth"
},
{
"name": "AppSync - GraphQL API request (with IAM)",
"value": "appsync-request"
}
]
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"key1": "value1",
"key2": "value2",
"key3": "value3"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<%= props.topLevelComment %>

import crypto from '@aws-crypto/sha256-js';
import { defaultProvider } from '@aws-sdk/credential-provider-node';
import { SignatureV4 } from '@aws-sdk/signature-v4';
import { HttpRequest } from '@aws-sdk/protocol-http';
import { default as fetch, Request } from 'node-fetch';

const GRAPHQL_ENDPOINT = process.env.API_<%= props.providerContext.projectName.toUpperCase() %>_GRAPHQLAPIENDPOINTOUTPUT;
const AWS_REGION = process.env.AWS_REGION || 'us-east-1';
const { Sha256 } = crypto;

const query = /* GraphQL */ `
query LIST_TODOS {
listTodos {
items {
id
name
description
}
}
}
`;

/**
* @type {import('@types/aws-lambda').APIGatewayProxyHandler}
*/

export const handler = async (event) => {
console.log(`EVENT: ${JSON.stringify(event)}`);

const endpoint = new URL(GRAPHQL_ENDPOINT);

const signer = new SignatureV4({
credentials: defaultProvider(),
region: AWS_REGION,
service: 'appsync',
sha256: Sha256
});

const requestToBeSigned = new HttpRequest({
method: 'POST',
headers: {
'Content-Type': 'application/json',
host: endpoint.host
},
hostname: endpoint.host,
body: JSON.stringify({ query }),
path: endpoint.pathname
});

const signed = await signer.sign(requestToBeSigned);
const request = new Request(endpoint, signed);

let statusCode = 200;
let body;
let response;

try {
response = await fetch(request);
body = await response.json();
if (body.errors) statusCode = 400;
} catch (error) {
statusCode = 500;
body = {
errors: [
{
message: error.message
}
]
};
}

return {
statusCode,
// Uncomment below to enable CORS requests
// headers: {
// "Access-Control-Allow-Origin": "*",
// "Access-Control-Allow-Headers": "*"
// },
body: JSON.stringify(body)
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "<%= props.functionName.toLowerCase() %>",
"type": "module",
"version": "2.0.0",
"description": "Lambda function generated by Amplify",
"main": "index.js",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-js": "^2.0.1",
"@aws-sdk/credential-provider-node": "^3.76.0",
"@aws-sdk/protocol-http": "^3.58.0",
"@aws-sdk/signature-v4": "^3.58.0",
"node-fetch": "^3.2.3"
},
"devDependencies": {
"@types/aws-lambda": "^8.10.92"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { provideCrud } from './providers/crudProvider';
import { provideServerless } from './providers/serverlessProvider';
import { provideTrigger } from './providers/triggerProvider';
import { provideLambdaAuth } from './providers/lambdaAuthProvider';
import { graphqlRequest } from './providers/graphqlRequestProvider';

export const functionTemplateContributorFactory: FunctionTemplateContributorFactory = context => {
return {
Expand All @@ -25,6 +26,9 @@ export const functionTemplateContributorFactory: FunctionTemplateContributorFact
case 'lambda-auth': {
return provideLambdaAuth();
}
case 'appsync-request': {
return graphqlRequest(context);
}
default: {
throw new Error(`Unknown template selection [${request.selection}]`);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { FunctionTemplateParameters } from 'amplify-function-plugin-interface';
import path from 'path';
import fs from 'fs-extra';
import {
AmplifySupportedService, exitOnNextTick, $TSContext,
} from 'amplify-cli-core';

import { printer } from 'amplify-prompts';
import { getDstMap } from '../utils/destFileMapper';
import { templateRoot } from '../utils/constants';

const pathToTemplateFilesIAM = path.join(templateRoot, 'lambda', 'appsync-request');

/**
* Graphql request to an AppSync API using Node runtime Lambda function
*/
export async function graphqlRequest(context: $TSContext): Promise<FunctionTemplateParameters> {
const { allResources } = await context.amplify.getResourceStatus('api');

const apiResource = allResources.find((resource: { service: string }) => resource.service === AmplifySupportedService.APPSYNC);

if (!apiResource) {
printer.error(`${AmplifySupportedService.APPSYNC} API does not exist. To add an api, use "amplify add api".`);
exitOnNextTick(0);
}

const AWS_IAM = 'AWS_IAM';
function isIAM(authType: string) {
return authType === AWS_IAM;
}

function isAppSyncWithIAM(config : any) {
const { authConfig } = config.output;
return [authConfig.defaultAuthentication.authenticationType, ...authConfig.additionalAuthenticationProviders.map((provider : any) => provider.authenticationType)].some(isIAM);
}

const iamCheck = isAppSyncWithIAM(apiResource);

if (!iamCheck) {
printer.error(`IAM Auth not enabled for ${AmplifySupportedService.APPSYNC} API. To update an api, use "amplify update api".`);
exitOnNextTick(0);
}

const files = fs.readdirSync(pathToTemplateFilesIAM);
return {
functionTemplate: {
sourceRoot: pathToTemplateFilesIAM,
sourceFiles: files,
defaultEditorFile: path.join('src', 'index.js'),
destMap: getDstMap(files),
},
};
}

0 comments on commit 880f7eb

Please sign in to comment.