Skip to content

Commit

Permalink
fix(sdk): cloud.Api doesn't work on AWS (#3711)
Browse files Browse the repository at this point in the history
Fixes #3710

Previous changes to `cloud.Function` were causing issues for API Gateways because they started returning an object that wrapped the payload instead of a plain payload. To fix this I'm proposing an implementation that avoids the payload wrapping problem.

## Checklist

- [ ] Title matches [Winglang's style guide](https://www.winglang.io/contributing/start-here/pull_requests#how-are-pull-request-titles-formatted)
- [ ] Description explains motivation and solution
- [ ] Tests added (always)
- [ ] Docs updated (only required for features)
- [ ] Added `pr/e2e-full` label if this feature requires end-to-end testing

*By submitting this pull request, I confirm that my contribution is made under the terms of the [Wing Cloud Contribution License](https://github.com/winglang/wing/blob/main/CONTRIBUTION_LICENSE.md)*.
  • Loading branch information
Chriscbr authored Aug 7, 2023
1 parent ebab873 commit dbfedd6
Show file tree
Hide file tree
Showing 12 changed files with 123 additions and 202 deletions.
23 changes: 5 additions & 18 deletions libs/wingsdk/src/cloud/function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { mkdirSync, writeFileSync } from "fs";
import { join } from "path";
import { Construct } from "constructs";
import { fqnForType } from "../constants";
import { Code } from "../core";
import { App } from "../core/app";
import { CaseConventions, ResourceNames } from "../shared/resource-names";
import { Duration } from "../std/duration";
Expand Down Expand Up @@ -84,7 +83,11 @@ export abstract class Function extends Resource implements IInflightHost {
handler._registerBind(this, ["handle", "$inflight_init"]);

const inflightClient = handler._toInflight();
const lines = this._generateLines(inflightClient);
const lines = new Array<string>();

lines.push("exports.handler = async function(event) {");
lines.push(` return await (${inflightClient.text}).handle(event);`);
lines.push("};");

const assetName = ResourceNames.generateName(this, {
// Avoid characters that may cause path issues
Expand All @@ -107,22 +110,6 @@ export abstract class Function extends Resource implements IInflightHost {
}
}

/**
* Generates the code lines for the cloud function, can be overridden by the targets
* @param inflightClient inflight client code
* @returns cloud function code string
* @internal
*/
protected _generateLines(inflightClient: Code): string[] {
const lines = new Array<string>();

lines.push("exports.handler = async function(event) {");
lines.push(` return await (${inflightClient.text}).handle(event);`);
lines.push("};");

return lines;
}

/**
* Add an environment variable to the function.
*/
Expand Down
154 changes: 65 additions & 89 deletions libs/wingsdk/src/shared-aws/function.inflight.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda";
import { InvokeCommand, LambdaClient, LogType } from "@aws-sdk/client-lambda";
import { fromUtf8, toUtf8 } from "@aws-sdk/util-utf8-node";
import { Context } from "aws-lambda";
import { IFunctionClient } from "../cloud";
import { Trace } from "../std";
import { Trace, TraceType } from "../std";

export class FunctionClient implements IFunctionClient {
constructor(
Expand All @@ -12,69 +11,13 @@ export class FunctionClient implements IFunctionClient {
) {}

/**
* Reading the function's logs,
* along with any logs of a function that was called by the parent function
*
* @param logGroupName function's context logGroupName
* @param logStreamName function's context logGroupName
* @param constructPath cdk's path to construct
* @returns a list of Traces
*/

private readLogs(logs: Trace[]): Trace[] {
const logsCollector: Trace[] = [];

for (const log of logs) {
const invocationLog = log.data.message?.match(/Invoking .*:/gm);

if (invocationLog) {
const logData = log.data.message.split("\t") ?? [];
const parsedLogs: Trace[] =
JSON.parse(
Buffer.from(logData[logData.length - 1], "base64").toString(
"binary"
)
)?.logs ?? [];

logsCollector.push(...this.readLogs(parsedLogs));
} else {
logsCollector.push(log);
}
}

return logsCollector;
}

/**
* Verify the function's return payload
*
* @returns the function's return payload, if verified
*/
private verify(value: { context?: Context; payload: string }): string {
if (typeof value.payload !== "string") {
throw new Error(
`function returned value of type ${typeof value.payload}, not string`
);
}
return value.payload;
}

/**
* Invoke the function
* Invoke the function, passing the given payload as an argument.
* @returns the function returned payload only
*/
private async executeFunction(payload: string): Promise<{
context?: Context & {
logs: Trace[];
};
payload: string;
}> {
public async invoke(payload: string): Promise<string> {
const command = new InvokeCommand({
FunctionName: this.functionArn,
Payload: fromUtf8(JSON.stringify(payload)),
ClientContext: Buffer.from(
JSON.stringify({ constructPath: this.constructPath }),
"binary"
).toString("base64"),
});
const response = await this.lambdaClient.send(command);

Expand All @@ -86,47 +29,80 @@ export class FunctionClient implements IFunctionClient {
);
}
if (!response.Payload) {
return { payload: "" };
return "";
}
const value = JSON.parse(toUtf8(response.Payload)) ?? "";
if (typeof value !== "string") {
throw new Error(
`function returned value of type ${typeof value}, not string`
);
}
return value;
}

/**
* Invoke the function, passing the given payload as an argument.
* @returns the function returned payload only
*/
public async invoke(payload: string): Promise<string> {
const value = await this.executeFunction(payload);
const functionName = value?.context?.functionName;

// kind of hacky, but this is the most convenient way to pass those arguments to the calling function
console.log(
`Invoking ${functionName}:\t${Buffer.from(
JSON.stringify({
logs: value?.context?.logs,
}),
"binary"
).toString("base64")}`
);

return this.verify(value);
}

/**
* Invoke the function, passing the given payload as an argument.
*
* @returns the function returned payload and logs
*/
public async invokeWithLogs(payload: string): Promise<[string, Trace[]]> {
const traces: Trace[] = [];
const command = new InvokeCommand({
FunctionName: this.functionArn,
Payload: fromUtf8(JSON.stringify(payload)),
LogType: LogType.Tail,
});
const response = await this.lambdaClient.send(command);

const value = await this.executeFunction(payload);
const logs = Buffer.from(response.LogResult ?? "", "base64").toString();
const traces = parseLogs(logs, this.constructPath);

if (value.context?.logs) {
traces.push(...this.readLogs(value.context.logs));
if (response.FunctionError) {
throw new Error(
`Invoke failed with message: "${
response.FunctionError
}". Full error: "${toUtf8(response.Payload!)}"`
);
}
if (!response.Payload) {
return ["", traces];
}
const value = JSON.parse(toUtf8(response.Payload)) ?? "";
if (typeof value !== "string") {
throw new Error(
`function returned value of type ${typeof value}, not string`
);
}
return ["", traces];
}
}

return [this.verify(value), traces];
export function parseLogs(logs: string, sourcePath: string) {
const lines = logs.split("\n");
const traces: Trace[] = [];
for (const line of lines) {
const parts = line.split("\t");
// 2023-08-04T16:40:47.309Z 6beb7628-d0c3-4fe9-bf5a-d64c559aa25f INFO hello
// 2023-08-04T16:40:47.309Z 6beb7628-d0c3-4fe9-bf5a-d64c559aa25f Task timed out after 3.0 seconds
if (
parts.length >= 3 &&
parts[0].match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) !==
null &&
parts[1].match(/^[0-9a-fA-F-]{36}$/) !== null
) {
const timestamp = parts[0];
if (parts.slice(2).join(" ").startsWith("Task timed out after")) {
continue;
}
const message = parts.slice(3).join(" ");
const trace: Trace = {
data: { message },
timestamp,
sourceType: "wingsdk.cloud.Function",
sourcePath,
type: TraceType.LOG,
};
traces.push(trace);
}
}
return traces;
}
42 changes: 1 addition & 41 deletions libs/wingsdk/src/shared-aws/function.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { PolicyStatement } from "./types";
import { Code } from "../core";
import { IInflightHost, TraceType } from "../std";
import { FUNCTION_TYPE } from "../target-sim/schema-resources";
import { IInflightHost } from "../std";
import { Function as TfAwsFunction } from "../target-tf-aws";

/**
Expand Down Expand Up @@ -39,41 +37,3 @@ export class Function {
return undefined;
}
}

/**
* Generates the code lines for the cloud function,
* overridden by the aws targets to have the function context too,
* as well as collecting the logs as Traces and keeping them in context.logs for later use.
* Eventually, this enables us displaying user defined logs, called in aws lambdas,
* in the user's terminal while testing.
* @param inflightClient inflight client code
* @returns cloud function code string
* @internal
*/
export function _generateAwsFunctionLines(inflightClient: Code): string[] {
const lines = new Array<string>();

lines.push("exports.handler = async function(event, context) {");
lines.push(`
const $originalLog = console.log.bind({});
console.log = (...args) => {
const logs = args.map(item => ({
data: { message: item },
sourceType: "${FUNCTION_TYPE}",
sourcePath: context?.clientContext?.constructPath,
type: "${TraceType.LOG}",
timestamp: new Date().toISOString()
}));
!context.logs ? context.logs = [...logs] : context.logs.push(...logs);
$originalLog(args);
};`);

lines.push(
` return { payload: (await (${inflightClient.text}).handle(event)) ?? "", context };`
);
lines.push("};");

return lines;
}
13 changes: 1 addition & 12 deletions libs/wingsdk/src/target-awscdk/function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { Construct } from "constructs";
import * as cloud from "../cloud";
import * as core from "../core";
import { createBundle } from "../shared/bundling";
import { PolicyStatement, _generateAwsFunctionLines } from "../shared-aws";
import { PolicyStatement } from "../shared-aws";
import { IInflightHost } from "../std";

/**
Expand Down Expand Up @@ -71,17 +71,6 @@ export class Function extends cloud.Function {
super._bind(host, ops);
}

/**
* Generates the code lines for the cloud function,
* overridden by the awscdk target to have the function context too
* @param inflightClient inflight client code
* @returns cloud function code string
* @internal
*/
protected _generateLines(inflightClient: core.Code): string[] {
return _generateAwsFunctionLines(inflightClient);
}

/** @internal */
public _toInflight(): core.Code {
return core.InflightClient.for(
Expand Down
17 changes: 1 addition & 16 deletions libs/wingsdk/src/target-tf-aws/function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,7 @@ import * as cloud from "../cloud";
import * as core from "../core";
import { createBundle } from "../shared/bundling";
import { NameOptions, ResourceNames } from "../shared/resource-names";
import {
IAwsFunction,
PolicyStatement,
_generateAwsFunctionLines,
} from "../shared-aws";
import { IAwsFunction, PolicyStatement } from "../shared-aws";
import { IInflightHost, Resource } from "../std";
import { Duration } from "../std/duration";

Expand Down Expand Up @@ -226,17 +222,6 @@ export class Function extends cloud.Function implements IAwsFunction {
return this.function.functionName;
}

/**
* Generates the code lines for the cloud function,
* overridden by the tf-aws target to have the function context too
* @param inflightClient inflight client code
* @returns cloud function code string
* @internal
*/
protected _generateLines(inflightClient: core.Code): string[] {
return _generateAwsFunctionLines(inflightClient);
}

/** @internal */
public _bind(host: IInflightHost, ops: string[]): void {
if (!(host instanceof Function)) {
Expand Down
Loading

0 comments on commit dbfedd6

Please sign in to comment.