@dbos-inc/koa-serve 2.11.6-preview.gcb74958171

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/jest.config.js ADDED
@@ -0,0 +1,8 @@
1
+ /** @type {import('ts-jest').JestConfigWithTsJest} */
2
+ module.exports = {
3
+ preset: 'ts-jest',
4
+ testEnvironment: 'node',
5
+ testRegex: '((\\.|/)(test|spec))\\.ts?$',
6
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
7
+ modulePaths: ['./'],
8
+ };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@dbos-inc/koa-serve",
3
+ "version": "2.11.6-preview.gcb74958171",
4
+ "description": "DBOS HTTP Package for serving workflows with Koa",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/dbos-inc/dbos-transact-ts",
9
+ "directory": "packages/koa-serve"
10
+ },
11
+ "homepage": "https://docs.dbos.dev/",
12
+ "main": "dist/src/index.js",
13
+ "types": "dist/src/index.d.ts",
14
+ "scripts": {
15
+ "build": "tsc --project tsconfig.json",
16
+ "testv": "npm run build && jest --detectOpenHandles tests/validation.test.ts",
17
+ "testa": "npm run build && jest --detectOpenHandles tests/argsource.test.ts",
18
+ "test": "npm run build && jest --detectOpenHandles"
19
+ },
20
+ "devDependencies": {
21
+ "@types/koa": "^2.15.0",
22
+ "@types/koa__cors": "^5.0.0",
23
+ "@types/koa__router": "^12.0.4",
24
+ "@types/jest": "^29.5.12",
25
+ "@types/node": "^20.11.25",
26
+ "@types/supertest": "^6.0.2",
27
+ "jest": "^29.7.0",
28
+ "supertest": "^7.0.0",
29
+ "ts-jest": "^29.1.4",
30
+ "typescript": "^5.3.3"
31
+ },
32
+ "peerDependencies": {
33
+ "@dbos-inc/dbos-sdk": "*"
34
+ },
35
+ "dependencies": {
36
+ "@koa/bodyparser": "5.0.0",
37
+ "@koa/cors": "5.0.0",
38
+ "@koa/router": "13.1.0",
39
+ "jsonwebtoken": "^9.0.2",
40
+ "koa": "^2.15.4",
41
+ "serialize-error": "8.1.0"
42
+ }
43
+ }
@@ -0,0 +1,185 @@
1
+ import { IncomingHttpHeaders } from 'http';
2
+ import { ParsedUrlQuery } from 'querystring';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ import {
6
+ DBOS,
7
+ DBOSLifecycleCallback,
8
+ Error as DBOSErrors,
9
+ MethodParameter,
10
+ requestArgValidation,
11
+ } from '@dbos-inc/dbos-sdk';
12
+
13
+ export enum APITypes {
14
+ GET = 'GET',
15
+ POST = 'POST',
16
+ PUT = 'PUT',
17
+ PATCH = 'PATCH',
18
+ DELETE = 'DELETE',
19
+ }
20
+
21
+ export enum ArgSources {
22
+ AUTO = 'AUTO', // Look both places
23
+ DEFAULT = 'DEFAULT', // Look in the standard place for the method
24
+ BODY = 'BODY', // Look in body only
25
+ QUERY = 'QUERY', // Look in query string only
26
+ }
27
+
28
+ export interface DBOSHTTPAuthReturn {
29
+ authenticatedUser: string;
30
+ authenticatedRoles: string[];
31
+ }
32
+
33
+ export interface DBOSHTTPReg {
34
+ apiURL: string;
35
+ apiType: APITypes;
36
+ }
37
+
38
+ export interface DBOSHTTPMethodInfo {
39
+ registrations?: DBOSHTTPReg[];
40
+ }
41
+
42
+ export interface DBOSHTTPArgInfo {
43
+ argSource?: ArgSources;
44
+ }
45
+
46
+ /**
47
+ * HTTPRequest includes useful information from http.IncomingMessage and parsed body,
48
+ * URL parameters, and parsed query string.
49
+ * In essence, it is the serializable part of the request.
50
+ */
51
+ export interface DBOSHTTPRequest {
52
+ readonly headers?: IncomingHttpHeaders; // A node's http.IncomingHttpHeaders object.
53
+ readonly rawHeaders?: string[]; // Raw headers.
54
+ readonly params?: unknown; // Parsed path parameters from the URL.
55
+ readonly body?: unknown; // parsed HTTP body as an object.
56
+ readonly rawBody?: string; // Unparsed raw HTTP body string.
57
+ readonly query?: ParsedUrlQuery; // Parsed query string.
58
+ readonly querystring?: string; // Unparsed raw query string.
59
+ readonly url?: string; // Request URL.
60
+ readonly method?: string; // Request HTTP method.
61
+ readonly ip?: string; // Request remote address.
62
+ readonly requestID?: string; // Request ID. Gathered from headers or generated if missing.
63
+ }
64
+
65
+ export const DBOSHTTP = 'dboshttp';
66
+
67
+ export const WorkflowIDHeader = 'dbos-idempotency-key';
68
+
69
+ export const RequestIDHeader = 'X-Request-ID';
70
+ export function getOrGenerateRequestID(headers: IncomingHttpHeaders): string {
71
+ const reqID = headers[RequestIDHeader.toLowerCase()] as string | undefined; // RequestIDHeader is expected to be a single value, so we dismiss the possible string[] returned type.
72
+ if (reqID) {
73
+ return reqID;
74
+ }
75
+ const newID = randomUUID();
76
+ headers[RequestIDHeader.toLowerCase()] = newID; // This does not carry through the response
77
+ return newID;
78
+ }
79
+
80
+ export function isClientRequestError(e: Error) {
81
+ return DBOSErrors.isDataValidationError(e);
82
+ }
83
+
84
+ export class DBOSHTTPBase extends DBOSLifecycleCallback {
85
+ static HTTP_OPERATION_TYPE: string = 'http';
86
+
87
+ static get httpRequest(): DBOSHTTPRequest {
88
+ const req = DBOS.requestObject();
89
+ if (req === undefined) {
90
+ throw new TypeError('`DBOSHTTP.httpRequest` accessed from outside of HTTP requests');
91
+ }
92
+ return req as DBOSHTTPRequest;
93
+ }
94
+
95
+ httpApiDec(verb: APITypes, url: string) {
96
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
97
+ const er = this;
98
+ return function apidec<This, Args extends unknown[], Return>(
99
+ target: object,
100
+ propertyKey: string,
101
+ descriptor: TypedPropertyDescriptor<(this: This, ...args: Args) => Promise<Return>>,
102
+ ) {
103
+ const { registration, regInfo } = DBOS.associateFunctionWithInfo(er, descriptor.value!, {
104
+ classOrInst: target,
105
+ name: propertyKey,
106
+ });
107
+ const handlerRegistration = regInfo as DBOSHTTPMethodInfo;
108
+ if (!handlerRegistration.registrations) handlerRegistration.registrations = [];
109
+ handlerRegistration.registrations.push({
110
+ apiURL: url,
111
+ apiType: verb,
112
+ });
113
+ requestArgValidation(registration);
114
+
115
+ return descriptor;
116
+ };
117
+ }
118
+
119
+ /** Decorator indicating that the method is the target of HTTP GET operations for `url` */
120
+ getApi(url: string) {
121
+ return this.httpApiDec(APITypes.GET, url);
122
+ }
123
+
124
+ /** Decorator indicating that the method is the target of HTTP POST operations for `url` */
125
+ postApi(url: string) {
126
+ return this.httpApiDec(APITypes.POST, url);
127
+ }
128
+
129
+ /** Decorator indicating that the method is the target of HTTP PUT operations for `url` */
130
+ putApi(url: string) {
131
+ return this.httpApiDec(APITypes.PUT, url);
132
+ }
133
+
134
+ /** Decorator indicating that the method is the target of HTTP PATCH operations for `url` */
135
+ patchApi(url: string) {
136
+ return this.httpApiDec(APITypes.PATCH, url);
137
+ }
138
+
139
+ /** Decorator indicating that the method is the target of HTTP DELETE operations for `url` */
140
+ deleteApi(url: string) {
141
+ return this.httpApiDec(APITypes.DELETE, url);
142
+ }
143
+
144
+ /** Parameter decorator indicating which source to use (URL, BODY, etc) for arg data */
145
+ argSource(source: ArgSources) {
146
+ return function (target: object, propertyKey: string | symbol, parameterIndex: number) {
147
+ const curParam = DBOS.associateParamWithInfo(
148
+ DBOSHTTP,
149
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
150
+ Object.getOwnPropertyDescriptor(target, propertyKey)!.value,
151
+ {
152
+ classOrInst: target,
153
+ name: propertyKey.toString(),
154
+ param: parameterIndex,
155
+ },
156
+ ) as DBOSHTTPArgInfo;
157
+
158
+ curParam.argSource = source;
159
+ };
160
+ }
161
+
162
+ getArgSource(arg: MethodParameter) {
163
+ const arginfo = arg.getRegisteredInfo(DBOSHTTP) as DBOSHTTPArgInfo;
164
+ return arginfo?.argSource ?? ArgSources.AUTO;
165
+ }
166
+
167
+ override logRegisteredEndpoints(): void {
168
+ DBOS.logger.info('HTTP endpoints supported:');
169
+ const eps = DBOS.getAssociatedInfo(this);
170
+
171
+ for (const e of eps) {
172
+ const { methodConfig, methodReg } = e;
173
+ const httpmethod = methodConfig as DBOSHTTPMethodInfo;
174
+ for (const ro of httpmethod.registrations ?? []) {
175
+ if (ro.apiURL) {
176
+ DBOS.logger.info(' ' + ro.apiType.padEnd(6) + ' : ' + ro.apiURL);
177
+ const roles = methodReg.getRequiredRoles();
178
+ if (roles.length > 0) {
179
+ DBOS.logger.info(' Required Roles: ' + JSON.stringify(roles));
180
+ }
181
+ }
182
+ }
183
+ }
184
+ }
185
+ }
package/src/dboskoa.ts ADDED
@@ -0,0 +1,415 @@
1
+ import Koa from 'koa';
2
+ import Router from '@koa/router';
3
+ import bodyParser from '@koa/bodyparser';
4
+ import cors from '@koa/cors';
5
+
6
+ import { W3CTraceContextPropagator } from '@opentelemetry/core';
7
+ import { SpanStatusCode, trace, defaultTextMapGetter, ROOT_CONTEXT } from '@opentelemetry/api';
8
+ import { Span } from '@opentelemetry/sdk-trace-base';
9
+
10
+ import { DBOS, Error as DBOSErrors } from '@dbos-inc/dbos-sdk';
11
+ import {
12
+ APITypes,
13
+ ArgSources,
14
+ DBOSHTTPAuthReturn,
15
+ DBOSHTTPBase,
16
+ DBOSHTTPMethodInfo,
17
+ getOrGenerateRequestID,
18
+ isClientRequestError,
19
+ RequestIDHeader,
20
+ WorkflowIDHeader,
21
+ } from './dboshttp';
22
+ import { AsyncLocalStorage } from 'async_hooks';
23
+
24
+ // Context for auth middleware
25
+ export interface DBOSKoaAuthContext {
26
+ readonly koaContext: Koa.Context;
27
+ readonly name: string; // Method (handler, transaction, workflow) name
28
+ readonly requiredRole: string[]; // Roles required for the invoked operation, if empty perhaps auth is not required
29
+ }
30
+
31
+ /**
32
+ * Authentication middleware that executes before a request reaches a function.
33
+ * This is expected to:
34
+ * - Validate the request found in the handler context and extract auth information from the request.
35
+ * - Map the HTTP request to the user identity and roles defined in app.
36
+ * If this succeeds, return the current authenticated user and a list of roles.
37
+ * If any step fails, throw an error.
38
+ */
39
+ export type DBOSKoaAuthMiddleware = (ctx: DBOSKoaAuthContext) => Promise<DBOSHTTPAuthReturn | void>;
40
+
41
+ export interface DBOSKoaConfig {
42
+ corsMiddleware?: boolean;
43
+ credentials?: boolean;
44
+ allowedOrigins?: string[];
45
+ }
46
+
47
+ export interface DBOSKoaClassReg {
48
+ authMiddleware?: DBOSKoaAuthMiddleware;
49
+ koaBodyParser?: Koa.Middleware;
50
+ koaCors?: Koa.Middleware;
51
+ koaMiddlewares?: Koa.Middleware[];
52
+ koaGlobalMiddlewares?: Koa.Middleware[];
53
+ }
54
+
55
+ function exhaustiveCheckGuard(_: never): never {
56
+ throw new Error('Exaustive matching is not applied');
57
+ }
58
+
59
+ interface DBOSKoaLocalCtx {
60
+ koaCtxt: Koa.Context;
61
+ }
62
+ const asyncLocalCtx = new AsyncLocalStorage<DBOSKoaLocalCtx>();
63
+
64
+ function getCurrentKoaContextStore(): DBOSKoaLocalCtx | undefined {
65
+ return asyncLocalCtx.getStore();
66
+ }
67
+
68
+ function assertCurrentKoaContextStore(): DBOSKoaLocalCtx {
69
+ const ctx = getCurrentKoaContextStore();
70
+ if (!ctx) throw new TypeError('Invalid use of `DBOSKoa.koaContext` outside of a handler function');
71
+ return ctx;
72
+ }
73
+
74
+ export class DBOSKoa extends DBOSHTTPBase {
75
+ static get koaContext(): Koa.Context {
76
+ return assertCurrentKoaContextStore().koaCtxt;
77
+ }
78
+
79
+ /**
80
+ * Define an authentication function for each endpoint in this class.
81
+ */
82
+ authentication(authMiddleware: DBOSKoaAuthMiddleware) {
83
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
84
+ const er = this;
85
+ function clsdec<T extends { new (...args: unknown[]): object }>(ctor: T) {
86
+ const clsreg = DBOS.associateClassWithInfo(er, ctor) as DBOSKoaClassReg;
87
+ clsreg.authMiddleware = authMiddleware;
88
+ }
89
+ return clsdec;
90
+ }
91
+
92
+ koaBodyParser(koaBodyParser: Koa.Middleware) {
93
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
94
+ const er = this;
95
+ function clsdec<T extends { new (...args: unknown[]): object }>(ctor: T) {
96
+ const clsreg = DBOS.associateClassWithInfo(er, ctor) as DBOSKoaClassReg;
97
+ clsreg.koaBodyParser = koaBodyParser;
98
+ }
99
+ return clsdec;
100
+ }
101
+
102
+ koaCors(koaCors: Koa.Middleware) {
103
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
104
+ const er = this;
105
+ function clsdec<T extends { new (...args: unknown[]): object }>(ctor: T) {
106
+ const clsreg = DBOS.associateClassWithInfo(er, ctor) as DBOSKoaClassReg;
107
+ clsreg.koaCors = koaCors;
108
+ }
109
+ return clsdec;
110
+ }
111
+
112
+ /**
113
+ * Define Koa middleware that is applied in order to each endpoint in this class.
114
+ */
115
+ koaMiddleware(...koaMiddleware: Koa.Middleware[]) {
116
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
117
+ const er = this;
118
+ koaMiddleware.forEach((i) => {
119
+ if (i === undefined) {
120
+ throw new TypeError('undefined argument passed to koaMiddleware');
121
+ }
122
+ });
123
+ function clsdec<T extends { new (...args: unknown[]): object }>(ctor: T) {
124
+ const clsreg = DBOS.associateClassWithInfo(er, ctor) as DBOSKoaClassReg;
125
+ clsreg.koaMiddlewares = [...(clsreg.koaMiddlewares ?? []), ...koaMiddleware];
126
+ }
127
+ return clsdec;
128
+ }
129
+
130
+ /**
131
+ * Define Koa middleware that is applied to all requests, including this class, other classes,
132
+ * or requests that do not end up in DBOS handlers at all.
133
+ * @deprecated Apply global middleware to your application router directly
134
+ */
135
+ koaGlobalMiddleware(...koaMiddleware: Koa.Middleware[]) {
136
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
137
+ const er = this;
138
+ koaMiddleware.forEach((i) => {
139
+ if (i === undefined) {
140
+ throw new TypeError('undefined argument passed to koaMiddleware');
141
+ }
142
+ });
143
+ function clsdec<T extends { new (...args: unknown[]): object }>(ctor: T) {
144
+ const clsreg = DBOS.associateClassWithInfo(er, ctor) as DBOSKoaClassReg;
145
+ clsreg.koaGlobalMiddlewares = [...(clsreg.koaGlobalMiddlewares ?? []), ...koaMiddleware];
146
+ }
147
+ return clsdec;
148
+ }
149
+
150
+ app?: Koa;
151
+ appRouter?: Router;
152
+
153
+ createApp(config?: DBOSKoaConfig) {
154
+ this.app = new Koa();
155
+ this.appRouter = new Router();
156
+ this.registerWithApp(this.app, this.appRouter, config);
157
+ }
158
+
159
+ registerWithApp(app: Koa, appRouter: Router, config?: DBOSKoaConfig) {
160
+ const globalMiddlewares: Set<Koa.Middleware> = new Set();
161
+
162
+ const eps = DBOS.getAssociatedInfo(this);
163
+ for (const e of eps) {
164
+ const { methodConfig, classConfig, methodReg } = e;
165
+ const defaults = classConfig as DBOSKoaClassReg;
166
+ const httpmethod = methodConfig as DBOSHTTPMethodInfo;
167
+
168
+ for (const ro of httpmethod?.registrations ?? []) {
169
+ // What about instance methods?
170
+ // Those would have to be registered another way that accepted the instances.
171
+ if (methodReg.isInstance) {
172
+ DBOS.logger.warn(
173
+ `Operation ${methodReg.className}/${methodReg.name} is registered with an endpoint (${ro.apiURL}) but cannot be invoked without an instance.`,
174
+ );
175
+ continue;
176
+ }
177
+
178
+ // Apply CORS, bodyParser, and other middlewares
179
+ // Check if we need to apply a custom CORS
180
+ if (defaults.koaCors) {
181
+ appRouter.all(ro.apiURL, defaults.koaCors); // Use router.all to register with all methods including preflight requests
182
+ } else {
183
+ if (config?.corsMiddleware ?? true) {
184
+ appRouter.all(
185
+ ro.apiURL,
186
+ cors({
187
+ credentials: config?.credentials ?? true,
188
+ origin: (o: Koa.Context) => {
189
+ const whitelist = config?.allowedOrigins;
190
+ const origin = o.request.header.origin ?? '*';
191
+ if (whitelist && whitelist.length > 0) {
192
+ return whitelist.includes(origin) ? origin : '';
193
+ }
194
+ return o.request.header.origin || '*';
195
+ },
196
+ allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS',
197
+ allowHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Authorization'],
198
+ }),
199
+ );
200
+ }
201
+ }
202
+
203
+ // Check if we need to apply any Koa global middleware.
204
+ if (defaults?.koaGlobalMiddlewares) {
205
+ defaults.koaGlobalMiddlewares.forEach((koaMiddleware) => {
206
+ if (globalMiddlewares.has(koaMiddleware)) {
207
+ return;
208
+ }
209
+ DBOS.logger.debug(`DBOS Koa Server applying middleware ${koaMiddleware.name} globally`);
210
+ globalMiddlewares.add(koaMiddleware);
211
+ app.use(koaMiddleware);
212
+ });
213
+ }
214
+
215
+ const wrappedHandler = async (koaCtxt: Koa.Context, koaNext: Koa.Next) => {
216
+ let authenticatedUser: string | undefined = undefined;
217
+ let authenticatedRoles: string[] | undefined = undefined;
218
+ let span: Span | undefined;
219
+ const httpTracer = new W3CTraceContextPropagator();
220
+
221
+ try {
222
+ // Auth first
223
+ if (defaults?.authMiddleware) {
224
+ const res = await defaults.authMiddleware({
225
+ name: methodReg.name,
226
+ requiredRole: methodReg.getRequiredRoles(),
227
+ koaContext: koaCtxt,
228
+ });
229
+ if (res) {
230
+ authenticatedUser = res.authenticatedUser;
231
+ authenticatedRoles = res.authenticatedRoles;
232
+ }
233
+ }
234
+
235
+ // Parse the arguments.
236
+ const args: unknown[] = [];
237
+ methodReg.args.forEach((marg) => {
238
+ let foundArg = undefined;
239
+ const isQueryMethod = ro.apiType === APITypes.GET || ro.apiType === APITypes.DELETE;
240
+ const isBodyMethod =
241
+ ro.apiType === APITypes.POST || ro.apiType === APITypes.PUT || ro.apiType === APITypes.PATCH;
242
+ const argSource = this.getArgSource(marg);
243
+
244
+ if ((isQueryMethod && argSource !== ArgSources.BODY) || argSource === ArgSources.QUERY) {
245
+ foundArg = koaCtxt.request.query[marg.name];
246
+ if (foundArg !== undefined) {
247
+ args.push(foundArg);
248
+ } else if (argSource === ArgSources.AUTO) {
249
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
250
+ foundArg = koaCtxt.request.body?.[marg.name];
251
+ if (foundArg !== undefined) {
252
+ args.push(foundArg);
253
+ }
254
+ }
255
+ } else if (isBodyMethod || argSource === ArgSources.BODY) {
256
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
257
+ foundArg = koaCtxt.request.body?.[marg.name];
258
+ if (foundArg !== undefined) {
259
+ args.push(foundArg);
260
+ } else if (argSource === ArgSources.AUTO) {
261
+ foundArg = koaCtxt.request.query[marg.name];
262
+ if (foundArg !== undefined) {
263
+ args.push(foundArg);
264
+ } else {
265
+ if (!koaCtxt.request.body) {
266
+ throw new DBOSErrors.DBOSDataValidationError(`Argument ${marg.name} requires a method body.`);
267
+ }
268
+ }
269
+ }
270
+ }
271
+
272
+ // Try to parse the argument from the URL if nothing found.
273
+ if (foundArg === undefined) {
274
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
275
+ args.push(koaCtxt.params[marg.name]);
276
+ }
277
+
278
+ //console.log(`found arg ${marg.name} ${idx} ${args[idx-1]}`);
279
+ });
280
+
281
+ // Extract workflow ID from headers (if any).
282
+ // We pass in the specified workflow ID in for any workflow started, should handler happen to do so.
283
+ const headerWorkflowID = koaCtxt.get(WorkflowIDHeader);
284
+
285
+ // Retrieve or generate the tracing request ID
286
+ const requestID = getOrGenerateRequestID(koaCtxt.request.headers);
287
+ koaCtxt.set(RequestIDHeader, requestID);
288
+
289
+ // If present, retrieve the trace context from the request
290
+ const extractedSpanContext = trace.getSpanContext(
291
+ httpTracer.extract(ROOT_CONTEXT, koaCtxt.request.headers, defaultTextMapGetter),
292
+ );
293
+ const spanAttributes = {
294
+ operationType: DBOSHTTPBase.HTTP_OPERATION_TYPE,
295
+ requestID: requestID,
296
+ requestIP: koaCtxt.request.ip,
297
+ requestURL: koaCtxt.request.url,
298
+ requestMethod: koaCtxt.request.method,
299
+ };
300
+ if (extractedSpanContext === undefined) {
301
+ span = DBOS.tracer?.startSpan(koaCtxt.url, spanAttributes);
302
+ } else {
303
+ extractedSpanContext.isRemote = true;
304
+ span = DBOS.tracer?.startSpanWithContext(extractedSpanContext, koaCtxt.url, spanAttributes);
305
+ }
306
+
307
+ // Finally, invoke the function and properly set HTTP response.
308
+ // If functions return successfully and hasn't set the body, we set the body to the function return value.
309
+ // The status code will be automatically set to 200 or 204 (if the body is null/undefined).
310
+ // In case of an exception:
311
+ // - If a client-side error is thrown, we return 400.
312
+ // - If an error contains a `status` field, we return the specified status code.
313
+ // - Otherwise, we return 500.
314
+ const cresult = await asyncLocalCtx.run({ koaCtxt }, async () => {
315
+ return await DBOS.runWithContext(
316
+ {
317
+ authenticatedUser,
318
+ authenticatedRoles,
319
+ idAssignedForNextWorkflow: headerWorkflowID,
320
+ span,
321
+ request: koaCtxt.request,
322
+ },
323
+ async () => {
324
+ return await methodReg.invoke(undefined, args);
325
+ },
326
+ );
327
+ });
328
+ const retValue = cresult!;
329
+
330
+ // Set the body to the return value unless the body is already set by the handler.
331
+ if (koaCtxt.body === undefined) {
332
+ koaCtxt.body = retValue;
333
+ }
334
+ span?.setStatus({ code: SpanStatusCode.OK });
335
+ } catch (e) {
336
+ if (e instanceof Error) {
337
+ span?.setStatus({ code: SpanStatusCode.ERROR, message: e.message });
338
+ let st = (e as DBOSErrors.DBOSResponseError)?.status || 500;
339
+ if (isClientRequestError(e)) {
340
+ st = 400; // Set to 400: client-side error.
341
+ }
342
+ koaCtxt.status = st;
343
+ koaCtxt.message = e.message;
344
+ koaCtxt.body = {
345
+ status: st,
346
+ message: e.message,
347
+ details: e,
348
+ };
349
+ } else {
350
+ // Thrown item was not an Error, which is poor form.
351
+ // using stringify() will not produce a pretty output, because our format function uses stringify() too.
352
+ DBOS.logger.error(e);
353
+ span?.setStatus({ code: SpanStatusCode.ERROR, message: JSON.stringify(e) });
354
+
355
+ koaCtxt.body = e;
356
+ koaCtxt.status = 500;
357
+ }
358
+ } finally {
359
+ // Inject trace context into response headers.
360
+ // We cannot use the defaultTextMapSetter to set headers through Koa
361
+ // So we provide a custom setter that sets headers through Koa's context.
362
+ // See https://github.com/open-telemetry/opentelemetry-js/blob/868f75e448c7c3a0efd75d72c448269f1375a996/packages/opentelemetry-core/src/trace/W3CTraceContextPropagator.ts#L74
363
+ interface Carrier {
364
+ context: Koa.Context;
365
+ }
366
+ if (span) {
367
+ httpTracer.inject(
368
+ trace.setSpanContext(ROOT_CONTEXT, span.spanContext()),
369
+ {
370
+ context: koaCtxt,
371
+ },
372
+ {
373
+ set: (carrier: Carrier, key: string, value: string) => {
374
+ carrier.context.set(key, value);
375
+ },
376
+ },
377
+ );
378
+ DBOS.tracer?.endSpan(span);
379
+ }
380
+ await koaNext();
381
+ }
382
+ };
383
+
384
+ // Middleware functions are applied directly to router verb methods to prevent duplicate calls.
385
+ const routeMiddlewares = [defaults.koaBodyParser ?? bodyParser()].concat(defaults.koaMiddlewares ?? []);
386
+ switch (ro.apiType) {
387
+ case APITypes.GET:
388
+ appRouter.get(ro.apiURL, ...routeMiddlewares, wrappedHandler);
389
+ DBOS.logger.debug(`DBOS Server Registered GET ${ro.apiURL}`);
390
+ break;
391
+ case APITypes.POST:
392
+ appRouter.post(ro.apiURL, ...routeMiddlewares, wrappedHandler);
393
+ DBOS.logger.debug(`DBOS Server Registered POST ${ro.apiURL}`);
394
+ break;
395
+ case APITypes.PUT:
396
+ appRouter.put(ro.apiURL, ...routeMiddlewares, wrappedHandler);
397
+ DBOS.logger.debug(`DBOS Server Registered PUT ${ro.apiURL}`);
398
+ break;
399
+ case APITypes.PATCH:
400
+ appRouter.patch(ro.apiURL, ...routeMiddlewares, wrappedHandler);
401
+ DBOS.logger.debug(`DBOS Server Registered PATCH ${ro.apiURL}`);
402
+ break;
403
+ case APITypes.DELETE:
404
+ appRouter.delete(ro.apiURL, ...routeMiddlewares, wrappedHandler);
405
+ DBOS.logger.debug(`DBOS Server Registered DELETE ${ro.apiURL}`);
406
+ break;
407
+ default:
408
+ exhaustiveCheckGuard(ro.apiType);
409
+ }
410
+ }
411
+ }
412
+
413
+ app.use(appRouter.routes()).use(appRouter.allowedMethods());
414
+ }
415
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export {
2
+ ArgSources,
3
+ DBOSHTTP,
4
+ DBOSHTTPArgInfo,
5
+ DBOSHTTPAuthReturn,
6
+ DBOSHTTPBase,
7
+ DBOSHTTPMethodInfo,
8
+ DBOSHTTPReg,
9
+ DBOSHTTPRequest,
10
+ RequestIDHeader,
11
+ WorkflowIDHeader,
12
+ } from './dboshttp';
13
+
14
+ export { DBOSKoa, DBOSKoaAuthContext, DBOSKoaClassReg, DBOSKoaAuthMiddleware, DBOSKoaConfig } from './dboskoa';