diff --git a/python/api.test.w b/python/api.test.w new file mode 100644 index 00000000..cff803c8 --- /dev/null +++ b/python/api.test.w @@ -0,0 +1,24 @@ +bring cloud; +bring http; +bring expect; +bring util; +bring "./lib.w" as python; + +let bucket = new cloud.Bucket(); +let api = new cloud.Api(); +api.get("/test", new python.InflightApiEndpointHandler( + path: "./test-assets", + handler: "main.api_handler", +).lift(bucket, id: "bucket", allow: ["put"])); + +test "invokes api handler" { + let res = http.get("{api.url}/test"); + log(Json.stringify(res)); + expect.equal(res.status, 200); + expect.equal(res.body, "Hello from Api Handler!"); + expect.equal(res.headers["header1"], "value1"); + + util.waitUntil(inflight () => { + return bucket.exists("/test"); + }); +} diff --git a/python/lib.w b/python/lib.w index 593264bc..c21f8723 100644 --- a/python/lib.w +++ b/python/lib.w @@ -3,7 +3,9 @@ bring util; bring "constructs" as construct; bring "./types.w" as types; bring "./sim" as sim; +bring "./sim/api_onrequest_inflight.w" as simapi; bring "./tfaws/inflight.w" as aws; +bring "./tfaws/api_onrequest_inflight.w" as tfawsapi; pub class InflightFunction impl cloud.IFunctionHandler { _inflightType: str; @@ -112,3 +114,30 @@ pub class InflightBucketEvent impl cloud.IBucketEventHandler { return this; } } + +pub class InflightApiEndpointHandler impl cloud.IApiEndpointHandler { + _inflightType: str; + inner: types.IApiOnRequest; + + new(props: types.InflightProps) { + this._inflightType = "_inflightPython"; + + let target = util.env("WING_TARGET"); + if target == "sim" { + this.inner = new simapi.InflightApiEndpointHandler(props); + } elif target == "tf-aws" { + this.inner = new tfawsapi.Inflight_tfaws(props); + } else { + throw "Unsupported target ${target}"; + } + } + + pub inflight handle(req: cloud.ApiRequest): cloud.ApiResponse? { + return this.inner.handle(req); + } + + pub lift(obj: std.Resource, options: types.LiftOptions): InflightApiEndpointHandler { + this.inner.lift(obj, options); + return this; + } +} diff --git a/python/package-lock.json b/python/package-lock.json index ca09abf6..8f9eef55 100644 --- a/python/package-lock.json +++ b/python/package-lock.json @@ -1,12 +1,12 @@ { "name": "@winglibs/python", - "version": "0.0.2", + "version": "0.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@winglibs/python", - "version": "0.0.2", + "version": "0.0.3", "license": "MIT", "peerDependencies": { "@aws-sdk/client-lambda": "^3.549.0", diff --git a/python/package.json b/python/package.json index 50c714bd..67842432 100644 --- a/python/package.json +++ b/python/package.json @@ -1,7 +1,7 @@ { "name": "@winglibs/python", "description": "python library for Wing", - "version": "0.0.2", + "version": "0.0.3", "repository": { "type": "git", "url": "https://github.com/winglang/winglibs.git", diff --git a/python/sim/api_onrequest_inflight.w b/python/sim/api_onrequest_inflight.w new file mode 100644 index 00000000..d42c54d1 --- /dev/null +++ b/python/sim/api_onrequest_inflight.w @@ -0,0 +1,26 @@ +bring cloud; +bring "./inflight.w" as inflyght; +bring "../types.w" as types; + +pub class InflightApiEndpointHandler extends inflyght.Inflight impl cloud.IApiEndpointHandler { + new(props: types.InflightProps) { + super(props); + } + + pub inflight handle(req: cloud.ApiRequest): cloud.ApiResponse? { + let response = this._handle(Json.stringify(req)); + if let res = Json.tryParse(response) { + let headers = MutMap{}; + for entry in Json.entries(res["headers"]) { + headers.set(entry.key, entry.value.asStr()); + } + return { + status: res["statusCode"].asNum(), + headers: headers.copy(), + body: res["body"].tryAsStr(), + }; + } else { + return nil; + } + } +} diff --git a/python/test-assets/main.py b/python/test-assets/main.py index cfd511eb..8fecd578 100644 --- a/python/test-assets/main.py +++ b/python/test-assets/main.py @@ -58,3 +58,19 @@ def bucket_oncreate_handler(event, context): "statusCode": 200, "body": "Hello from Bucket OnCreate!" } + +def api_handler(event, context): + print(event) + print(context) + + req = from_api_event(event) + client_put = lifted("bucket") + client_put.put(req.path, req.toJSON()) + + return from_api_response({ + "status": 200, + "body": "Hello from Api Handler!", + "headers": { + "header1": "value1" + } + }) diff --git a/python/test-assets/wing.py b/python/test-assets/wing.py index c366f9f2..f4975f2c 100644 --- a/python/test-assets/wing.py +++ b/python/test-assets/wing.py @@ -154,3 +154,68 @@ def from_bucket_event(event): return [bucket_event] else: raise Exception(f"Unsupported target: {target}") + +class ApiRequest: + method: str + path: str + query: dict + headers: dict + body: str + vars: dict + + def toJSON(self): + return json.dumps( + self, + default=lambda o: o.__dict__, + sort_keys=True, + indent=2) + +def from_api_event(event): + target = os.getenv(f"WING_TARGET") + if target == "tf-aws": + req = ApiRequest() + req.method = event["httpMethod"] + req.path = event["path"] + req.query = event["queryStringParameters"] + req.headers = event["headers"] + req.body = event["body"] + req.vars = event["pathParameters"] + return req + elif target == "sim": + data = event["payload"] + req = ApiRequest() + req.method = data["method"] + req.path = data["path"] + req.query = data["query"] + req.headers = data["headers"] + req.body = data["body"] + req.vars = data["vars"] + return req + else: + raise Exception(f"Unsupported target: {target}") + +def from_api_response(res = None): + if not res: + return { + "statusCode": 200, + "body": "", + "headers": {} + } + + response = {} + if not res.get("status"): + response["statusCode"] = 200 + else: + response["statusCode"] = res["status"] + + if not res.get("body"): + response["body"] = "" + else: + response["body"] = res["body"] + + if not res.get("headers"): + response["headers"] = {} + else: + response["headers"] = res["headers"] + + return response diff --git a/python/tfaws/api.js b/python/tfaws/api.js new file mode 100644 index 00000000..73dad4e3 --- /dev/null +++ b/python/tfaws/api.js @@ -0,0 +1,27 @@ +const { Api: TfAwsApi } = require("@winglang/sdk/lib/target-tf-aws/api.js"); +const { App } = require("@winglang/sdk/lib/target-tf-aws/app.js"); +const { Node } = require("@winglang/sdk/lib/std/node.js"); +const awsProvider = require("@cdktf/provider-aws"); +const { Function } = require("./function.js"); + +module.exports.Api = class Api extends TfAwsApi { + addHandler(inflight, method, path) { + if (inflight._inflightType !== "_inflightPython") { + return super.addHandler(inflight, method, path); + } + + let handler = this.handlers[inflight._id]; + if (!handler) { + handler = new Function( + this, + App.of(this).makeId(this, `${this.node.id}-OnMessage`), + inflight, + {}, + ); + Node.of(handler).hidden = true; + this.handlers[inflight._id] = handler; + } + + return handler; + } +}; diff --git a/python/tfaws/api_onrequest_inflight.w b/python/tfaws/api_onrequest_inflight.w new file mode 100644 index 00000000..8e5f321c --- /dev/null +++ b/python/tfaws/api_onrequest_inflight.w @@ -0,0 +1,19 @@ +bring cloud; +bring util; +bring "../types.w" as types; +bring "./inflight.w" as inflyght; + +pub class Inflight_tfaws extends inflyght.Inflight_tfaws impl types.IApiOnRequest { + new(props: types.InflightProps) { + super(props); + } + + pub inflight handle(req: cloud.ApiRequest): cloud.ApiResponse? { + + } + + pub lift(obj: std.Resource, options: types.LiftOptions): cloud.IApiEndpointHandler { + this.lifts.set(options.id, { client: obj, options: options }); + return this; + } +} diff --git a/python/tfaws/function.js b/python/tfaws/function.js index ea73e61a..da7e25b2 100644 --- a/python/tfaws/function.js +++ b/python/tfaws/function.js @@ -1,15 +1,22 @@ const { Construct } = require("constructs"); const { Function: TfAwsFunction } = require("@winglang/sdk/lib/target-tf-aws/function.js"); const { App } = require("@winglang/sdk/lib/target-tf-aws/app.js"); -const { Testing } = require("@winglang/sdk/lib/simulator"); +const { inflight: createInflight } = require("@winglang/sdk/lib/core"); const { Function: AwsFunction } = require("@winglang/sdk/lib/shared-aws/function.js"); const { Bucket: AwsBucket } = require("@winglang/sdk/lib/shared-aws/bucket.js"); const { normalPath } = require("@winglang/sdk/lib/shared/misc.js"); const { Duration } = require("@winglang/sdk/lib/std/duration.js"); +const { ResourceNames } = require("@winglang/sdk/lib/shared/resource-names"); +const { DEFAULT_MEMORY_SIZE } = require("@winglang/sdk/lib/shared/function"); const cdktf = require("cdktf"); const awsProvider = require("@cdktf/provider-aws"); const { build } = require("../util.js"); +const FUNCTION_NAME_OPTS = { + maxLen: 64, + disallowedRegex: /[^a-zA-Z0-9\_\-]+/g, +}; + module.exports.Function = class Function extends Construct { constructor( scope, @@ -18,11 +25,8 @@ module.exports.Function = class Function extends Construct { props = {}, ) { super(scope, id); - this.dummy = new TfAwsFunction(this, "Dummy", Testing.makeHandler(` - async handle(event) { - return; - }` - )); + + this.dummy = new TfAwsFunction(this, "Dummy", createInflight(async (ctx) => {})); const entrypointDir = App.of(this).entrypointDir; const workDir = App.of(this).workdir; const pathEnv = process.env["PATH"] || ""; @@ -42,6 +46,7 @@ module.exports.Function = class Function extends Construct { }); const roleArn = this.dummy.role.arn; + const roleName = this.dummy.role.name; const clients = {}; for (let clientId of Object.keys(inflight.inner.lifts)) { @@ -63,8 +68,27 @@ module.exports.Function = class Function extends Construct { WING_CLIENTS: cdktf.Fn.jsonencode(clients), }; + this.name = ResourceNames.generateName(this, FUNCTION_NAME_OPTS); + this.functionName = this.name; + + // Add execution role for lambda to write to CloudWatch logs + new awsProvider.iamRolePolicyAttachment.IamRolePolicyAttachment(this, "IamRolePolicyAttachment", { + policyArn: + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + role: roleName, + }); + + if (!props.logRetentionDays || props.logRetentionDays >= 0) { + new awsProvider.cloudwatchLogGroup.CloudwatchLogGroup(this, "CloudwatchLogGroup", { + name: `/aws/lambda/${this.name}`, + retentionInDays: props.logRetentionDays ?? 30, + }); + } else { + // Negative value means Infinite retention + } + this.lambda = new awsProvider.lambdaFunction.LambdaFunction(this, "PyFunction", { - functionName: `${id}-${this.node.addr.substring(42-8)}`, + functionName: this.name, role: roleArn, handler: inflight.inner.props.handler, runtime: "python3.11", @@ -73,6 +97,7 @@ module.exports.Function = class Function extends Construct { timeout: props.timeout ? props.timeout.seconds : Duration.fromMinutes(1).seconds, + memorySize: props.memory ?? DEFAULT_MEMORY_SIZE, environment: { variables: cdktf.Lazy.anyValue({ produce: () => ({ diff --git a/python/tfaws/inflight.w b/python/tfaws/inflight.w index b7c98602..27310b42 100644 --- a/python/tfaws/inflight.w +++ b/python/tfaws/inflight.w @@ -9,7 +9,7 @@ struct Lifted { pub class Inflight_tfaws impl types.IInflight { props: types.InflightProps; - lifts: MutMap; + protected lifts: MutMap; new(props: types.InflightProps) { this.props = props; diff --git a/python/types.w b/python/types.w index c3c6e146..265d2881 100644 --- a/python/types.w +++ b/python/types.w @@ -31,3 +31,7 @@ pub interface IInflight extends cloud.IFunctionHandler { pub interface IBucketEventInflight extends cloud.IBucketEventHandler { lift(obj: std.Resource, options: LiftOptions): cloud.IBucketEventHandler; } + +pub interface IApiOnRequest extends cloud.IApiEndpointHandler { + lift(obj: std.Resource, options: LiftOptions): cloud.IApiEndpointHandler; +} diff --git a/python/wplatform.js b/python/wplatform.js index 18770bad..d47ce51b 100644 --- a/python/wplatform.js +++ b/python/wplatform.js @@ -3,11 +3,13 @@ const { Function: TfAwsFunction } = require("./tfaws/function.js"); const { Queue: TfAwsQueue } = require("./tfaws/queue.js"); const { Topic: TfAwsTopic } = require("./tfaws/topic.js"); const { Bucket: TfAwsBucket } = require("./tfaws/bucket.js"); +const { Api: TfAwsApi } = require("./tfaws/api.js"); const FUNCTION_FQN = "@winglang/sdk.cloud.Function"; const QUEUE_FQN = "@winglang/sdk.cloud.Queue"; const TOPIC_FQN = "@winglang/sdk.cloud.Topic"; const BUCKET_FQN = "@winglang/sdk.cloud.Bucket"; +const API_FQN = "@winglang/sdk.cloud.Api"; const createFunction = (target, scope, id, inflight, props) => { if (inflight._inflightType === "_inflightPython") { @@ -36,6 +38,10 @@ module.exports.Platform = class Platform { if (target === "tf-aws") { return new TfAwsBucket(scope, id, ...props); } + } else if (type === API_FQN) { + if (target === "tf-aws") { + return new TfAwsApi(scope, id, ...props); + } } } };