From 84556eeaffa36ef7cd8b0b01d34b1d927dd9e666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Tue, 17 Sep 2024 10:01:06 +0200 Subject: [PATCH 01/16] wip --- .../sdk/src/target-sim/bucket.inflight.ts | 123 ++++++- wing-console/console/app/demo/main.w | 307 +----------------- 2 files changed, 119 insertions(+), 311 deletions(-) diff --git a/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts b/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts index 35abd33fec0..7d90d003422 100644 --- a/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts +++ b/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts @@ -1,7 +1,10 @@ import * as crypto from "crypto"; import * as fs from "fs"; +import { Server } from "http"; +import { AddressInfo, Socket } from "net"; import { dirname, join } from "path"; import * as url from "url"; +import express from "express"; import mime from "mime-types"; import { BucketAttributes, BucketSchema } from "./schema-resources"; import { exists } from "./util"; @@ -27,6 +30,20 @@ import { Datetime, Json, LogLevel, TraceType } from "../std"; export const METADATA_FILENAME = "metadata.json"; +const LOCALHOST_ADDRESS = "127.0.0.1"; + +const STATE_FILENAME = "state.json"; + +/** + * Contents of the state file for this resource. + */ +interface StateFileContents { + /** + * The last port used by the API server on a previous simulator run. + */ + readonly lastPort?: number; +} + export class Bucket implements IBucketClient, ISimulatorResourceInstance { private _fileDir!: string; private _context: ISimulatorContext | undefined; @@ -34,12 +51,18 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { private readonly _public: boolean; private readonly topicHandlers: Partial>; private _metadata: Map; + private readonly app: express.Application; + private server: Server | undefined; + private url: string | undefined; + private port: number | undefined; public constructor(props: BucketSchema) { this.initialObjects = props.initialObjects ?? {}; this._public = props.public ?? false; this.topicHandlers = props.topics; this._metadata = new Map(); + + this.app = express(); } private get context(): ISimulatorContext { @@ -86,7 +109,38 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { }); } - return {}; + // Check for a previous state file to see if there was a port that was previously being used + // if so, try to use it out of convenience + let lastPort: number | undefined; + const state: StateFileContents = await this.loadState(); + if (state.lastPort) { + const portAvailable = await isPortAvailable(state.lastPort); + if (portAvailable) { + lastPort = state.lastPort; + } + } + + // `server.address()` returns `null` until the server is listening + // on a port. We use a promise to wait for the server to start + // listening before returning the URL. + const addrInfo: AddressInfo = await new Promise((resolve, reject) => { + this.server = this.app.listen(lastPort ?? 0, LOCALHOST_ADDRESS, () => { + const addr = this.server?.address(); + if (addr && typeof addr === "object" && (addr as AddressInfo).port) { + resolve(addr); + } else { + reject(new Error("No address found")); + } + }); + }); + this.port = addrInfo.port; + this.url = `http://${addrInfo.address}:${addrInfo.port}`; + + this.addTrace(`Server listening on ${this.url}`, LogLevel.VERBOSE); + + return { + url: this.url, + }; } public async cleanup(): Promise {} @@ -95,6 +149,28 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { return UpdatePlan.AUTO; } + private async loadState(): Promise { + const stateFileExists = await exists( + join(this.context.statedir, STATE_FILENAME) + ); + if (stateFileExists) { + const stateFileContents = await fs.promises.readFile( + join(this.context.statedir, STATE_FILENAME), + "utf-8" + ); + return JSON.parse(stateFileContents); + } else { + return {}; + } + } + + private async saveState(state: StateFileContents): Promise { + fs.writeFileSync( + join(this.context.statedir, STATE_FILENAME), + JSON.stringify(state) + ); + } + public async save(): Promise { // no need to save individual files, since they are already persisted in the state dir // during the bucket's lifecycle @@ -102,6 +178,8 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { join(this.context.statedir, METADATA_FILENAME), serialize(Array.from(this._metadata.entries())) // metadata contains Datetime values, so we need to serialize it ); + + await this.saveState({ lastPort: this.port }); } private async notifyListeners( @@ -297,15 +375,17 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { } public async signedUrl(key: string, options?: BucketSignedUrlOptions) { - options; - return this.context.withTrace({ - message: `Signed URL (key=${key})`, - activity: async () => { - throw new Error( - `signedUrl is not implemented yet for the simulator (key=${key})` - ); - }, - }); + const params = new URLSearchParams(); + if (options?.action) { + params.set("action", options.action); + } + if (options?.duration) { + params.set( + "validUntil", + String(Datetime.utcNow().sec + options?.duration?.seconds) + ); + } + return `${this.url}/${key}?${params.toString()}`; } /** @@ -398,3 +478,26 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { }); } } + +async function isPortAvailable(port: number): Promise { + return new Promise((resolve, _reject) => { + const s = new Socket(); + s.once("error", (err) => { + s.destroy(); + if ((err as any).code !== "ECONNREFUSED") { + resolve(false); + } else { + // connection refused means the port is not used + resolve(true); + } + }); + + s.once("connect", () => { + s.destroy(); + // connection successful means the port is used + resolve(false); + }); + + s.connect(port, LOCALHOST_ADDRESS); + }); +} diff --git a/wing-console/console/app/demo/main.w b/wing-console/console/app/demo/main.w index af4d685243b..4f5e697bae1 100644 --- a/wing-console/console/app/demo/main.w +++ b/wing-console/console/app/demo/main.w @@ -15,305 +15,10 @@ let visual = new ui.Table( }, ) as "ui.Table" in tableBucket; -// let errorService = new cloud.Service(inflight () => {}) as "ErrorService"; - -// let errorResource = new sim.Resource(inflight () => { -// throw "Oops"; -// }) as "ErrorResource" in errorService; - -// @see https://github.com/winglang/wing/issues/4237 it crashes the Console preview env. -// let secret = new cloud.Secret(name: "my-secret"); - -let bucket = new cloud.Bucket(); -let queue = new cloud.Queue(); -let api = new cloud.Api(); -let counter = new cloud.Counter(initial: 0); - -class myBucket { - b: cloud.Bucket; - new() { - this.b = new cloud.Bucket(); - new ui.FileBrowser("File Browser", - { - put: inflight (fileName: str, fileContent:str) => { - this.b.put(fileName, fileContent); - }, - delete: inflight (fileName: str) => { - this.b.delete(fileName); - }, - get: inflight (fileName: str) => { - return this.b.get(fileName); - }, - list: inflight () => {return this.b.list();}, - } - ); - - new cloud.Service( - inflight () => { - this.b.put("hello.txt", "Hello, GET!"); - return inflight () => { - }; - }, - ); - } - pub inflight put(key: str, value: str) { - this.b.put(key, value); - } +test "presigned url" { + let bucket = new cloud.Bucket() as "Bucket"; + let obj = new cloud.Object() as "Object"; + // let url = obj.getPresignedUrl({ action: "get" }); + // log(url); + // assert eq(url, "https://minio.example.com/bucket/object?action=get&validUntil=1714857600"); } - -let myB = new myBucket() as "MyUIComponentBucket"; - -let putfucn = new cloud.Function(inflight () => { - myB.put("test", "Test"); -}) as "PutFileInCustomBucket"; - -api.get("/test-get", inflight (req: cloud.ApiRequest): cloud.ApiResponse => { - bucket.put("hello.txt", "Hello, GET!"); - return cloud.ApiResponse { - status: 200, - body: Json.stringify(req.query) - }; -}); -api.post("/test-post", inflight (req: cloud.ApiRequest): cloud.ApiResponse => { - counter.inc(); - return cloud.ApiResponse { - status: 200, - body: "Hello, POST!" - }; -}); - -let handler = inflight (message): str => { - counter.inc(); - bucket.put("hello{counter.peek()}.txt", "Hello, {message}!"); - log("Hello, {message}!"); - return message; -}; - -queue.setConsumer(handler); - -new cloud.Function(inflight (message: Json?) => { - counter.inc(); - log("Counter is now {counter.inc(0)}"); - return message; -}) as "IncrementCounter"; - -let topic = new cloud.Topic() as "Topic"; -topic.onMessage(inflight (message: str): str => { - log("Topic subscriber #1: {message}"); - return message; -}); - -let rateSchedule = new cloud.Schedule(cloud.ScheduleProps{ - rate: 5m -}) as "Rate Schedule"; -nodeof(rateSchedule).expanded = true; - -rateSchedule.onTick(inflight () => { - log("Rate schedule ticked!"); -}); - -new cloud.Service( - inflight () => { - return inflight () => { - log("stop!"); - }; - }, -); - -let cronSchedule = new cloud.Schedule(cloud.ScheduleProps{ - cron: "* * * * *" -}) as "Cron Schedule"; - -// cronSchedule.onTick(inflight () => { -// log("Cron schedule ticked!"); -// }); - -test "Increment counter" { - let previous = counter.inc(); - log("Assertion should fail: {previous} === {counter.peek()}"); - assert(previous == 1); -} - -test "Push message to the queue" { - queue.push("hey"); -} - -test "Print"{ - log("Hello World!"); - assert(true); -} - -test "without assertions nor prints" { -} - -test "Add fixtures" { - let arr = [1, 2, 3, 4, 5]; - - log("Adding {arr.length} files in the bucket.."); - for item in arr { - bucket.put("fixture_{item}.txt", "Content for the fixture_{item}!"); - } - - log("Publishing to the topic.."); - topic.publish("Hello, topic!"); - - log("Setting up counter.."); - counter.set(0); - counter.inc(100); -} - -class WidgetService { - data: cloud.Bucket; - counter: cloud.Counter; - bucket: myBucket; - - new() { - this.data = new cloud.Bucket(); - this.counter = new cloud.Counter(); - this.bucket = new myBucket() as "MyInternalBucket"; - - // a field displays a labeled value, with optional refreshing - new ui.Field( - "Total widgets", - inflight () => { return this.countWidgets(); }, - refreshRate: 5s, - ) as "TotalWidgets"; - - // a link field displays a clickable link - new ui.Field( - "Widgets Link", - inflight () => { return "https://winglang.io"; }, - link: true, - ) as "WidgetsLink"; - - // a button lets you invoke any inflight function - new ui.Button("Add widget", inflight () => { this.addWidget(); }); - } - - pub inflight addWidget() { - let id = this.counter.inc(); - this.data.put("widget-{id}", "my data"); - } - - inflight countWidgets(): str { - return "{this.data.list().length}"; - } -} - -let widget = new WidgetService(); - -new cloud.Function(inflight () => { - widget.addWidget(); -}) as "AddWidget"; - -class ApiUsersService { - api: cloud.Api; - db: cloud.Bucket; - - new() { - this.api = new cloud.Api(); - this.db = new cloud.Bucket(); - - this.api.post("/users", inflight (request: cloud.ApiRequest): cloud.ApiResponse => { - let input = Json.tryParse(request.body ?? "") ?? ""; - let name = input.tryGet("name")?.tryAsStr() ?? ""; - if name == "" { - return cloud.ApiResponse { - status: 400, - body: "Body parameter 'name' is required" - }; - } - this.db.put("user-{name}", Json.stringify(input)); - return cloud.ApiResponse { - status: 200, - body: Json.stringify(input) - }; - }); - this.api.get("/users", inflight (request: cloud.ApiRequest): cloud.ApiResponse => { - let name = request.query.tryGet("name") ?? ""; - - if name != "" { - try { - return cloud.ApiResponse { - status: 200, - body: this.db.get("user-{name}") - }; - } catch { - return cloud.ApiResponse { - status: 404, - body: "User not found" - }; - } - } - - return cloud.ApiResponse { - status: 200, - body: Json.stringify(this.db.list()) - }; - }); - - new ui.HttpClient( - "Test HttpClient UI component", - inflight () => { - return this.api.url; - }, - inflight () => { - return Json.stringify({ - "paths": { - "/users": { - "post": { - "summary": "Create a new user", - "parameters": [ - { - "in": "header", - "name": "accept", - }, - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name", - ], - "properties": { - "name": { - "type": "string", - "description": "The name of the user" - }, - "email": { - "type": "string", - "description": "The email of the user", - } - } - } - } - } - }, - }, - "get": { - "summary": "List all widgets", - "parameters": [ - { - "in": "query", - "name": "name", - "schema": { - "type": "string" - }, - "description": "The name of the user" - } - ], - } - }, - } - }); - } - ); - } -} - -new ApiUsersService(); - -log("hello from inflight"); From e57499c616efe1f68fdc7552c293c244f05dab9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Tue, 17 Sep 2024 14:34:40 +0200 Subject: [PATCH 02/16] wip --- packages/@winglang/sdk/.projen/deps.json | 8 ++ packages/@winglang/sdk/.projenrc.ts | 2 + packages/@winglang/sdk/package.json | 3 + .../sdk/src/target-sim/bucket.inflight.ts | 115 ++++++++++++++++-- pnpm-lock.yaml | 36 +++++- 5 files changed, 151 insertions(+), 13 deletions(-) diff --git a/packages/@winglang/sdk/.projen/deps.json b/packages/@winglang/sdk/.projen/deps.json index c38b2198449..de2564a266e 100644 --- a/packages/@winglang/sdk/.projen/deps.json +++ b/packages/@winglang/sdk/.projen/deps.json @@ -9,6 +9,10 @@ "name": "@types/aws-lambda", "type": "build" }, + { + "name": "@types/busboy", + "type": "build" + }, { "name": "@types/express", "type": "build" @@ -276,6 +280,10 @@ "name": "ajv", "type": "bundled" }, + { + "name": "busboy", + "type": "bundled" + }, { "name": "cdktf", "version": "0.20.7", diff --git a/packages/@winglang/sdk/.projenrc.ts b/packages/@winglang/sdk/.projenrc.ts index 7e2d86aaed5..f887e585b0c 100644 --- a/packages/@winglang/sdk/.projenrc.ts +++ b/packages/@winglang/sdk/.projenrc.ts @@ -77,6 +77,7 @@ const project = new cdk.JsiiProject({ "protobufjs@7.2.5", // simulator dependencies "express", + "busboy", "uuid", // using version 3 because starting from version 4, it no longer works with CommonJS. "nanoid@^3.3.7", @@ -114,6 +115,7 @@ const project = new cdk.JsiiProject({ "eslint-plugin-sort-exports", "fs-extra", "vitest", + "@types/busboy", "@types/uuid", "nanoid", // for ESM import test in target-sim/function.test.ts "chalk", diff --git a/packages/@winglang/sdk/package.json b/packages/@winglang/sdk/package.json index 8f59d40e1d5..8f5c3cf7fbb 100644 --- a/packages/@winglang/sdk/package.json +++ b/packages/@winglang/sdk/package.json @@ -38,6 +38,7 @@ "devDependencies": { "@cdktf/provider-aws": "^19", "@types/aws-lambda": "^8.10.109", + "@types/busboy": "^1.5.4", "@types/express": "^4.17.21", "@types/fs-extra": "^11.0.4", "@types/glob": "^7.2.0", @@ -102,6 +103,7 @@ "@types/aws-lambda": "^8.10.140", "@winglang/wingtunnels": "workspace:^", "ajv": "^8.16.0", + "busboy": "^1.6.0", "cdktf": "0.20.7", "constructs": "^10.3", "cron-parser": "^4.9.0", @@ -148,6 +150,7 @@ "@types/aws-lambda", "@winglang/wingtunnels", "ajv", + "busboy", "cdktf", "cron-parser", "cron-validator", diff --git a/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts b/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts index 7d90d003422..d437474d621 100644 --- a/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts +++ b/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts @@ -3,7 +3,8 @@ import * as fs from "fs"; import { Server } from "http"; import { AddressInfo, Socket } from "net"; import { dirname, join } from "path"; -import * as url from "url"; +import { pathToFileURL } from "url"; +import busboy, { FileInfo } from "busboy"; import express from "express"; import mime from "mime-types"; import { BucketAttributes, BucketSchema } from "./schema-resources"; @@ -19,6 +20,8 @@ import { BucketGetOptions, BucketTryGetOptions, BUCKET_FQN, + BucketSignedUrlAction, + CorsHeaders, } from "../cloud"; import { deserialize, serialize } from "../simulator/serialization"; import { @@ -63,6 +66,91 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { this._metadata = new Map(); this.app = express(); + // Enable cors for all requests. + this.app.use((req, res, next) => { + const corsHeaders: CorsHeaders = { + defaultResponse: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, PUT", + "Access-Control-Allow-Headers": "*", + }, + optionsResponse: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, PUT", + "Access-Control-Allow-Headers": "*", + "Access-Control-Max-Age": "86400", + }, + }; + const method = + req.method && req.method.toUpperCase && req.method.toUpperCase(); + + if (method === "OPTIONS") { + for (const [key, value] of Object.entries( + corsHeaders.optionsResponse + )) { + res.setHeader(key, value); + } + res.status(204).send(); + } else { + for (const [key, value] of Object.entries( + corsHeaders.defaultResponse + )) { + res.setHeader(key, value); + } + next(); + } + }); + this.app.put("*", (req, res) => { + const action = req.query.action; + if (action === BucketSignedUrlAction.DOWNLOAD) { + return res.status(403).send("Operation not allowed"); + } + + const key = req.path; + const hash = this.hashKey(key); + const filename = join(this._fileDir, hash); + + const actionType: BucketEventType = this._metadata.has(key) + ? BucketEventType.UPDATE + : BucketEventType.CREATE; + + let fileInfo: FileInfo | undefined; + + const bb = busboy({ headers: req.headers }); + bb.on("file", (_name, file, _info) => { + fileInfo = _info; + file.pipe(fs.createWriteStream(filename)); + }); + bb.on("close", () => { + void (async () => { + const filestat = await fs.promises.stat(filename); + const determinedContentType = + fileInfo?.mimeType ?? "application/octet-stream"; + + this._metadata.set(key, { + size: filestat.size, + lastModified: Datetime.fromDate(filestat.mtime), + contentType: determinedContentType, + }); + + await this.notifyListeners(actionType, key); + })(); + res.writeHead(200, { Connection: "close" }); + res.end(`That's all folks!`); + }); + req.pipe(bb); + return; + }); + this.app.get("*", (req, res) => { + const action = req.query.action; + if (action === BucketSignedUrlAction.UPLOAD) { + return res.status(403).send("Operation not allowed"); + } + + const hash = this.hashKey(req.path); + const filename = join(this._fileDir, hash); + return res.download(filename); + }); } private get context(): ISimulatorContext { @@ -143,7 +231,20 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { }; } - public async cleanup(): Promise {} + public async cleanup(): Promise { + this.addTrace(`Closing server on ${this.url}`, LogLevel.VERBOSE); + + return new Promise((resolve, reject) => { + this.server?.close((err) => { + if (err) { + return reject(err); + } + + this.server?.closeAllConnections(); + return resolve(); + }); + }); + } public async plan() { return UpdatePlan.AUTO; @@ -369,23 +470,23 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { ); } - return url.pathToFileURL(filePath).href; + return pathToFileURL(filePath).href; }, }); } public async signedUrl(key: string, options?: BucketSignedUrlOptions) { - const params = new URLSearchParams(); + const url = new URL(key, this.url); if (options?.action) { - params.set("action", options.action); + url.searchParams.set("action", options.action); } if (options?.duration) { - params.set( + url.searchParams.set( "validUntil", String(Datetime.utcNow().sec + options?.duration?.seconds) ); } - return `${this.url}/${key}?${params.toString()}`; + return url.toString(); } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b248ac1193..e37cdef0194 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,6 +354,9 @@ importers: ajv: specifier: ^8.16.0 version: 8.17.1 + busboy: + specifier: ^1.6.0 + version: 1.6.0 cdktf: specifier: 0.20.7 version: 0.20.7(constructs@10.3.0) @@ -425,6 +428,9 @@ importers: '@cdktf/provider-aws': specifier: ^19 version: 19.23.0(cdktf@0.20.7)(constructs@10.3.0) + '@types/busboy': + specifier: ^1.5.4 + version: 1.5.4 '@types/express': specifier: ^4.17.21 version: 4.17.21 @@ -10567,6 +10573,12 @@ packages: resolution: {integrity: sha512-ZYbcE2x7yrvNFJiU7xJGrpF/ihpkM7zKgw8bha3LNJSesvTtUNxbpzaT7WXBIryf6jovisrxTBvymxMeLLj1Mg==} dev: true + /@types/busboy@1.5.4: + resolution: {integrity: sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==} + dependencies: + '@types/node': 22.5.5 + dev: true + /@types/cacache@17.0.2: resolution: {integrity: sha512-IrqHzVX2VRMDQQKa7CtKRnuoCLdRJiLW6hWU+w7i7+AaQ0Ii5bKwJxd5uRK4zBCyrHd3tG6G8zOm2LplxbSfQg==} dependencies: @@ -10717,7 +10729,7 @@ packages: /@types/graceful-fs@4.1.9: resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} dependencies: - '@types/node': 20.14.8 + '@types/node': 22.5.5 dev: true /@types/hast@3.0.4: @@ -11091,7 +11103,7 @@ packages: resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} requiresBuild: true dependencies: - '@types/node': 20.14.8 + '@types/node': 22.5.5 dev: true optional: true @@ -12867,6 +12879,13 @@ packages: load-tsconfig: 0.2.5 dev: true + /busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + dependencies: + streamsearch: 1.1.0 + dev: false + /bytes@3.0.0: resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} engines: {node: '>= 0.8'} @@ -14299,7 +14318,7 @@ packages: dependencies: semver: 7.6.3 shelljs: 0.8.5 - typescript: 5.7.0-dev.20240916 + typescript: 5.7.0-dev.20240917 dev: true /dset@3.1.3: @@ -17281,7 +17300,7 @@ packages: resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@types/node': 20.14.8 + '@types/node': 22.5.5 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -21662,6 +21681,11 @@ packages: - supports-color dev: true + /streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + dev: false + /string-length@4.0.2: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} @@ -22621,8 +22645,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - /typescript@5.7.0-dev.20240916: - resolution: {integrity: sha512-PvqtY2wVBTIlHc0+Vs3w3UNntEzSho0uNm56c8D3mFINC/WFcEyOszkwxjSKyqVwEv62zl6xg+p7Ilt3H6yOJA==} + /typescript@5.7.0-dev.20240917: + resolution: {integrity: sha512-LoeyRa9RbB6lMasZvBmepDPrI6KNHURSleqbQ4BL4lxvXbCAQkMgmRzqyjA2DJE0ytuj5OEPFU5k/g3f9GaQKA==} engines: {node: '>=14.17'} hasBin: true dev: true From 6cfdaa414ef7bf1ba252e4a72f3027eb7f19cbcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Tue, 17 Sep 2024 16:24:40 +0200 Subject: [PATCH 03/16] wip --- .../sdk/src/target-sim/bucket.inflight.ts | 49 ++- .../__snapshots__/bucket.test.ts.snap | 344 +++--------------- .../sdk/test/target-sim/bucket.test.ts | 6 +- 3 files changed, 86 insertions(+), 313 deletions(-) diff --git a/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts b/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts index d437474d621..79f6b074652 100644 --- a/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts +++ b/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts @@ -106,6 +106,14 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { return res.status(403).send("Operation not allowed"); } + const validUntil = req.query.validUntil?.toString(); + if (validUntil) { + const validUntilMs = parseInt(validUntil) * 1000; + if (Date.now() > validUntilMs) { + return res.status(403).send("Signed URL has expired"); + } + } + const key = req.path; const hash = this.hashKey(key); const filename = join(this._fileDir, hash); @@ -122,21 +130,9 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { file.pipe(fs.createWriteStream(filename)); }); bb.on("close", () => { - void (async () => { - const filestat = await fs.promises.stat(filename); - const determinedContentType = - fileInfo?.mimeType ?? "application/octet-stream"; - - this._metadata.set(key, { - size: filestat.size, - lastModified: Datetime.fromDate(filestat.mtime), - contentType: determinedContentType, - }); - - await this.notifyListeners(actionType, key); - })(); + void this.updateMetadataAndNotify(key, actionType, fileInfo?.mimeType); res.writeHead(200, { Connection: "close" }); - res.end(`That's all folks!`); + res.end(); }); req.pipe(bb); return; @@ -147,6 +143,14 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { return res.status(403).send("Operation not allowed"); } + const validUntil = req.query.validUntil?.toString(); + if (validUntil) { + const validUntilMs = parseInt(validUntil) * 1000; + if (Date.now() > validUntilMs) { + return res.status(403).send("Signed URL has expired"); + } + } + const hash = this.hashKey(req.path); const filename = join(this._fileDir, hash); return res.download(filename); @@ -481,9 +485,11 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { url.searchParams.set("action", options.action); } if (options?.duration) { + // BUG: The `options?.duration` is supposed to be an instance of `Duration` but it is not. It's just + // a POJO with seconds, but TypeScript thinks otherwise. url.searchParams.set( "validUntil", - String(Datetime.utcNow().sec + options?.duration?.seconds) + String(Datetime.utcNow().ms + options.duration.seconds * 1000) ); } return url.toString(); @@ -551,10 +557,19 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { await fs.promises.mkdir(dirName, { recursive: true }); await fs.promises.writeFile(filename, value); + await this.updateMetadataAndNotify(key, actionType, contentType); + } + + private async updateMetadataAndNotify( + key: string, + actionType: BucketEventType, + contentType?: string + ): Promise { + const hash = this.hashKey(key); + const filename = join(this._fileDir, hash); const filestat = await fs.promises.stat(filename); const determinedContentType = - (contentType ?? mime.lookup(key)) || "application/octet-stream"; - + contentType ?? (mime.lookup(key) || "application/octet-stream"); this._metadata.set(key, { size: filestat.size, lastModified: Datetime.fromDate(filestat.mtime), diff --git a/packages/@winglang/sdk/test/target-sim/__snapshots__/bucket.test.ts.snap b/packages/@winglang/sdk/test/target-sim/__snapshots__/bucket.test.ts.snap index 98d98391b5a..c179333f7b6 100644 --- a/packages/@winglang/sdk/test/target-sim/__snapshots__/bucket.test.ts.snap +++ b/packages/@winglang/sdk/test/target-sim/__snapshots__/bucket.test.ts.snap @@ -5,8 +5,10 @@ exports[`bucket on event creates 3 topics, and sends the right event and key in "root/my_bucket/OnCreate started", "root/my_bucket/OnUpdate started", "root/my_bucket/OnDelete started", + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", + "Server listening on http://127.0.0.1:", "root/log_bucket started", "root/my_bucket/OnCreate/OnMessage0 started", "root/my_bucket/OnCreate/Policy started", @@ -40,6 +42,7 @@ exports[`bucket on event creates 3 topics, and sends the right event and key in "I am done", "Get (key=a).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", "root/my_bucket/OnCreate/Policy stopped", "root/my_bucket/OnCreate/TopicEventMapping0 stopped", @@ -54,6 +57,7 @@ exports[`bucket on event creates 3 topics, and sends the right event and key in "root/my_bucket/OnUpdate/OnMessage0 stopped", "root/my_bucket/OnDelete/OnMessage0 stopped", "root/log_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/log_bucket stopped", ] `; @@ -61,12 +65,14 @@ exports[`bucket on event creates 3 topics, and sends the right event and key in exports[`can add file in preflight 1`] = ` [ "Adding object from preflight (key=test.txt).", + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Get (key=test.txt).", "Get (key=test.txt).", "List (prefix=null).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; @@ -224,12 +230,14 @@ exports[`can add file in preflight 2`] = ` exports[`can add object in preflight 1`] = ` [ "Adding object from preflight (key=greeting.txt).", + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Get (key=greeting.txt).", "Get (key=greeting.txt).", "List (prefix=null).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; @@ -384,314 +392,45 @@ exports[`can add object in preflight 2`] = ` } `; -exports[`create a bucket 1`] = ` -{ - "connections.json": { - "connections": [], - "version": "connections-0.1", - }, - "simulator.json": { - "resources": { - "root/my_bucket": { - "addr": "c8220a82a4ad9c25a4f236946f350d2ac081dd7bbc", - "path": "root/my_bucket", - "props": { - "initialObjects": {}, - "public": false, - "topics": {}, - }, - "type": "@winglang/sdk.cloud.Bucket", - }, - "root/my_bucket/Policy": { - "addr": "c8b5ba55132964ee19331fb9f46241560e67fed76b", - "path": "root/my_bucket/Policy", - "props": { - "principal": "\${wsim#root/my_bucket#attrs.handle}", - "statements": [], - }, - "type": "@winglang/sdk.sim.Policy", - }, - }, - "sdkVersion": "0.0.0", - "types": { - "@winglang/sdk.cloud.Api": { - "className": "Api", - "sourcePath": "/api.inflight.js", - }, - "@winglang/sdk.cloud.Bucket": { - "className": "Bucket", - "sourcePath": "/bucket.inflight.js", - }, - "@winglang/sdk.cloud.Domain": { - "className": "Domain", - "sourcePath": "/domain.inflight.js", - }, - "@winglang/sdk.cloud.Endpoint": { - "className": "Endpoint", - "sourcePath": "/endpoint.inflight.js", - }, - "@winglang/sdk.cloud.Function": { - "className": "Function", - "sourcePath": "/function.inflight.js", - }, - "@winglang/sdk.cloud.OnDeploy": { - "className": "OnDeploy", - "sourcePath": "/on-deploy.inflight.js", - }, - "@winglang/sdk.cloud.Queue": { - "className": "Queue", - "sourcePath": "/queue.inflight.js", - }, - "@winglang/sdk.cloud.Schedule": { - "className": "Schedule", - "sourcePath": "/schedule.inflight.js", - }, - "@winglang/sdk.cloud.Secret": { - "className": "Secret", - "sourcePath": "/secret.inflight.js", - }, - "@winglang/sdk.cloud.Service": { - "className": "Service", - "sourcePath": "/service.inflight.js", - }, - "@winglang/sdk.cloud.Topic": { - "className": "Topic", - "sourcePath": "/topic.inflight.js", - }, - "@winglang/sdk.cloud.Website": { - "className": "Website", - "sourcePath": "/website.inflight.js", - }, - "@winglang/sdk.sim.Container": { - "className": "Container", - "sourcePath": "/container.inflight.js", - }, - "@winglang/sdk.sim.EventMapping": { - "className": "EventMapping", - "sourcePath": "/event-mapping.inflight.js", - }, - "@winglang/sdk.sim.Policy": { - "className": "Policy", - "sourcePath": "/policy.inflight.js", - }, - "@winglang/sdk.sim.Resource": { - "className": "Resource", - "sourcePath": "/resource.inflight.js", - }, - "@winglang/sdk.sim.State": { - "className": "State", - "sourcePath": "/state.inflight.js", - }, - "@winglang/sdk.std.TestRunner": { - "className": "TestRunner", - "sourcePath": "/test-runner.inflight.js", - }, - }, - }, - "tree.json": { - "tree": { - "children": { - "my_bucket": { - "children": { - "Policy": { - "constructInfo": { - "fqn": "constructs.Construct", - "version": "10.3.0", - }, - "display": { - "description": "A simulated resource policy", - "hidden": true, - "title": "Policy", - }, - "id": "Policy", - "path": "root/my_bucket/Policy", - }, - }, - "constructInfo": { - "fqn": "constructs.Construct", - "version": "10.3.0", - }, - "display": { - "description": "A cloud object store", - "title": "Bucket", - }, - "id": "my_bucket", - "path": "root/my_bucket", - }, - }, - "constructInfo": { - "fqn": "constructs.Construct", - "version": "10.3.0", - }, - "display": {}, - "id": "root", - "path": "root", - }, - "version": "tree-0.1", - }, -} -`; - exports[`get invalid object throws an error 1`] = ` [ + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Error: Object does not exist (key=unknown.txt). (Get (key=unknown.txt).)", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; exports[`get invalid object throws an error 2`] = ` -{ - "connections.json": { - "connections": [], - "version": "connections-0.1", - }, - "simulator.json": { - "resources": { - "root/my_bucket": { - "addr": "c8220a82a4ad9c25a4f236946f350d2ac081dd7bbc", - "path": "root/my_bucket", - "props": { - "initialObjects": {}, - "public": false, - "topics": {}, - }, - "type": "@winglang/sdk.cloud.Bucket", - }, - "root/my_bucket/Policy": { - "addr": "c8b5ba55132964ee19331fb9f46241560e67fed76b", - "path": "root/my_bucket/Policy", - "props": { - "principal": "\${wsim#root/my_bucket#attrs.handle}", - "statements": [], - }, - "type": "@winglang/sdk.sim.Policy", - }, - }, - "sdkVersion": "0.0.0", - "types": { - "@winglang/sdk.cloud.Api": { - "className": "Api", - "sourcePath": "/api.inflight.js", - }, - "@winglang/sdk.cloud.Bucket": { - "className": "Bucket", - "sourcePath": "/bucket.inflight.js", - }, - "@winglang/sdk.cloud.Domain": { - "className": "Domain", - "sourcePath": "/domain.inflight.js", - }, - "@winglang/sdk.cloud.Endpoint": { - "className": "Endpoint", - "sourcePath": "/endpoint.inflight.js", - }, - "@winglang/sdk.cloud.Function": { - "className": "Function", - "sourcePath": "/function.inflight.js", - }, - "@winglang/sdk.cloud.OnDeploy": { - "className": "OnDeploy", - "sourcePath": "/on-deploy.inflight.js", - }, - "@winglang/sdk.cloud.Queue": { - "className": "Queue", - "sourcePath": "/queue.inflight.js", - }, - "@winglang/sdk.cloud.Schedule": { - "className": "Schedule", - "sourcePath": "/schedule.inflight.js", - }, - "@winglang/sdk.cloud.Secret": { - "className": "Secret", - "sourcePath": "/secret.inflight.js", - }, - "@winglang/sdk.cloud.Service": { - "className": "Service", - "sourcePath": "/service.inflight.js", - }, - "@winglang/sdk.cloud.Topic": { - "className": "Topic", - "sourcePath": "/topic.inflight.js", - }, - "@winglang/sdk.cloud.Website": { - "className": "Website", - "sourcePath": "/website.inflight.js", - }, - "@winglang/sdk.sim.Container": { - "className": "Container", - "sourcePath": "/container.inflight.js", - }, - "@winglang/sdk.sim.EventMapping": { - "className": "EventMapping", - "sourcePath": "/event-mapping.inflight.js", - }, - "@winglang/sdk.sim.Policy": { - "className": "Policy", - "sourcePath": "/policy.inflight.js", - }, - "@winglang/sdk.sim.Resource": { - "className": "Resource", - "sourcePath": "/resource.inflight.js", - }, - "@winglang/sdk.sim.State": { - "className": "State", - "sourcePath": "/state.inflight.js", - }, - "@winglang/sdk.std.TestRunner": { - "className": "TestRunner", - "sourcePath": "/test-runner.inflight.js", - }, - }, - }, - "tree.json": { - "tree": { - "children": { - "my_bucket": { - "children": { - "Policy": { - "constructInfo": { - "fqn": "constructs.Construct", - "version": "10.3.0", - }, - "display": { - "description": "A simulated resource policy", - "hidden": true, - "title": "Policy", - }, - "id": "Policy", - "path": "root/my_bucket/Policy", - }, - }, - "constructInfo": { - "fqn": "constructs.Construct", - "version": "10.3.0", - }, - "display": { - "description": "A cloud object store", - "title": "Bucket", - }, - "id": "my_bucket", - "path": "root/my_bucket", - }, - }, - "constructInfo": { - "fqn": "constructs.Construct", - "version": "10.3.0", - }, - "display": {}, - "id": "root", - "path": "root", - }, - "version": "tree-0.1", - }, -} +[ + "Server listening on http://127.0.0.1:", + "root/my_bucket started", + "root/my_bucket/Policy started", + "Error: Object does not exist (key=unknown.txt). (Get (key=unknown.txt).)", + "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", + "root/my_bucket stopped", +] +`; + +exports[`get invalid object throws an error 3`] = ` +[ + "Server listening on http://127.0.0.1:", + "root/my_bucket started", + "root/my_bucket/Policy started", + "Error: Object does not exist (key=unknown.txt). (Get (key=unknown.txt).)", + "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", + "root/my_bucket stopped", +] `; exports[`list respects prefixes 1`] = ` [ + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Put (key=path/dir1/file1.txt).", @@ -701,12 +440,14 @@ exports[`list respects prefixes 1`] = ` "List (prefix=path/dir1).", "List (prefix=path/dir2).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; exports[`objects can have keys that look like directories 1`] = ` [ + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Put (key=foo).", @@ -721,12 +462,14 @@ exports[`objects can have keys that look like directories 1`] = ` "List (prefix=foo/bar/).", "List (prefix=foo/bar/baz).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; exports[`put and get metadata of objects from bucket 1`] = ` [ + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Put (key=file1.main.w).", @@ -736,23 +479,27 @@ exports[`put and get metadata of objects from bucket 1`] = ` "Metadata (key=file2.txt).", "Metadata (key=file3.txt).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; exports[`put and get object from bucket 1`] = ` [ + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Put (key=greeting.txt).", "Get (key=greeting.txt).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; exports[`put multiple json objects and list all from bucket 1`] = ` [ + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Put Json (key=greeting1.json).", @@ -760,12 +507,14 @@ exports[`put multiple json objects and list all from bucket 1`] = ` "Put Json (key=greeting3.json).", "List (prefix=null).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; exports[`put multiple objects and list all from bucket 1`] = ` [ + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Put (key=greeting1.txt).", @@ -773,28 +522,33 @@ exports[`put multiple objects and list all from bucket 1`] = ` "Put (key=greeting3.txt).", "List (prefix=null).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; exports[`remove object from a bucket 1`] = ` [ + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Put (key=unknown.txt).", "Delete (key=unknown.txt).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; exports[`remove object from a bucket with mustExist as option 1`] = ` [ + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "Put (key=unknown.txt).", "Delete (key=unknown.txt).", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", ] `; @@ -802,6 +556,7 @@ exports[`remove object from a bucket with mustExist as option 1`] = ` exports[`removing a key will call onDelete method 1`] = ` [ "root/my_bucket/OnDelete started", + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "root/my_bucket/OnDelete/OnMessage0 started", @@ -814,6 +569,7 @@ exports[`removing a key will call onDelete method 1`] = ` "Delete (key=unknown.txt).", "Received unknown.txt", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", "root/my_bucket/OnDelete/Policy stopped", "root/my_bucket/OnDelete/TopicEventMapping0 stopped", @@ -825,6 +581,7 @@ exports[`removing a key will call onDelete method 1`] = ` exports[`update an object in bucket 1`] = ` [ "root/my_bucket/OnCreate started", + "Server listening on http://127.0.0.1:", "root/my_bucket started", "root/my_bucket/Policy started", "root/my_bucket/OnCreate/OnMessage0 started", @@ -837,6 +594,7 @@ exports[`update an object in bucket 1`] = ` "Put (key=1.txt).", "I am done", "root/my_bucket/Policy stopped", + "Closing server on http://127.0.0.1:", "root/my_bucket stopped", "root/my_bucket/OnCreate/Policy stopped", "root/my_bucket/OnCreate/TopicEventMapping0 stopped", diff --git a/packages/@winglang/sdk/test/target-sim/bucket.test.ts b/packages/@winglang/sdk/test/target-sim/bucket.test.ts index 8f86c51061b..568af1d1fec 100644 --- a/packages/@winglang/sdk/test/target-sim/bucket.test.ts +++ b/packages/@winglang/sdk/test/target-sim/bucket.test.ts @@ -938,7 +938,7 @@ test("bucket ignores corrupted state file", async () => { expect(files).toEqual(["b"]); }); -test("signedUrl is not implemented for the simulator", async () => { +test("signedUrl is implemented for the simulator", async () => { // GIVEN const app = new SimApp(); new cloud.Bucket(app, "my_bucket"); @@ -947,8 +947,8 @@ test("signedUrl is not implemented for the simulator", async () => { const client = s.getResource("/my_bucket") as cloud.IBucketClient; // THEN - await expect(() => client.signedUrl("key")).rejects.toThrowError( - "signedUrl is not implemented yet" + await expect(() => client.signedUrl("key")).resolves.toMatch( + /^http:\/\/localhost:\d+\//i ); await s.stop(); }); From f0e0f02ce0502430ffed5b84718dece3560fbeba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Tue, 17 Sep 2024 16:43:59 +0200 Subject: [PATCH 04/16] wip --- .../__snapshots__/bucket.test.ts.snap | 148 ++++++++++++++++++ .../sdk/test/target-sim/bucket.test.ts | 17 +- 2 files changed, 162 insertions(+), 3 deletions(-) diff --git a/packages/@winglang/sdk/test/target-sim/__snapshots__/bucket.test.ts.snap b/packages/@winglang/sdk/test/target-sim/__snapshots__/bucket.test.ts.snap index c179333f7b6..2b785f19269 100644 --- a/packages/@winglang/sdk/test/target-sim/__snapshots__/bucket.test.ts.snap +++ b/packages/@winglang/sdk/test/target-sim/__snapshots__/bucket.test.ts.snap @@ -392,6 +392,154 @@ exports[`can add object in preflight 2`] = ` } `; +exports[`create a bucket 1`] = ` +{ + "connections.json": { + "connections": [], + "version": "connections-0.1", + }, + "simulator.json": { + "resources": { + "root/my_bucket": { + "addr": "c8220a82a4ad9c25a4f236946f350d2ac081dd7bbc", + "path": "root/my_bucket", + "props": { + "initialObjects": {}, + "public": false, + "topics": {}, + }, + "type": "@winglang/sdk.cloud.Bucket", + }, + "root/my_bucket/Policy": { + "addr": "c8b5ba55132964ee19331fb9f46241560e67fed76b", + "path": "root/my_bucket/Policy", + "props": { + "principal": "\${wsim#root/my_bucket#attrs.handle}", + "statements": [], + }, + "type": "@winglang/sdk.sim.Policy", + }, + }, + "sdkVersion": "0.0.0", + "types": { + "@winglang/sdk.cloud.Api": { + "className": "Api", + "sourcePath": "/api.inflight.js", + }, + "@winglang/sdk.cloud.Bucket": { + "className": "Bucket", + "sourcePath": "/bucket.inflight.js", + }, + "@winglang/sdk.cloud.Domain": { + "className": "Domain", + "sourcePath": "/domain.inflight.js", + }, + "@winglang/sdk.cloud.Endpoint": { + "className": "Endpoint", + "sourcePath": "/endpoint.inflight.js", + }, + "@winglang/sdk.cloud.Function": { + "className": "Function", + "sourcePath": "/function.inflight.js", + }, + "@winglang/sdk.cloud.OnDeploy": { + "className": "OnDeploy", + "sourcePath": "/on-deploy.inflight.js", + }, + "@winglang/sdk.cloud.Queue": { + "className": "Queue", + "sourcePath": "/queue.inflight.js", + }, + "@winglang/sdk.cloud.Schedule": { + "className": "Schedule", + "sourcePath": "/schedule.inflight.js", + }, + "@winglang/sdk.cloud.Secret": { + "className": "Secret", + "sourcePath": "/secret.inflight.js", + }, + "@winglang/sdk.cloud.Service": { + "className": "Service", + "sourcePath": "/service.inflight.js", + }, + "@winglang/sdk.cloud.Topic": { + "className": "Topic", + "sourcePath": "/topic.inflight.js", + }, + "@winglang/sdk.cloud.Website": { + "className": "Website", + "sourcePath": "/website.inflight.js", + }, + "@winglang/sdk.sim.Container": { + "className": "Container", + "sourcePath": "/container.inflight.js", + }, + "@winglang/sdk.sim.EventMapping": { + "className": "EventMapping", + "sourcePath": "/event-mapping.inflight.js", + }, + "@winglang/sdk.sim.Policy": { + "className": "Policy", + "sourcePath": "/policy.inflight.js", + }, + "@winglang/sdk.sim.Resource": { + "className": "Resource", + "sourcePath": "/resource.inflight.js", + }, + "@winglang/sdk.sim.State": { + "className": "State", + "sourcePath": "/state.inflight.js", + }, + "@winglang/sdk.std.TestRunner": { + "className": "TestRunner", + "sourcePath": "/test-runner.inflight.js", + }, + }, + }, + "tree.json": { + "tree": { + "children": { + "my_bucket": { + "children": { + "Policy": { + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0", + }, + "display": { + "description": "A simulated resource policy", + "hidden": true, + "title": "Policy", + }, + "id": "Policy", + "path": "root/my_bucket/Policy", + }, + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0", + }, + "display": { + "description": "A cloud object store", + "title": "Bucket", + }, + "id": "my_bucket", + "path": "root/my_bucket", + }, + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0", + }, + "display": {}, + "id": "root", + "path": "root", + }, + "version": "tree-0.1", + }, +} +`; + exports[`get invalid object throws an error 1`] = ` [ "Server listening on http://127.0.0.1:", diff --git a/packages/@winglang/sdk/test/target-sim/bucket.test.ts b/packages/@winglang/sdk/test/target-sim/bucket.test.ts index 568af1d1fec..5e8808f5c6b 100644 --- a/packages/@winglang/sdk/test/target-sim/bucket.test.ts +++ b/packages/@winglang/sdk/test/target-sim/bucket.test.ts @@ -20,6 +20,7 @@ test("create a bucket", async () => { expect(s.getResourceConfig("/my_bucket")).toEqual({ attrs: { handle: expect.any(String), + url: expect.any(String), }, path: "root/my_bucket", addr: expect.any(String), @@ -946,10 +947,20 @@ test("signedUrl is implemented for the simulator", async () => { const s = await app.startSimulator(); const client = s.getResource("/my_bucket") as cloud.IBucketClient; + // WHEN + const formData = new FormData(); + formData.set("file", new Blob(["Hello, World!"], { type: "text/utf8" })); + + const signedUrl = await client.signedUrl("key"); + const response = await fetch(signedUrl, { + method: "PUT", + body: formData, + }); + // THEN - await expect(() => client.signedUrl("key")).resolves.toMatch( - /^http:\/\/localhost:\d+\//i - ); + expect(response.ok).toBe(true); + await expect(client.get("key")).resolves.toBe("Hello, World!"); + await s.stop(); }); From 87e272e4acbbbd168e611929bfcb05361dfb66d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Tue, 17 Sep 2024 16:56:16 +0200 Subject: [PATCH 05/16] wip --- .../sdk/src/target-sim/bucket.inflight.ts | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts b/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts index 79f6b074652..0051a9b0c8e 100644 --- a/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts +++ b/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts @@ -480,19 +480,24 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { } public async signedUrl(key: string, options?: BucketSignedUrlOptions) { - const url = new URL(key, this.url); - if (options?.action) { - url.searchParams.set("action", options.action); - } - if (options?.duration) { - // BUG: The `options?.duration` is supposed to be an instance of `Duration` but it is not. It's just - // a POJO with seconds, but TypeScript thinks otherwise. - url.searchParams.set( - "validUntil", - String(Datetime.utcNow().ms + options.duration.seconds * 1000) - ); - } - return url.toString(); + return this.context.withTrace({ + message: `Signed URL (key=${key}).`, + activity: async () => { + const url = new URL(key, this.url); + if (options?.action) { + url.searchParams.set("action", options.action); + } + if (options?.duration) { + // BUG: The `options?.duration` is supposed to be an instance of `Duration` but it is not. It's just + // a POJO with seconds, but TypeScript thinks otherwise. + url.searchParams.set( + "validUntil", + String(Datetime.utcNow().ms + options.duration.seconds * 1000) + ); + } + return url.toString(); + }, + }); } /** From 7ce61ed478ac3fb28a6d7279ba60c17f03142946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Tue, 17 Sep 2024 17:36:01 +0200 Subject: [PATCH 06/16] wip --- .../sdk/src/target-sim/api.inflight.ts | 47 +---- .../sdk/src/target-sim/bucket.inflight.ts | 69 +++----- packages/@winglang/sdk/src/target-sim/util.ts | 51 ++++++ .../__snapshots__/bucket.test.ts.snap | 166 +++++++++++++++--- .../sdk/test/target-sim/bucket.test.ts | 6 +- tests/sdk_tests/bucket/signed_url.test.w | 136 +++++++------- 6 files changed, 293 insertions(+), 182 deletions(-) diff --git a/packages/@winglang/sdk/src/target-sim/api.inflight.ts b/packages/@winglang/sdk/src/target-sim/api.inflight.ts index 44e66385430..9dce3682bcb 100644 --- a/packages/@winglang/sdk/src/target-sim/api.inflight.ts +++ b/packages/@winglang/sdk/src/target-sim/api.inflight.ts @@ -1,6 +1,5 @@ import * as fs from "fs"; import { Server } from "http"; -import { AddressInfo, Socket } from "net"; import { join } from "path"; import express from "express"; import { IEventPublisher } from "./event-mapping"; @@ -10,7 +9,7 @@ import { ApiRoute, EventSubscription, } from "./schema-resources"; -import { exists } from "./util"; +import { exists, isPortAvailable, listenExpress } from "./util"; import { API_FQN, ApiRequest, @@ -28,8 +27,6 @@ import { } from "../simulator/simulator"; import { LogLevel, Json, TraceType } from "../std"; -const LOCALHOST_ADDRESS = "127.0.0.1"; - const STATE_FILENAME = "state.json"; /** @@ -114,21 +111,10 @@ export class Api } } - // `server.address()` returns `null` until the server is listening - // on a port. We use a promise to wait for the server to start - // listening before returning the URL. - const addrInfo: AddressInfo = await new Promise((resolve, reject) => { - this.server = this.app.listen(lastPort ?? 0, LOCALHOST_ADDRESS, () => { - const addr = this.server?.address(); - if (addr && typeof addr === "object" && (addr as AddressInfo).port) { - resolve(addr); - } else { - reject(new Error("No address found")); - } - }); - }); - this.port = addrInfo.port; - this.url = `http://${addrInfo.address}:${addrInfo.port}`; + const { server, address } = await listenExpress(this.app, lastPort); + this.server = server; + this.port = address.port; + this.url = `http://${address.address}:${address.port}`; this.addTrace(`Server listening on ${this.url}`, LogLevel.VERBOSE); @@ -343,26 +329,3 @@ function asyncMiddleware( Promise.resolve(fn(req, res, next)).catch(next); }; } - -async function isPortAvailable(port: number): Promise { - return new Promise((resolve, _reject) => { - const s = new Socket(); - s.once("error", (err) => { - s.destroy(); - if ((err as any).code !== "ECONNREFUSED") { - resolve(false); - } else { - // connection refused means the port is not used - resolve(true); - } - }); - - s.once("connect", () => { - s.destroy(); - // connection successful means the port is used - resolve(false); - }); - - s.connect(port, LOCALHOST_ADDRESS); - }); -} diff --git a/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts b/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts index 0051a9b0c8e..4e30d31e333 100644 --- a/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts +++ b/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts @@ -1,14 +1,13 @@ import * as crypto from "crypto"; import * as fs from "fs"; import { Server } from "http"; -import { AddressInfo, Socket } from "net"; import { dirname, join } from "path"; import { pathToFileURL } from "url"; import busboy, { FileInfo } from "busboy"; import express from "express"; import mime from "mime-types"; import { BucketAttributes, BucketSchema } from "./schema-resources"; -import { exists } from "./util"; +import { exists, isPortAvailable, listenExpress } from "./util"; import { ITopicClient, BucketSignedUrlOptions, @@ -33,8 +32,6 @@ import { Datetime, Json, LogLevel, TraceType } from "../std"; export const METADATA_FILENAME = "metadata.json"; -const LOCALHOST_ADDRESS = "127.0.0.1"; - const STATE_FILENAME = "state.json"; /** @@ -66,6 +63,7 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { this._metadata = new Map(); this.app = express(); + // Enable cors for all requests. this.app.use((req, res, next) => { const corsHeaders: CorsHeaders = { @@ -100,6 +98,8 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { next(); } }); + + // Handle signed URL uploads. this.app.put("*", (req, res) => { const action = req.query.action; if (action === BucketSignedUrlAction.DOWNLOAD) { @@ -114,7 +114,7 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { } } - const key = req.path; + const key = req.path.slice(1); // remove leading slash const hash = this.hashKey(key); const filename = join(this._fileDir, hash); @@ -130,13 +130,21 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { file.pipe(fs.createWriteStream(filename)); }); bb.on("close", () => { - void this.updateMetadataAndNotify(key, actionType, fileInfo?.mimeType); - res.writeHead(200, { Connection: "close" }); - res.end(); + console.log("file uploaded", filename, hash); + void this.updateMetadataAndNotify( + key, + actionType, + fileInfo?.mimeType + ).then(() => { + res.writeHead(200, { Connection: "close" }); + res.end(); + }); }); req.pipe(bb); return; }); + + // Handle signed URL downloads. this.app.get("*", (req, res) => { const action = req.query.action; if (action === BucketSignedUrlAction.UPLOAD) { @@ -151,7 +159,8 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { } } - const hash = this.hashKey(req.path); + const key = req.path.slice(1); // remove leading slash + const hash = this.hashKey(key); const filename = join(this._fileDir, hash); return res.download(filename); }); @@ -212,21 +221,10 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { } } - // `server.address()` returns `null` until the server is listening - // on a port. We use a promise to wait for the server to start - // listening before returning the URL. - const addrInfo: AddressInfo = await new Promise((resolve, reject) => { - this.server = this.app.listen(lastPort ?? 0, LOCALHOST_ADDRESS, () => { - const addr = this.server?.address(); - if (addr && typeof addr === "object" && (addr as AddressInfo).port) { - resolve(addr); - } else { - reject(new Error("No address found")); - } - }); - }); - this.port = addrInfo.port; - this.url = `http://${addrInfo.address}:${addrInfo.port}`; + const { server, address } = await listenExpress(this.app, lastPort); + this.server = server; + this.port = address.port; + this.url = `http://${address.address}:${address.port}`; this.addTrace(`Server listening on ${this.url}`, LogLevel.VERBOSE); @@ -599,26 +597,3 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { }); } } - -async function isPortAvailable(port: number): Promise { - return new Promise((resolve, _reject) => { - const s = new Socket(); - s.once("error", (err) => { - s.destroy(); - if ((err as any).code !== "ECONNREFUSED") { - resolve(false); - } else { - // connection refused means the port is not used - resolve(true); - } - }); - - s.once("connect", () => { - s.destroy(); - // connection successful means the port is used - resolve(false); - }); - - s.connect(port, LOCALHOST_ADDRESS); - }); -} diff --git a/packages/@winglang/sdk/src/target-sim/util.ts b/packages/@winglang/sdk/src/target-sim/util.ts index fb6a3fff77e..987e8fa6910 100644 --- a/packages/@winglang/sdk/src/target-sim/util.ts +++ b/packages/@winglang/sdk/src/target-sim/util.ts @@ -1,6 +1,9 @@ import { access, constants } from "fs"; +import { Server } from "http"; +import { AddressInfo, Socket } from "net"; import { promisify } from "util"; import { IConstruct } from "constructs"; +import type { Application } from "express"; import { isSimulatorInflightHost } from "./resource"; import { simulatorHandleToken } from "./tokens"; import { Duration, IInflightHost, Resource } from "../std"; @@ -171,3 +174,51 @@ export function convertDurationToCronExpression(dur: Duration): string { const cronString = `${minute} ${hour} ${dayInMonth} ${month} ${dayOfWeek}`; return cronString; } + +const LOCALHOST_ADDRESS = "127.0.0.1"; + +export async function isPortAvailable(port: number): Promise { + return new Promise((resolve, _reject) => { + const s = new Socket(); + s.once("error", (err) => { + s.destroy(); + if ((err as any).code !== "ECONNREFUSED") { + resolve(false); + } else { + // connection refused means the port is not used + resolve(true); + } + }); + + s.once("connect", () => { + s.destroy(); + // connection successful means the port is used + resolve(false); + }); + + s.connect(port, LOCALHOST_ADDRESS); + }); +} + +export async function listenExpress( + app: Application, + port?: number +): Promise<{ server: Server; address: AddressInfo }> { + // `server.address()` returns `null` until the server is listening + // on a port. We use a promise to wait for the server to start + // listening before returning the URL. + return new Promise((resolve, reject) => { + const server = app.listen(port ?? 0, LOCALHOST_ADDRESS, () => { + const address = server.address(); + if ( + address && + typeof address === "object" && + (address as AddressInfo).port + ) { + resolve({ server, address }); + } else { + reject(new Error("No address found")); + } + }); + }); +} diff --git a/packages/@winglang/sdk/test/target-sim/__snapshots__/bucket.test.ts.snap b/packages/@winglang/sdk/test/target-sim/__snapshots__/bucket.test.ts.snap index 2b785f19269..cf6f4dbd626 100644 --- a/packages/@winglang/sdk/test/target-sim/__snapshots__/bucket.test.ts.snap +++ b/packages/@winglang/sdk/test/target-sim/__snapshots__/bucket.test.ts.snap @@ -553,27 +553,151 @@ exports[`get invalid object throws an error 1`] = ` `; exports[`get invalid object throws an error 2`] = ` -[ - "Server listening on http://127.0.0.1:", - "root/my_bucket started", - "root/my_bucket/Policy started", - "Error: Object does not exist (key=unknown.txt). (Get (key=unknown.txt).)", - "root/my_bucket/Policy stopped", - "Closing server on http://127.0.0.1:", - "root/my_bucket stopped", -] -`; - -exports[`get invalid object throws an error 3`] = ` -[ - "Server listening on http://127.0.0.1:", - "root/my_bucket started", - "root/my_bucket/Policy started", - "Error: Object does not exist (key=unknown.txt). (Get (key=unknown.txt).)", - "root/my_bucket/Policy stopped", - "Closing server on http://127.0.0.1:", - "root/my_bucket stopped", -] +{ + "connections.json": { + "connections": [], + "version": "connections-0.1", + }, + "simulator.json": { + "resources": { + "root/my_bucket": { + "addr": "c8220a82a4ad9c25a4f236946f350d2ac081dd7bbc", + "path": "root/my_bucket", + "props": { + "initialObjects": {}, + "public": false, + "topics": {}, + }, + "type": "@winglang/sdk.cloud.Bucket", + }, + "root/my_bucket/Policy": { + "addr": "c8b5ba55132964ee19331fb9f46241560e67fed76b", + "path": "root/my_bucket/Policy", + "props": { + "principal": "\${wsim#root/my_bucket#attrs.handle}", + "statements": [], + }, + "type": "@winglang/sdk.sim.Policy", + }, + }, + "sdkVersion": "0.0.0", + "types": { + "@winglang/sdk.cloud.Api": { + "className": "Api", + "sourcePath": "/api.inflight.js", + }, + "@winglang/sdk.cloud.Bucket": { + "className": "Bucket", + "sourcePath": "/bucket.inflight.js", + }, + "@winglang/sdk.cloud.Domain": { + "className": "Domain", + "sourcePath": "/domain.inflight.js", + }, + "@winglang/sdk.cloud.Endpoint": { + "className": "Endpoint", + "sourcePath": "/endpoint.inflight.js", + }, + "@winglang/sdk.cloud.Function": { + "className": "Function", + "sourcePath": "/function.inflight.js", + }, + "@winglang/sdk.cloud.OnDeploy": { + "className": "OnDeploy", + "sourcePath": "/on-deploy.inflight.js", + }, + "@winglang/sdk.cloud.Queue": { + "className": "Queue", + "sourcePath": "/queue.inflight.js", + }, + "@winglang/sdk.cloud.Schedule": { + "className": "Schedule", + "sourcePath": "/schedule.inflight.js", + }, + "@winglang/sdk.cloud.Secret": { + "className": "Secret", + "sourcePath": "/secret.inflight.js", + }, + "@winglang/sdk.cloud.Service": { + "className": "Service", + "sourcePath": "/service.inflight.js", + }, + "@winglang/sdk.cloud.Topic": { + "className": "Topic", + "sourcePath": "/topic.inflight.js", + }, + "@winglang/sdk.cloud.Website": { + "className": "Website", + "sourcePath": "/website.inflight.js", + }, + "@winglang/sdk.sim.Container": { + "className": "Container", + "sourcePath": "/container.inflight.js", + }, + "@winglang/sdk.sim.EventMapping": { + "className": "EventMapping", + "sourcePath": "/event-mapping.inflight.js", + }, + "@winglang/sdk.sim.Policy": { + "className": "Policy", + "sourcePath": "/policy.inflight.js", + }, + "@winglang/sdk.sim.Resource": { + "className": "Resource", + "sourcePath": "/resource.inflight.js", + }, + "@winglang/sdk.sim.State": { + "className": "State", + "sourcePath": "/state.inflight.js", + }, + "@winglang/sdk.std.TestRunner": { + "className": "TestRunner", + "sourcePath": "/test-runner.inflight.js", + }, + }, + }, + "tree.json": { + "tree": { + "children": { + "my_bucket": { + "children": { + "Policy": { + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0", + }, + "display": { + "description": "A simulated resource policy", + "hidden": true, + "title": "Policy", + }, + "id": "Policy", + "path": "root/my_bucket/Policy", + }, + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0", + }, + "display": { + "description": "A cloud object store", + "title": "Bucket", + }, + "id": "my_bucket", + "path": "root/my_bucket", + }, + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0", + }, + "display": {}, + "id": "root", + "path": "root", + }, + "version": "tree-0.1", + }, +} `; exports[`list respects prefixes 1`] = ` diff --git a/packages/@winglang/sdk/test/target-sim/bucket.test.ts b/packages/@winglang/sdk/test/target-sim/bucket.test.ts index 5e8808f5c6b..39cbdee9f97 100644 --- a/packages/@winglang/sdk/test/target-sim/bucket.test.ts +++ b/packages/@winglang/sdk/test/target-sim/bucket.test.ts @@ -334,7 +334,9 @@ test("get invalid object throws an error", async () => { await s.stop(); expect(listMessages(s)).toMatchSnapshot(); - expect(s.listTraces()[2].data.status).toEqual("failure"); + expect( + s.listTraces().filter((t) => t.data.status === "failure") + ).toHaveLength(1); expect(app.snapshot()).toMatchSnapshot(); }); @@ -939,7 +941,7 @@ test("bucket ignores corrupted state file", async () => { expect(files).toEqual(["b"]); }); -test("signedUrl is implemented for the simulator", async () => { +test.only("signedUrl is implemented for the simulator", async () => { // GIVEN const app = new SimApp(); new cloud.Bucket(app, "my_bucket"); diff --git a/tests/sdk_tests/bucket/signed_url.test.w b/tests/sdk_tests/bucket/signed_url.test.w index 5df8b055127..6a44570df08 100644 --- a/tests/sdk_tests/bucket/signed_url.test.w +++ b/tests/sdk_tests/bucket/signed_url.test.w @@ -5,98 +5,94 @@ bring expect; let bucket = new cloud.Bucket(); -// TODO: signedUrl is not implemented for the simulator yet -// https://github.com/winglang/wing/issues/1383 -if util.env("WING_TARGET") != "sim" { - test "signedUrl GET (implicit)" { - let KEY = "tempfile.txt"; - let VALUE = "Hello, Wing!"; +test "signedUrl GET (implicit)" { + let KEY = "tempfile.txt"; + let VALUE = "Hello, Wing!"; - bucket.put(KEY, VALUE); + bucket.put(KEY, VALUE); - let getSignedUrl = bucket.signedUrl(KEY); + let getSignedUrl = bucket.signedUrl(KEY); - // Download file from private bucket using GET presigned URL - let output = util.shell("curl \"{getSignedUrl}\""); + // Download file from private bucket using GET presigned URL + let output = util.shell("curl \"{getSignedUrl}\""); - expect.equal(output, VALUE); - } - - test "signedUrl GET (explicit)" { - let KEY = "tempfile.txt"; - let VALUE = "Hello, Wing!"; + expect.equal(output, VALUE); +} - bucket.put(KEY, VALUE); +test "signedUrl GET (explicit)" { + let KEY = "tempfile.txt"; + let VALUE = "Hello, Wing!"; - let getSignedUrl = bucket.signedUrl(KEY, { action: cloud.BucketSignedUrlAction.DOWNLOAD }); + bucket.put(KEY, VALUE); - // Download file from private bucket using GET presigned URL - let output = util.shell("curl \"{getSignedUrl}\""); + let getSignedUrl = bucket.signedUrl(KEY, { action: cloud.BucketSignedUrlAction.DOWNLOAD }); - expect.equal(output, VALUE); - } + // Download file from private bucket using GET presigned URL + let output = util.shell("curl \"{getSignedUrl}\""); - test "signedUrl GET with non-existent key" { - let assertThrows = (expected: str, block: (): void) => { - let var error = false; - try { - block(); - } catch actual { - expect.equal(actual, expected); - error = true; - } - expect.equal(error, true); - }; - let UNEXISTING_KEY = "no-such-file.txt"; - let OBJECT_DOES_NOT_EXIST_ERROR = "Cannot provide signed url for a non-existent key (key={UNEXISTING_KEY})"; + expect.equal(output, VALUE); +} - assertThrows(OBJECT_DOES_NOT_EXIST_ERROR, () => { - bucket.signedUrl(UNEXISTING_KEY); - }); - } +test "signedUrl GET with non-existent key" { + let assertThrows = (expected: str, block: (): void) => { + let var error = false; + try { + block(); + } catch actual { + expect.equal(actual, expected); + error = true; + } + expect.equal(error, true); + }; + let UNEXISTING_KEY = "no-such-file.txt"; + let OBJECT_DOES_NOT_EXIST_ERROR = "Cannot provide signed url for a non-existent key (key={UNEXISTING_KEY})"; + + assertThrows(OBJECT_DOES_NOT_EXIST_ERROR, () => { + bucket.signedUrl(UNEXISTING_KEY); + }); +} - test "signedUrl PUT" { - let KEY = "tempfile.txt"; - let VALUE = "Hello, Wing!"; +test "signedUrl PUT" { + let KEY = "tempfile.txt"; + let VALUE = "Hello, Wing!"; - let tempDir = fs.mkdtemp(); - let tempFile = fs.join(tempDir, KEY); - fs.writeFile(tempFile, VALUE); + let tempDir = fs.mkdtemp(); + let tempFile = fs.join(tempDir, KEY); + fs.writeFile(tempFile, VALUE); - let putSignedUrl = bucket.signedUrl(KEY, { action: cloud.BucketSignedUrlAction.UPLOAD }); + let putSignedUrl = bucket.signedUrl(KEY, { action: cloud.BucketSignedUrlAction.UPLOAD }); - // Upload file to private bucket using PUT presigned URL - util.shell("curl -X PUT -T \"{tempFile}\" \"{putSignedUrl}\""); + // Upload file to private bucket using PUT presigned URL + util.shell("curl -X PUT -T \"{tempFile}\" \"{putSignedUrl}\""); - expect.equal(bucket.get(KEY), VALUE); - } + expect.equal(bucket.get(KEY), VALUE); +} - test "signedUrl duration option is respected" { - let isExpiredTokenError = (output: str) => { - let target = util.env("WING_TARGET"); - let var result = false; +test "signedUrl duration option is respected" { + let isExpiredTokenError = (output: str) => { + let target = util.env("WING_TARGET"); + let var result = false; - if target == "tf-aws" { - result = output.contains("AccessDeniedRequest has expired"); - } else if target == "tf-gcp" { - result = output.contains("ExpiredTokenInvalid argument."); - } + if target == "tf-aws" { + result = output.contains("AccessDeniedRequest has expired"); + } else if target == "tf-gcp" { + result = output.contains("ExpiredTokenInvalid argument."); + } - return result; - }; + return result; + }; - let KEY = "tempfile.txt"; - let VALUE = "Hello, Wing!"; + let KEY = "tempfile.txt"; + let VALUE = "Hello, Wing!"; - bucket.put(KEY, VALUE); + bucket.put(KEY, VALUE); - let getSignedUrl = bucket.signedUrl(KEY, { duration: 1s }); + let getSignedUrl = bucket.signedUrl(KEY, { duration: 1s }); - util.sleep(2s); + util.sleep(2s); - // Download file from private bucket using expired GET presigned URL - let output = util.shell("curl \"{getSignedUrl}\""); + // Download file from private bucket using expired GET presigned URL + let output = util.shell("curl \"{getSignedUrl}\""); - expect.equal(isExpiredTokenError(output), true); - } + expect.equal(isExpiredTokenError(output), true); } From 310dafcbee401cd947f0f19c72ca52f105bf7905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Tue, 17 Sep 2024 17:40:06 +0200 Subject: [PATCH 07/16] remove `test.only` --- packages/@winglang/sdk/test/target-sim/bucket.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@winglang/sdk/test/target-sim/bucket.test.ts b/packages/@winglang/sdk/test/target-sim/bucket.test.ts index 39cbdee9f97..0d7b6c5c450 100644 --- a/packages/@winglang/sdk/test/target-sim/bucket.test.ts +++ b/packages/@winglang/sdk/test/target-sim/bucket.test.ts @@ -941,7 +941,7 @@ test("bucket ignores corrupted state file", async () => { expect(files).toEqual(["b"]); }); -test.only("signedUrl is implemented for the simulator", async () => { +test("signedUrl is implemented for the simulator", async () => { // GIVEN const app = new SimApp(); new cloud.Bucket(app, "my_bucket"); From ada5c33ae04817e5aced3d2a6ffc6f861584650a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Tue, 17 Sep 2024 18:33:06 +0200 Subject: [PATCH 08/16] wip --- .../sdk/src/target-sim/bucket.inflight.ts | 100 +++++++++++------- 1 file changed, 61 insertions(+), 39 deletions(-) diff --git a/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts b/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts index 4e30d31e333..1556dc91ec7 100644 --- a/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts +++ b/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts @@ -102,16 +102,13 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { // Handle signed URL uploads. this.app.put("*", (req, res) => { const action = req.query.action; - if (action === BucketSignedUrlAction.DOWNLOAD) { + if (action !== BucketSignedUrlAction.UPLOAD) { return res.status(403).send("Operation not allowed"); } const validUntil = req.query.validUntil?.toString(); - if (validUntil) { - const validUntilMs = parseInt(validUntil) * 1000; - if (Date.now() > validUntilMs) { - return res.status(403).send("Signed URL has expired"); - } + if (!validUntil || Date.now() > parseInt(validUntil)) { + return res.status(403).send("Signed URL has expired"); } const key = req.path.slice(1); // remove leading slash @@ -124,39 +121,56 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { let fileInfo: FileInfo | undefined; - const bb = busboy({ headers: req.headers }); - bb.on("file", (_name, file, _info) => { - fileInfo = _info; - file.pipe(fs.createWriteStream(filename)); - }); - bb.on("close", () => { - console.log("file uploaded", filename, hash); - void this.updateMetadataAndNotify( - key, - actionType, - fileInfo?.mimeType - ).then(() => { - res.writeHead(200, { Connection: "close" }); - res.end(); + // Handle simple uploads. + if (!req.headers["content-type"]) { + const fileStream = fs.createWriteStream(filename); + req.pipe(fileStream); + + fileStream.on("error", () => { + res.status(500).send("Failed to save the file."); }); - }); - req.pipe(bb); - return; + + fileStream.on("finish", () => { + res.status(200).send(); + }); + + return; + } + // Handle multipart uploads. + else { + const bb = busboy({ + headers: req.headers, + }); + bb.on("file", (_name, file, _info) => { + fileInfo = _info; + file.pipe(fs.createWriteStream(filename)); + }); + bb.on("close", () => { + console.log("file uploaded", filename, hash); + void this.updateMetadataAndNotify( + key, + actionType, + fileInfo?.mimeType + ).then(() => { + res.writeHead(200, { Connection: "close" }); + res.end(); + }); + }); + req.pipe(bb); + return; + } }); // Handle signed URL downloads. this.app.get("*", (req, res) => { const action = req.query.action; - if (action === BucketSignedUrlAction.UPLOAD) { + if (action !== BucketSignedUrlAction.DOWNLOAD) { return res.status(403).send("Operation not allowed"); } const validUntil = req.query.validUntil?.toString(); - if (validUntil) { - const validUntilMs = parseInt(validUntil) * 1000; - if (Date.now() > validUntilMs) { - return res.status(403).send("Signed URL has expired"); - } + if (!validUntil || Date.now() > parseInt(validUntil)) { + return res.status(403).send("Signed URL has expired"); } const key = req.path.slice(1); // remove leading slash @@ -481,18 +495,26 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { return this.context.withTrace({ message: `Signed URL (key=${key}).`, activity: async () => { - const url = new URL(key, this.url); - if (options?.action) { - url.searchParams.set("action", options.action); - } - if (options?.duration) { - // BUG: The `options?.duration` is supposed to be an instance of `Duration` but it is not. It's just - // a POJO with seconds, but TypeScript thinks otherwise. - url.searchParams.set( - "validUntil", - String(Datetime.utcNow().ms + options.duration.seconds * 1000) + const action = options?.action ?? BucketSignedUrlAction.DOWNLOAD; + // BUG: The `options?.duration` is supposed to be an instance of `Duration` but it is not. It's just + // a POJO with seconds, but TypeScript thinks otherwise. + const duration = options?.duration?.seconds ?? 900; + + if ( + action === BucketSignedUrlAction.DOWNLOAD && + !(await this.exists(key)) + ) { + throw new Error( + `Cannot provide signed url for a non-existent key (key=${key})` ); } + + const url = new URL(key, this.url); + url.searchParams.set("action", action); + url.searchParams.set( + "validUntil", + String(Date.now() + duration * 1000) + ); return url.toString(); }, }); From 9c9f3a8e3aae316cb804f032513d24026ec7a84e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Tue, 17 Sep 2024 18:35:00 +0200 Subject: [PATCH 09/16] wip --- tests/sdk_tests/bucket/signed_url.test.w | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/sdk_tests/bucket/signed_url.test.w b/tests/sdk_tests/bucket/signed_url.test.w index 6a44570df08..3b36b3eb0c7 100644 --- a/tests/sdk_tests/bucket/signed_url.test.w +++ b/tests/sdk_tests/bucket/signed_url.test.w @@ -77,6 +77,8 @@ test "signedUrl duration option is respected" { result = output.contains("AccessDeniedRequest has expired"); } else if target == "tf-gcp" { result = output.contains("ExpiredTokenInvalid argument."); + } else if target == "sim" { + result = output.contains("Signed URL has expired"); } return result; From a1ad138b0138e8a0499eb5fed87334f1fbb98ef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Tue, 17 Sep 2024 18:43:08 +0200 Subject: [PATCH 10/16] wip --- .../sdk/src/target-sim/bucket.inflight.ts | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts b/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts index 1556dc91ec7..3e035148c62 100644 --- a/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts +++ b/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts @@ -121,23 +121,7 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { let fileInfo: FileInfo | undefined; - // Handle simple uploads. - if (!req.headers["content-type"]) { - const fileStream = fs.createWriteStream(filename); - req.pipe(fileStream); - - fileStream.on("error", () => { - res.status(500).send("Failed to save the file."); - }); - - fileStream.on("finish", () => { - res.status(200).send(); - }); - - return; - } - // Handle multipart uploads. - else { + if (req.header("content-type")?.startsWith("multipart/form-data")) { const bb = busboy({ headers: req.headers, }); @@ -157,6 +141,19 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { }); }); req.pipe(bb); + return; + } else { + const fileStream = fs.createWriteStream(filename); + req.pipe(fileStream); + + fileStream.on("error", () => { + res.status(500).send("Failed to save the file."); + }); + + fileStream.on("finish", () => { + res.status(200).send(); + }); + return; } }); From 470cdfd3bc468dcc4b0bd3543873eda1e5126c8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Tue, 17 Sep 2024 18:56:06 +0200 Subject: [PATCH 11/16] wip --- packages/@winglang/sdk/.projen/deps.json | 8 --- packages/@winglang/sdk/.projenrc.ts | 2 - packages/@winglang/sdk/package.json | 3 -- .../sdk/src/target-sim/bucket.inflight.ts | 51 +++++++------------ pnpm-lock.yaml | 24 --------- 5 files changed, 17 insertions(+), 71 deletions(-) diff --git a/packages/@winglang/sdk/.projen/deps.json b/packages/@winglang/sdk/.projen/deps.json index de2564a266e..c38b2198449 100644 --- a/packages/@winglang/sdk/.projen/deps.json +++ b/packages/@winglang/sdk/.projen/deps.json @@ -9,10 +9,6 @@ "name": "@types/aws-lambda", "type": "build" }, - { - "name": "@types/busboy", - "type": "build" - }, { "name": "@types/express", "type": "build" @@ -280,10 +276,6 @@ "name": "ajv", "type": "bundled" }, - { - "name": "busboy", - "type": "bundled" - }, { "name": "cdktf", "version": "0.20.7", diff --git a/packages/@winglang/sdk/.projenrc.ts b/packages/@winglang/sdk/.projenrc.ts index f887e585b0c..7e2d86aaed5 100644 --- a/packages/@winglang/sdk/.projenrc.ts +++ b/packages/@winglang/sdk/.projenrc.ts @@ -77,7 +77,6 @@ const project = new cdk.JsiiProject({ "protobufjs@7.2.5", // simulator dependencies "express", - "busboy", "uuid", // using version 3 because starting from version 4, it no longer works with CommonJS. "nanoid@^3.3.7", @@ -115,7 +114,6 @@ const project = new cdk.JsiiProject({ "eslint-plugin-sort-exports", "fs-extra", "vitest", - "@types/busboy", "@types/uuid", "nanoid", // for ESM import test in target-sim/function.test.ts "chalk", diff --git a/packages/@winglang/sdk/package.json b/packages/@winglang/sdk/package.json index 8f5c3cf7fbb..8f59d40e1d5 100644 --- a/packages/@winglang/sdk/package.json +++ b/packages/@winglang/sdk/package.json @@ -38,7 +38,6 @@ "devDependencies": { "@cdktf/provider-aws": "^19", "@types/aws-lambda": "^8.10.109", - "@types/busboy": "^1.5.4", "@types/express": "^4.17.21", "@types/fs-extra": "^11.0.4", "@types/glob": "^7.2.0", @@ -103,7 +102,6 @@ "@types/aws-lambda": "^8.10.140", "@winglang/wingtunnels": "workspace:^", "ajv": "^8.16.0", - "busboy": "^1.6.0", "cdktf": "0.20.7", "constructs": "^10.3", "cron-parser": "^4.9.0", @@ -150,7 +148,6 @@ "@types/aws-lambda", "@winglang/wingtunnels", "ajv", - "busboy", "cdktf", "cron-parser", "cron-validator", diff --git a/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts b/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts index 3e035148c62..35fa67ccd5c 100644 --- a/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts +++ b/packages/@winglang/sdk/src/target-sim/bucket.inflight.ts @@ -3,7 +3,6 @@ import * as fs from "fs"; import { Server } from "http"; import { dirname, join } from "path"; import { pathToFileURL } from "url"; -import busboy, { FileInfo } from "busboy"; import express from "express"; import mime from "mime-types"; import { BucketAttributes, BucketSchema } from "./schema-resources"; @@ -119,43 +118,27 @@ export class Bucket implements IBucketClient, ISimulatorResourceInstance { ? BucketEventType.UPDATE : BucketEventType.CREATE; - let fileInfo: FileInfo | undefined; + const contentType = req.header("content-type"); + if (contentType?.startsWith("multipart/form-data")) { + return res.status(400).send("Multipart uploads not supported"); + } - if (req.header("content-type")?.startsWith("multipart/form-data")) { - const bb = busboy({ - headers: req.headers, - }); - bb.on("file", (_name, file, _info) => { - fileInfo = _info; - file.pipe(fs.createWriteStream(filename)); - }); - bb.on("close", () => { - console.log("file uploaded", filename, hash); - void this.updateMetadataAndNotify( - key, - actionType, - fileInfo?.mimeType - ).then(() => { - res.writeHead(200, { Connection: "close" }); - res.end(); - }); - }); - req.pipe(bb); - return; - } else { - const fileStream = fs.createWriteStream(filename); - req.pipe(fileStream); + const fileStream = fs.createWriteStream(filename); + req.pipe(fileStream); - fileStream.on("error", () => { - res.status(500).send("Failed to save the file."); - }); + fileStream.on("error", () => { + res.status(500).send("Failed to save the file."); + }); - fileStream.on("finish", () => { - res.status(200).send(); - }); + fileStream.on("finish", () => { + void this.updateMetadataAndNotify(key, actionType, contentType).then( + () => { + res.status(200).send(); + } + ); + }); - return; - } + return; }); // Handle signed URL downloads. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e37cdef0194..3bdb1046a90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,9 +354,6 @@ importers: ajv: specifier: ^8.16.0 version: 8.17.1 - busboy: - specifier: ^1.6.0 - version: 1.6.0 cdktf: specifier: 0.20.7 version: 0.20.7(constructs@10.3.0) @@ -428,9 +425,6 @@ importers: '@cdktf/provider-aws': specifier: ^19 version: 19.23.0(cdktf@0.20.7)(constructs@10.3.0) - '@types/busboy': - specifier: ^1.5.4 - version: 1.5.4 '@types/express': specifier: ^4.17.21 version: 4.17.21 @@ -10573,12 +10567,6 @@ packages: resolution: {integrity: sha512-ZYbcE2x7yrvNFJiU7xJGrpF/ihpkM7zKgw8bha3LNJSesvTtUNxbpzaT7WXBIryf6jovisrxTBvymxMeLLj1Mg==} dev: true - /@types/busboy@1.5.4: - resolution: {integrity: sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==} - dependencies: - '@types/node': 22.5.5 - dev: true - /@types/cacache@17.0.2: resolution: {integrity: sha512-IrqHzVX2VRMDQQKa7CtKRnuoCLdRJiLW6hWU+w7i7+AaQ0Ii5bKwJxd5uRK4zBCyrHd3tG6G8zOm2LplxbSfQg==} dependencies: @@ -12879,13 +12867,6 @@ packages: load-tsconfig: 0.2.5 dev: true - /busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} - dependencies: - streamsearch: 1.1.0 - dev: false - /bytes@3.0.0: resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} engines: {node: '>= 0.8'} @@ -21681,11 +21662,6 @@ packages: - supports-color dev: true - /streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} - dev: false - /string-length@4.0.2: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} From 258a5f04a07cb77aaf7b9e2448f8e12ec9e700c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Tue, 17 Sep 2024 19:10:22 +0200 Subject: [PATCH 12/16] wip --- packages/@winglang/sdk/test/target-sim/bucket.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/@winglang/sdk/test/target-sim/bucket.test.ts b/packages/@winglang/sdk/test/target-sim/bucket.test.ts index 0d7b6c5c450..d2719302970 100644 --- a/packages/@winglang/sdk/test/target-sim/bucket.test.ts +++ b/packages/@winglang/sdk/test/target-sim/bucket.test.ts @@ -953,7 +953,9 @@ test("signedUrl is implemented for the simulator", async () => { const formData = new FormData(); formData.set("file", new Blob(["Hello, World!"], { type: "text/utf8" })); - const signedUrl = await client.signedUrl("key"); + const signedUrl = await client.signedUrl("key", { + action: cloud.BucketSignedUrlAction.UPLOAD, + }); const response = await fetch(signedUrl, { method: "PUT", body: formData, From 15a67be4869a16cdd273a69846e9dc118812fc60 Mon Sep 17 00:00:00 2001 From: "monada-bot[bot]" Date: Tue, 17 Sep 2024 17:25:09 +0000 Subject: [PATCH 13/16] chore: self mutation (e2e-1of2.diff) Signed-off-by: monada-bot[bot] --- .../sdk_tests/bucket/signed_url.test.w_test_sim.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tools/hangar/__snapshots__/test_corpus/sdk_tests/bucket/signed_url.test.w_test_sim.md b/tools/hangar/__snapshots__/test_corpus/sdk_tests/bucket/signed_url.test.w_test_sim.md index b39fe7d0e69..612e92db068 100644 --- a/tools/hangar/__snapshots__/test_corpus/sdk_tests/bucket/signed_url.test.w_test_sim.md +++ b/tools/hangar/__snapshots__/test_corpus/sdk_tests/bucket/signed_url.test.w_test_sim.md @@ -2,9 +2,13 @@ ## stdout.log ```log -pass ─ signed_url.test.wsim (no tests) +pass ─ signed_url.test.wsim » root/Default/test:signedUrl duration option is respected +pass ─ signed_url.test.wsim » root/Default/test:signedUrl GET (explicit) +pass ─ signed_url.test.wsim » root/Default/test:signedUrl GET (implicit) +pass ─ signed_url.test.wsim » root/Default/test:signedUrl GET with non-existent key +pass ─ signed_url.test.wsim » root/Default/test:signedUrl PUT -Tests 1 passed (1) +Tests 5 passed (5) Snapshots 1 skipped Test Files 1 passed (1) Duration From 268ebd1eeb9705f0ba7ea6acaf2f41a3d66fd01e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Tue, 17 Sep 2024 19:22:52 +0200 Subject: [PATCH 14/16] wip --- packages/@winglang/sdk/test/simulator/reload.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@winglang/sdk/test/simulator/reload.test.ts b/packages/@winglang/sdk/test/simulator/reload.test.ts index 4ddcf9df6e8..bc0fd3096fb 100644 --- a/packages/@winglang/sdk/test/simulator/reload.test.ts +++ b/packages/@winglang/sdk/test/simulator/reload.test.ts @@ -47,9 +47,9 @@ test("traces are cleared when reloading the simulator with reset state set to tr await s.start(); const client = s.getResource("/my_bucket") as IBucketClient; await client.put("traces.txt", "Hello world!"); - expect(s.listTraces().filter((t) => t.type === "resource").length).toEqual(1); + expect(s.listTraces().filter((t) => t.type === "resource").length).toEqual(2); // Reload the simulator and reset state await s.reload(true); - expect(s.listTraces().filter((t) => t.type === "resource").length).toEqual(0); + expect(s.listTraces().filter((t) => t.type === "resource").length).toEqual(1); }); From a23a50d590c349a8afaf51417bf33857021cec15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Tue, 17 Sep 2024 19:26:26 +0200 Subject: [PATCH 15/16] wip --- .../sdk/test/target-sim/bucket.test.ts | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/@winglang/sdk/test/target-sim/bucket.test.ts b/packages/@winglang/sdk/test/target-sim/bucket.test.ts index d2719302970..01085cd25f0 100644 --- a/packages/@winglang/sdk/test/target-sim/bucket.test.ts +++ b/packages/@winglang/sdk/test/target-sim/bucket.test.ts @@ -941,7 +941,7 @@ test("bucket ignores corrupted state file", async () => { expect(files).toEqual(["b"]); }); -test("signedUrl is implemented for the simulator", async () => { +test("signedUrl accepts simple uploads", async () => { // GIVEN const app = new SimApp(); new cloud.Bucket(app, "my_bucket"); @@ -950,15 +950,12 @@ test("signedUrl is implemented for the simulator", async () => { const client = s.getResource("/my_bucket") as cloud.IBucketClient; // WHEN - const formData = new FormData(); - formData.set("file", new Blob(["Hello, World!"], { type: "text/utf8" })); - const signedUrl = await client.signedUrl("key", { action: cloud.BucketSignedUrlAction.UPLOAD, }); const response = await fetch(signedUrl, { method: "PUT", - body: formData, + body: new Blob(["Hello, World!"], { type: "text/utf8" }), }); // THEN @@ -968,6 +965,36 @@ test("signedUrl is implemented for the simulator", async () => { await s.stop(); }); +test("signedUrl doesn't accept multipart uploads yet", async () => { + // GIVEN + const app = new SimApp(); + new cloud.Bucket(app, "my_bucket"); + + const s = await app.startSimulator(); + const client = s.getResource("/my_bucket") as cloud.IBucketClient; + + // WHEN + const signedUrl = await client.signedUrl("key", { + action: cloud.BucketSignedUrlAction.UPLOAD, + }); + const response = await fetch(signedUrl, { + method: "PUT", + body: (() => { + const formData = new FormData(); + formData.set("file", new Blob(["Hello, World!"], { type: "text/utf8" })); + return formData; + })(), + }); + + // THEN + expect(response.ok).toBe(false); + await expect(response.text()).resolves.toBe( + "Multipart uploads not supported" + ); + + await s.stop(); +}); + // Deceided to seperate this feature in a different release,(see https://github.com/winglang/wing/issues/4143) // test("Given a bucket when reaching to a non existent key, signed url it should throw an error", async () => { From 5cb9ed7885a9df9d15a14f768bfb0d3c7d48b564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Pallar=C3=A9s?= Date: Tue, 17 Sep 2024 19:43:55 +0200 Subject: [PATCH 16/16] restore console demo --- wing-console/console/app/demo/main.w | 307 ++++++++++++++++++++++++++- 1 file changed, 301 insertions(+), 6 deletions(-) diff --git a/wing-console/console/app/demo/main.w b/wing-console/console/app/demo/main.w index 4f5e697bae1..af4d685243b 100644 --- a/wing-console/console/app/demo/main.w +++ b/wing-console/console/app/demo/main.w @@ -15,10 +15,305 @@ let visual = new ui.Table( }, ) as "ui.Table" in tableBucket; -test "presigned url" { - let bucket = new cloud.Bucket() as "Bucket"; - let obj = new cloud.Object() as "Object"; - // let url = obj.getPresignedUrl({ action: "get" }); - // log(url); - // assert eq(url, "https://minio.example.com/bucket/object?action=get&validUntil=1714857600"); +// let errorService = new cloud.Service(inflight () => {}) as "ErrorService"; + +// let errorResource = new sim.Resource(inflight () => { +// throw "Oops"; +// }) as "ErrorResource" in errorService; + +// @see https://github.com/winglang/wing/issues/4237 it crashes the Console preview env. +// let secret = new cloud.Secret(name: "my-secret"); + +let bucket = new cloud.Bucket(); +let queue = new cloud.Queue(); +let api = new cloud.Api(); +let counter = new cloud.Counter(initial: 0); + +class myBucket { + b: cloud.Bucket; + new() { + this.b = new cloud.Bucket(); + new ui.FileBrowser("File Browser", + { + put: inflight (fileName: str, fileContent:str) => { + this.b.put(fileName, fileContent); + }, + delete: inflight (fileName: str) => { + this.b.delete(fileName); + }, + get: inflight (fileName: str) => { + return this.b.get(fileName); + }, + list: inflight () => {return this.b.list();}, + } + ); + + new cloud.Service( + inflight () => { + this.b.put("hello.txt", "Hello, GET!"); + return inflight () => { + }; + }, + ); + } + pub inflight put(key: str, value: str) { + this.b.put(key, value); + } } + +let myB = new myBucket() as "MyUIComponentBucket"; + +let putfucn = new cloud.Function(inflight () => { + myB.put("test", "Test"); +}) as "PutFileInCustomBucket"; + +api.get("/test-get", inflight (req: cloud.ApiRequest): cloud.ApiResponse => { + bucket.put("hello.txt", "Hello, GET!"); + return cloud.ApiResponse { + status: 200, + body: Json.stringify(req.query) + }; +}); +api.post("/test-post", inflight (req: cloud.ApiRequest): cloud.ApiResponse => { + counter.inc(); + return cloud.ApiResponse { + status: 200, + body: "Hello, POST!" + }; +}); + +let handler = inflight (message): str => { + counter.inc(); + bucket.put("hello{counter.peek()}.txt", "Hello, {message}!"); + log("Hello, {message}!"); + return message; +}; + +queue.setConsumer(handler); + +new cloud.Function(inflight (message: Json?) => { + counter.inc(); + log("Counter is now {counter.inc(0)}"); + return message; +}) as "IncrementCounter"; + +let topic = new cloud.Topic() as "Topic"; +topic.onMessage(inflight (message: str): str => { + log("Topic subscriber #1: {message}"); + return message; +}); + +let rateSchedule = new cloud.Schedule(cloud.ScheduleProps{ + rate: 5m +}) as "Rate Schedule"; +nodeof(rateSchedule).expanded = true; + +rateSchedule.onTick(inflight () => { + log("Rate schedule ticked!"); +}); + +new cloud.Service( + inflight () => { + return inflight () => { + log("stop!"); + }; + }, +); + +let cronSchedule = new cloud.Schedule(cloud.ScheduleProps{ + cron: "* * * * *" +}) as "Cron Schedule"; + +// cronSchedule.onTick(inflight () => { +// log("Cron schedule ticked!"); +// }); + +test "Increment counter" { + let previous = counter.inc(); + log("Assertion should fail: {previous} === {counter.peek()}"); + assert(previous == 1); +} + +test "Push message to the queue" { + queue.push("hey"); +} + +test "Print"{ + log("Hello World!"); + assert(true); +} + +test "without assertions nor prints" { +} + +test "Add fixtures" { + let arr = [1, 2, 3, 4, 5]; + + log("Adding {arr.length} files in the bucket.."); + for item in arr { + bucket.put("fixture_{item}.txt", "Content for the fixture_{item}!"); + } + + log("Publishing to the topic.."); + topic.publish("Hello, topic!"); + + log("Setting up counter.."); + counter.set(0); + counter.inc(100); +} + +class WidgetService { + data: cloud.Bucket; + counter: cloud.Counter; + bucket: myBucket; + + new() { + this.data = new cloud.Bucket(); + this.counter = new cloud.Counter(); + this.bucket = new myBucket() as "MyInternalBucket"; + + // a field displays a labeled value, with optional refreshing + new ui.Field( + "Total widgets", + inflight () => { return this.countWidgets(); }, + refreshRate: 5s, + ) as "TotalWidgets"; + + // a link field displays a clickable link + new ui.Field( + "Widgets Link", + inflight () => { return "https://winglang.io"; }, + link: true, + ) as "WidgetsLink"; + + // a button lets you invoke any inflight function + new ui.Button("Add widget", inflight () => { this.addWidget(); }); + } + + pub inflight addWidget() { + let id = this.counter.inc(); + this.data.put("widget-{id}", "my data"); + } + + inflight countWidgets(): str { + return "{this.data.list().length}"; + } +} + +let widget = new WidgetService(); + +new cloud.Function(inflight () => { + widget.addWidget(); +}) as "AddWidget"; + +class ApiUsersService { + api: cloud.Api; + db: cloud.Bucket; + + new() { + this.api = new cloud.Api(); + this.db = new cloud.Bucket(); + + this.api.post("/users", inflight (request: cloud.ApiRequest): cloud.ApiResponse => { + let input = Json.tryParse(request.body ?? "") ?? ""; + let name = input.tryGet("name")?.tryAsStr() ?? ""; + if name == "" { + return cloud.ApiResponse { + status: 400, + body: "Body parameter 'name' is required" + }; + } + this.db.put("user-{name}", Json.stringify(input)); + return cloud.ApiResponse { + status: 200, + body: Json.stringify(input) + }; + }); + this.api.get("/users", inflight (request: cloud.ApiRequest): cloud.ApiResponse => { + let name = request.query.tryGet("name") ?? ""; + + if name != "" { + try { + return cloud.ApiResponse { + status: 200, + body: this.db.get("user-{name}") + }; + } catch { + return cloud.ApiResponse { + status: 404, + body: "User not found" + }; + } + } + + return cloud.ApiResponse { + status: 200, + body: Json.stringify(this.db.list()) + }; + }); + + new ui.HttpClient( + "Test HttpClient UI component", + inflight () => { + return this.api.url; + }, + inflight () => { + return Json.stringify({ + "paths": { + "/users": { + "post": { + "summary": "Create a new user", + "parameters": [ + { + "in": "header", + "name": "accept", + }, + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name", + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the user" + }, + "email": { + "type": "string", + "description": "The email of the user", + } + } + } + } + } + }, + }, + "get": { + "summary": "List all widgets", + "parameters": [ + { + "in": "query", + "name": "name", + "schema": { + "type": "string" + }, + "description": "The name of the user" + } + ], + } + }, + } + }); + } + ); + } +} + +new ApiUsersService(); + +log("hello from inflight");