1 | |
2 | * Supported HTTP methods for routing. |
3 | */ |
4 | export enum METHOD { |
5 | GET = "GET", |
6 | POST = "POST", |
7 | PUT = "PUT", |
8 | PATCH = "PATCH", |
9 | DELETE = "DELETE", |
10 | } |
11 |
|
12 | |
13 | * The standard request object passed to route handlers. |
14 | * Contains parsed URL components, headers, and body. |
15 | */ |
16 | type HttpRequest = { |
17 | |
18 | method: string; |
19 | |
20 | route: string; |
21 | |
22 | path: string; |
23 | |
24 | params: Record<string, string>; |
25 | |
26 | query: Record<string, string>; |
27 | |
28 | headers: Record<string, string>; |
29 | |
30 | body: any; |
31 | |
32 | raw_string: string | null; |
33 | }; |
34 |
|
35 | |
36 | * Windmill HTTP event object, extending HttpRequest with trigger metadata. |
37 | */ |
38 | type HttpEvent = HttpRequest & { |
39 | |
40 | kind: "http"; |
41 | |
42 | trigger_path: string; |
43 | }; |
44 |
|
45 | |
46 | * A lightweight, chainable router for Windmill HTTP scripts. |
47 | * Supports method chaining, custom error handling, and automatic response formatting. |
48 | */ |
49 | export class Router { |
50 | private routes: Record<string, Record<string, (req: HttpRequest) => any>> = {}; |
51 |
|
52 | |
53 | * Preprocessor hook for Windmill. |
54 | * Validates the event kind and prepares the request object. |
55 | * @param event - The incoming Windmill event |
56 | * @returns An object containing the normalized request |
57 | */ |
58 | preprocessor = async function (event: HttpEvent) { |
59 | if (event.kind === "http") { |
60 | return { req: event }; |
61 | } |
62 | throw new Error(`Unsupported trigger kind: ${event.kind}`); |
63 | }; |
64 |
|
65 | |
66 | * Normalizes HTTP method strings to uppercase for consistent routing. |
67 | */ |
68 | private normalizeMethod(method: string): string { |
69 | return method.toUpperCase(); |
70 | } |
71 |
|
72 | |
73 | * Registers a GET route. |
74 | * @param route - The URL path pattern (e.g., "/users") |
75 | * @param handler - The function to execute when the route is matched |
76 | */ |
77 | get(route: string, handler: (req: HttpRequest) => any) { |
78 | return this.addRoute(METHOD.GET, route, handler); |
79 | } |
80 |
|
81 | |
82 | * Registers a POST route. |
83 | * @param route - The URL path pattern |
84 | * @param handler - The function to execute |
85 | */ |
86 | post(route: string, handler: (req: HttpRequest) => any) { |
87 | return this.addRoute(METHOD.POST, route, handler); |
88 | } |
89 |
|
90 | |
91 | * Registers a PUT route. |
92 | * @param route - The URL path pattern |
93 | * @param handler - The function to execute |
94 | */ |
95 | put(route: string, handler: (req: HttpRequest) => any) { |
96 | return this.addRoute(METHOD.PUT, route, handler); |
97 | } |
98 |
|
99 | |
100 | * Registers a PATCH route. |
101 | * @param route - The URL path pattern |
102 | * @param handler - The function to execute |
103 | */ |
104 | patch(route: string, handler: (req: HttpRequest) => any) { |
105 | return this.addRoute(METHOD.PATCH, route, handler); |
106 | } |
107 |
|
108 | |
109 | * Registers a DELETE route. |
110 | * @param route - The URL path pattern |
111 | * @param handler - The function to execute |
112 | */ |
113 | delete(route: string, handler: (req: HttpRequest) => any) { |
114 | return this.addRoute(METHOD.DELETE, route, handler); |
115 | } |
116 |
|
117 | |
118 | * Internal method to register a route. |
119 | * @param method - The HTTP method |
120 | * @param route - The path pattern |
121 | * @param handler - The handler function |
122 | * @returns The Router instance for chaining |
123 | */ |
124 | private addRoute(method: METHOD, route: string, handler: (req: HttpRequest) => any) { |
125 | if (!this.routes[method]) { |
126 | this.routes[method] = {}; |
127 | } |
128 | this.routes[method][route] = handler; |
129 | return this; |
130 | } |
131 |
|
132 | |
133 | * Executes the matched handler or throws an appropriate HttpError. |
134 | * @param req - The request object |
135 | * @returns The handler's return value |
136 | * @throws HttpError if route or method is not found |
137 | */ |
138 | private executeHandler(req: HttpRequest): any { |
139 | const method = this.normalizeMethod(req.method); |
140 | const route = req.route; |
141 |
|
142 | if (!this.routes[method]) { |
143 | throw HttpError.MethodNotAllowed(`Method ${method} not allowed`); |
144 | } |
145 |
|
146 | const handler = this.routes[method][route]; |
147 |
|
148 | if (!handler) { |
149 | throw HttpError.NotFound(`Route ${route} not found`); |
150 | } |
151 |
|
152 | return handler(req); |
153 | } |
154 |
|
155 | |
156 | * Main entry point for handling requests. |
157 | * Wraps execution in try-catch to standardize error responses. |
158 | * @param req - The request object |
159 | * @returns A formatted WindmillResponse object |
160 | */ |
161 | handle(req: HttpRequest): any { |
162 | try { |
163 | return this.executeHandler(req); |
164 | } catch (error) { |
165 | console.error("Router Error:", error); |
166 |
|
167 | if (!(error instanceof HttpError)) { |
168 | |
169 | throw HttpError.InternalError(undefined, error); |
170 | } |
171 |
|
172 | |
173 | return { |
174 | windmill_status_code: error.status, |
175 | result: { |
176 | statusCode: error.status, |
177 | error: error.message |
178 | } |
179 | }; |
180 | } |
181 | } |
182 | } |
183 |
|
184 | |
185 | * Custom error class for HTTP status codes. |
186 | * Extends the native Error class with a status code and optional cause. |
187 | */ |
188 | export class HttpError extends Error { |
189 | status: number; |
190 |
|
191 | |
192 | * Creates a new HttpError. |
193 | * @param status - HTTP status code (e.g., 404, 500) |
194 | * @param message - Error message |
195 | * @param cause - The original error that caused this one (preserves stack trace) |
196 | */ |
197 | constructor(status: number, message: string, cause?: any) { |
198 | super(message); |
199 | this.status = status; |
200 | this.name = 'HttpError'; |
201 | |
202 | if (cause) { |
203 | (this as any).cause = cause; |
204 | } |
205 | } |
206 |
|
207 | static BadRequest(message = 'Bad Request', cause?: any) { |
208 | return new HttpError(400, message, cause); |
209 | } |
210 |
|
211 | static Unauthorized(message = 'Unauthorized', cause?: any) { |
212 | return new HttpError(401, message, cause); |
213 | } |
214 |
|
215 | static Forbidden(message = 'Forbidden', cause?: any) { |
216 | return new HttpError(403, message, cause); |
217 | } |
218 |
|
219 | static NotFound(message = 'Not found', cause?: any) { |
220 | return new HttpError(404, message, cause); |
221 | } |
222 |
|
223 | static MethodNotAllowed(message = 'Method not allowed', cause?: any) { |
224 | return new HttpError(405, message, cause); |
225 | } |
226 |
|
227 | static UnprocessableContent(message = 'Unprocessable Content', cause?: any) { |
228 | return new HttpError(422, message, cause); |
229 | } |
230 |
|
231 | static InternalError(message = 'Internal server error', cause?: any) { |
232 | return new HttpError(500, message, cause); |
233 | } |
234 | } |
235 |
|
236 | |
237 | * Standard Windmill HTTP response structure. |
238 | */ |
239 | type WindmillResponse = { |
240 | windmill_status_code: number; |
241 | windmill_headers?: Record<string, string>; |
242 | result: any; |
243 | }; |
244 |
|
245 | |
246 | * Helper class to construct HTTP responses with convenient status codes. |
247 | * Automatically formats output for Windmill. |
248 | */ |
249 | export class HttpResponse { |
250 | public status: number; |
251 | public data: any; |
252 | public headers?: Record<string, string>; |
253 |
|
254 | constructor(data: any, status: number = 200, headers?: Record<string, string>) { |
255 | this.data = data; |
256 | this.status = status; |
257 | this.headers = headers; |
258 | } |
259 |
|
260 | |
261 | * Returns a 200 OK response. |
262 | */ |
263 | static Ok(data: any, headers?: Record<string, string>) { |
264 | return new HttpResponse(data, 200, headers).toWindmillResponse(); |
265 | } |
266 |
|
267 | |
268 | * Returns a 201 Created response. |
269 | */ |
270 | static Created(data: any, headers?: Record<string, string>) { |
271 | return new HttpResponse(data, 201, headers).toWindmillResponse(); |
272 | } |
273 |
|
274 | |
275 | * Returns a 202 Accepted response. |
276 | */ |
277 | static Accepted(data: any, headers?: Record<string, string>) { |
278 | return new HttpResponse(data, 202, headers).toWindmillResponse(); |
279 | } |
280 |
|
281 | |
282 | * Returns a 204 No Content response. |
283 | */ |
284 | static NoContent(headers?: Record<string, string>) { |
285 | return new HttpResponse(null, 204, headers).toWindmillResponse(); |
286 | } |
287 |
|
288 | private toWindmillResponse(): WindmillResponse { |
289 | return { |
290 | windmill_status_code: this.status, |
291 | windmill_headers: this.headers, |
292 | result: this.data |
293 | }; |
294 | } |
295 | } |
296 |
|
297 | |
298 |
|
299 | const router = new Router(); |
300 |
|
301 | router |
302 | .get("router-test", (req) => { |
303 | return HttpResponse.Ok({ message: "GET TEST" }); |
304 | }) |
305 | .post("router-test", (req) => { |
306 | if (true) { |
307 | throw HttpError.Forbidden(); |
308 | } |
309 |
|
310 | return HttpResponse.NoContent(); |
311 | }); |
312 |
|
313 | |
314 | export async function preprocessor(event: HttpEvent) { |
315 | return await router.preprocessor(event); |
316 | } |
317 |
|
318 | |
319 | export async function main(req: HttpRequest) { |
320 | return router.handle(req); |
321 | } |