aws-amplify / amplify-backend

Home to all tools related to Amplify's code-first DX (Gen 2) for building fullstack apps on AWS
Apache License 2.0
158 stars 54 forks source link

Lambda functions do not bundle any libraries #1946

Closed jpkraemer closed 1 week ago

jpkraemer commented 1 week ago

Environment information

System:
  OS: macOS 14.6.1
  CPU: (8) arm64 Apple M1
  Memory: 144.66 MB / 16.00 GB
  Shell: /bin/zsh
Binaries:
  Node: 22.6.0 - /opt/homebrew/bin/node
  Yarn: 1.22.17 - /opt/homebrew/bin/yarn
  npm: 10.8.2 - /opt/homebrew/bin/npm
  pnpm: undefined - undefined
NPM Packages:
  @aws-amplify/auth-construct: 1.3.0
  @aws-amplify/backend: 1.2.0
  @aws-amplify/backend-auth: 1.1.3
  @aws-amplify/backend-cli: 1.2.5
  @aws-amplify/backend-data: 1.1.3
  @aws-amplify/backend-deployer: 1.1.0
  @aws-amplify/backend-function: 1.3.4
  @aws-amplify/backend-output-schemas: 1.2.0
  @aws-amplify/backend-output-storage: 1.1.1
  @aws-amplify/backend-secret: 1.1.0
  @aws-amplify/backend-storage: 1.1.2
  @aws-amplify/cli-core: 1.1.2
  @aws-amplify/client-config: 1.3.0
  @aws-amplify/deployed-backend-client: 1.4.0
  @aws-amplify/form-generator: 1.0.1
  @aws-amplify/model-generator: 1.0.5
  @aws-amplify/platform-core: 1.0.7
  @aws-amplify/plugin-types: 1.2.1
  @aws-amplify/sandbox: 1.2.0
  @aws-amplify/schema-generator: 1.2.1
  aws-amplify: 6.5.3
  aws-cdk: 2.155.0
  aws-cdk-lib: 2.155.0
  typescript: 5.5.4
AWS environment variables:
  AWS_STS_REGIONAL_ENDPOINTS = regional
  AWS_NODEJS_CONNECTION_REUSE_ENABLED = 1
  AWS_SDK_LOAD_CONFIG = 1
No CDK environment variables

Describe the bug

I try to use a lambda function that accesses my GraphQL API. This is how my lambda function looks like:

import { Handler } from 'aws-lambda'
import { env } from '$amplify/env/myHandler'
import { Amplify } from 'aws-amplify';
import { generateClient } from 'aws-amplify/data';
import { Schema } from '../../data/resource';

Amplify.configure(
  {
    API: {
      GraphQL: {
        endpoint: env.AMPLIFY_DATA_GRAPHQL_ENDPOINT, // replace with your defineData name
        region: env.AWS_REGION,
        defaultAuthMode: 'identityPool'
      }
    }
  },
  {
    Auth: {
      credentialsProvider: {
        getCredentialsAndIdentityId: async () => ({
          credentials: {
            accessKeyId: env.AWS_ACCESS_KEY_ID,
            secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
            sessionToken: env.AWS_SESSION_TOKEN,
          },
        }),
        clearCredentialsAndIdentityId: () => {
          /* noop */
        },
      },
    },
  }
);

const dataClient = generateClient<Schema>();

export const handler: Handler<{  }> = async (event, context) => {

  await dataClient.models.SOMETHING.create({
    data
  })
}

(Handler abbreviated to conceal the application under development).

When I deploy this, I get an error "Cannot find package 'aws-amplify'" when running the lambda. I cannot for the life of me figure out how to get esbuild to bundle the dependency automatically. Any ideas on how to fix this?

Reproduction steps

see above

dataminion commented 1 week ago

So its wild that you just submitted this I was down the exact same rabbit hole last night.

It's not constructive to your problem but part of me started thinking about if I want lambdas internal to my project hitting the appsync surface. My personal preference for both security and cost would be to go straight to the underlying service I was going to make a comprehensive feature request then I changed my mind about it so I my end to end fake usecase is burning a hole in my pocket.

I think that from an Amplify perspective what I am follows is technically an antipattern but if you are encountering the problem in this ticket it is a solution that worked for us and does leverage the SCHEMA["Object"["Type"] pattern so that the intention of our design is present in the solution.

Imagine we have a Data Object that represents requests to do service on vehicles for different groups in the company.

This is a new feature for most of the company who will use our app but one division in a remote area had an existing system that involved paper requests. Another Technical team made a shim to solve the issue without conflict by using OCR on pictures of the requests and shipping us the outputs in an SQS queue.

The goal is to use the lambda to take the record and merge it into our other records. Beyond the example in this issue Using appSync as the surface doesn't make complete sense here while the record is new to us it isn't really new from a chain of custody perspective we want to preserve the original createdAt time for example without adding additional complexity to our other codebase.

This is very possible to do and is working for us right now on another use case but because of the modeling its alot of code for a relatively simple task. To me it all becomes a non issue if there was an extension of {Schema} that gives you not just a type but an object that could hold a record and have getter hooks to give you the object to you in the format of a different service like dynamo expects

import type { SQSHandler } from "aws-lambda";
import { Logger } from "@aws-lambda-powertools/logger";
import { DynamoDBClient, PutItemCommand, PutItemCommandInput } from "@aws-sdk/client-dynamodb";
import { marshall, marshallOptions } from "@aws-sdk/util-dynamodb";

import { env } from '$amplify/env/function-object-sync-source';
import { Schema } from "../../data/resource";

import { v4 as uuidv4 } from 'uuid';

type EmployeeRequest = Schema['EmployeeRequest']['type'];
const region = env.APP_REGION;
const dynamoTable = env.DYNAMODB_TABLE;
const logger = new Logger({
  logLevel: "DEBUG",
  serviceName: "object-sync-source-handler",
});
const dynamoDbClient = new DynamoDBClient({ logger,region });
export const handler: SQSHandler = async (event) => {
  logger.debug(`Starting: Sync Process`);
    const batchItemFailures = [];
      for (const record of event.Records) {
        try {
          logger.debug(`Received message: ${record.body}`);
          const message = JSON.parse(record.body as string);
          const employeeRequest: EmployeeRequest = {
            id: message.guid as string,
            sourceId: message.id as string,
            request: message.request_ocr as string,
            vehicle: {id: message.vid as string, color: message.vehicle_color as string, type: message.vehicle_type as string},
            organization: {id: message.org_id as number, name: message.org_name as string, type: message.org_type as number},
            scannedAt: message.scanned_at,
            createdAt: message.created_at,
            updatedAt: message.created_at
          }
          await writeToDynamoDB(employeeRequest, dynamoTable);
          logger.info(`EmployeeRequest: ${employeeRequest.sourceId} Has Been Created or Updated`);
        } catch (error) {
          logger.error(`Error processing record ${record.body}: ${error}`);
          batchItemFailures.push({ itemIdentifier: record.messageId });
        }
      }
    return {batchItemFailures}
};

async function writeToDynamoDB(employeeRequest: EmployeeRequest, dynamoTable: string) {
  const uniqueId = uuidv4();
  const options: marshallOptions = {
    removeUndefinedValues: true,
    convertClassInstanceToMap: true
  };
  try {
    if (!employeeRequest.id) {
      employeeRequest.id = uniqueId;
    }
    const putParams: PutItemCommandInput = {
      TableName: dynamoTable,
      Item: {
        "id": { "S": employeeRequest.id },
        "sourceId": { "S": employeeRequest.sourceId as string },
        "__typename": { "S": "EmployeeRequest" },
        "request": { "S": employeeRequest.request as string },
        "vehicle": { "M": marshall(employeeRequest.vehicle, options) },
        "organization": { "M": marshall(employeeRequest.organization, options) },
        "scannedAt": { "S": employeeRequest.scannedAt as string },
        "createdAt": { "S": employeeRequest.createdAt as string },
        "updatedAt": { "S": employeeRequest.createdAt as string }
      }
    };
    logger.debug(`Payload: ${JSON.stringify(putParams)}`);
    const putItemCommand = new PutItemCommand(putParams);
    await dynamoDbClient.send(putItemCommand);
    logger.info(`Successfully wrote record to DynamoDB`);

  } catch (error) {
    logger.error(`Error writing to DynamoDB: ${error}`);
    throw new Error(`Error writing to DynamoDB`);
  }
}

Hopefully this is useful to someone out there

ykethan commented 1 week ago

Hey @jpkraemer, thank you for reaching. What version of esbuild do you have installed? This appears similar to https://github.com/aws-amplify/amplify-backend/issues/1704. It appears,there was a change that modified the default behavior for bundling dependencies in 0.22, but this was reverted in the latest 0.23 release evanw/esbuild@v0.23.0 (release) according to the comment updating the esbuild mitigated the issue.

jpkraemer commented 1 week ago

@ykethan Thanks a lot, that was it! Updated to 0.23 and it worked immediately.

For what it's worth, a workaround that also seemed to work was to create a lambda layer including the libraries - I guess that's not possible to use in conjunction with the esbuild default config now that this is working, though.

@dataminion Thanks for your suggestion as well! I was considering doing something like that but never saw it documented anywhere, so I was guessing it was not how Amplify intended it to work. Nevertheless, knowing how to put data into DynamoDB in the same way that the amplify configured API does it would be super useful. In my case I could just use a DynamoDB Put Action in a step function and skip the lambda altogether.

ykethan commented 1 week ago

@jpkraemer thank you for the confirmation. Closing the issue, do reach out if you are still experiencing this.