@terreno/api 0.11.6 → 0.11.8

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.
@@ -0,0 +1,302 @@
1
+ import {beforeEach, describe, expect, it, mock} from "bun:test";
2
+ import * as Sentry from "@sentry/bun";
3
+ import type {NextFunction, Request, Response} from "express";
4
+ import {Schema} from "mongoose";
5
+
6
+ import {
7
+ APIError,
8
+ apiErrorMiddleware,
9
+ apiUnauthorizedMiddleware,
10
+ errorsPlugin,
11
+ getAPIErrorBody,
12
+ getDisableExternalErrorTracking,
13
+ isAPIError,
14
+ } from "./errors";
15
+
16
+ interface MockResponse {
17
+ status: ReturnType<typeof mock>;
18
+ json: ReturnType<typeof mock>;
19
+ send: ReturnType<typeof mock>;
20
+ }
21
+
22
+ const buildResponse = (): MockResponse => {
23
+ const res: MockResponse = {
24
+ json: mock(() => res),
25
+ send: mock(() => res),
26
+ status: mock(() => res),
27
+ };
28
+ return res;
29
+ };
30
+
31
+ describe("APIError", () => {
32
+ it("creates an error with the provided fields", () => {
33
+ const error = new APIError({
34
+ code: "validation-failed",
35
+ detail: "Email is invalid",
36
+ id: "abc-123",
37
+ links: {about: "https://example.com/help", type: "https://example.com/types/validation"},
38
+ meta: {requestId: "req-1"},
39
+ source: {header: "x-foo", parameter: "limit", pointer: "/data/email"},
40
+ status: 400,
41
+ title: "Validation failed",
42
+ });
43
+
44
+ expect(error).toBeInstanceOf(Error);
45
+ expect(error.name).toBe("APIError");
46
+ expect(error.title).toBe("Validation failed");
47
+ expect(error.detail).toBe("Email is invalid");
48
+ expect(error.code).toBe("validation-failed");
49
+ expect(error.status).toBe(400);
50
+ expect(error.id).toBe("abc-123");
51
+ expect(error.links).toEqual({
52
+ about: "https://example.com/help",
53
+ type: "https://example.com/types/validation",
54
+ });
55
+ expect(error.source).toEqual({
56
+ header: "x-foo",
57
+ parameter: "limit",
58
+ pointer: "/data/email",
59
+ });
60
+ expect(error.meta).toEqual({requestId: "req-1"});
61
+ });
62
+
63
+ it("includes the title and detail in the error message", () => {
64
+ const error = new APIError({detail: "Something exploded", title: "Boom"});
65
+ expect(error.message).toBe("Boom: Something exploded");
66
+ });
67
+
68
+ it("includes the wrapped error stack in the message", () => {
69
+ const wrapped = new Error("inner");
70
+ const error = new APIError({error: wrapped, title: "Outer"});
71
+ expect(error.message).toContain("Outer");
72
+ expect(error.message).toContain(wrapped.stack ?? "");
73
+ });
74
+
75
+ it("defaults status to 500 when status is omitted", () => {
76
+ const error = new APIError({title: "No status"});
77
+ expect(error.status).toBe(500);
78
+ });
79
+
80
+ it("forces status to 500 when below 400", () => {
81
+ const error = new APIError({status: 200, title: "Too low"});
82
+ expect(error.status).toBe(500);
83
+ });
84
+
85
+ it("forces status to 500 when above 599", () => {
86
+ const error = new APIError({status: 600, title: "Too high"});
87
+ expect(error.status).toBe(500);
88
+ });
89
+
90
+ it("defaults meta to an empty object when not provided", () => {
91
+ const error = new APIError({title: "No meta"});
92
+ expect(error.meta).toEqual({});
93
+ });
94
+
95
+ it("merges fields into meta", () => {
96
+ const error = new APIError({
97
+ fields: {email: "Required", name: "Required"},
98
+ title: "Validation",
99
+ });
100
+ expect(error.meta?.fields).toEqual({email: "Required", name: "Required"});
101
+ });
102
+
103
+ it("respects disableExternalErrorTracking", () => {
104
+ const trackedError = new APIError({title: "Tracked"});
105
+ const untrackedError = new APIError({
106
+ disableExternalErrorTracking: true,
107
+ title: "Untracked",
108
+ });
109
+ expect(trackedError.disableExternalErrorTracking).toBeUndefined();
110
+ expect(untrackedError.disableExternalErrorTracking).toBe(true);
111
+ });
112
+ });
113
+
114
+ describe("isAPIError", () => {
115
+ it("returns true for an APIError instance", () => {
116
+ expect(isAPIError(new APIError({title: "Boom"}))).toBe(true);
117
+ });
118
+
119
+ it("returns false for a regular Error", () => {
120
+ expect(isAPIError(new Error("nope"))).toBe(false);
121
+ });
122
+
123
+ it("returns true for any error whose name is APIError", () => {
124
+ const err = new Error("custom");
125
+ err.name = "APIError";
126
+ expect(isAPIError(err)).toBe(true);
127
+ });
128
+ });
129
+
130
+ describe("getDisableExternalErrorTracking", () => {
131
+ it("returns the flag from an APIError", () => {
132
+ const error = new APIError({disableExternalErrorTracking: true, title: "Test"});
133
+ expect(getDisableExternalErrorTracking(error)).toBe(true);
134
+ });
135
+
136
+ it("returns undefined for a plain Error without the flag", () => {
137
+ expect(getDisableExternalErrorTracking(new Error("plain"))).toBeUndefined();
138
+ });
139
+
140
+ it("returns the flag when attached to a non-APIError object", () => {
141
+ const error = {disableExternalErrorTracking: false};
142
+ expect(getDisableExternalErrorTracking(error)).toBe(false);
143
+ });
144
+
145
+ it("returns undefined for primitives and null", () => {
146
+ expect(getDisableExternalErrorTracking(null)).toBeUndefined();
147
+ expect(getDisableExternalErrorTracking(undefined)).toBeUndefined();
148
+ expect(getDisableExternalErrorTracking("string")).toBeUndefined();
149
+ expect(getDisableExternalErrorTracking(42)).toBeUndefined();
150
+ });
151
+
152
+ it("returns undefined for an object missing the property", () => {
153
+ expect(getDisableExternalErrorTracking({foo: "bar"})).toBeUndefined();
154
+ });
155
+ });
156
+
157
+ describe("getAPIErrorBody", () => {
158
+ it("returns title and status by default", () => {
159
+ const error = new APIError({status: 404, title: "Not Found"});
160
+ const body = getAPIErrorBody(error);
161
+ expect(body).toEqual({meta: {}, status: 404, title: "Not Found"});
162
+ });
163
+
164
+ it("includes optional fields when set", () => {
165
+ const error = new APIError({
166
+ code: "not-found",
167
+ detail: "Could not find resource",
168
+ disableExternalErrorTracking: true,
169
+ id: "err-1",
170
+ links: {about: "https://example.com/help"},
171
+ source: {pointer: "/data/id"},
172
+ status: 404,
173
+ title: "Not Found",
174
+ });
175
+ const body = getAPIErrorBody(error);
176
+ expect(body).toEqual({
177
+ code: "not-found",
178
+ detail: "Could not find resource",
179
+ disableExternalErrorTracking: true,
180
+ id: "err-1",
181
+ links: {about: "https://example.com/help"},
182
+ meta: {},
183
+ source: {pointer: "/data/id"},
184
+ status: 404,
185
+ title: "Not Found",
186
+ });
187
+ });
188
+
189
+ it("omits empty meta and unset optional fields", () => {
190
+ const error = new APIError({status: 400, title: "Bad"});
191
+ // meta defaults to {} which is truthy, so it is included.
192
+ const body = getAPIErrorBody(error);
193
+ expect(body.meta).toEqual({});
194
+ expect(body.code).toBeUndefined();
195
+ expect(body.detail).toBeUndefined();
196
+ expect(body.id).toBeUndefined();
197
+ expect(body.links).toBeUndefined();
198
+ expect(body.source).toBeUndefined();
199
+ });
200
+ });
201
+
202
+ describe("errorsPlugin", () => {
203
+ it("adds an apiErrors array field to the schema", () => {
204
+ const schema = new Schema({name: String});
205
+ errorsPlugin(schema);
206
+ const path = schema.path("apiErrors");
207
+ expect(path).toBeDefined();
208
+ });
209
+
210
+ it("requires title on each error subdocument", () => {
211
+ const schema = new Schema({name: String});
212
+ errorsPlugin(schema);
213
+ const path = schema.path("apiErrors");
214
+ // Inspect the embedded error schema for the title definition.
215
+ const embedded = path as unknown as {schema: Schema};
216
+ const titlePath = embedded.schema.path("title");
217
+ expect(titlePath).toBeDefined();
218
+ expect(titlePath.isRequired).toBe(true);
219
+ });
220
+ });
221
+
222
+ describe("apiUnauthorizedMiddleware", () => {
223
+ let res: MockResponse;
224
+ let next: ReturnType<typeof mock>;
225
+ const req = {} as Request;
226
+
227
+ beforeEach(() => {
228
+ res = buildResponse();
229
+ next = mock(() => {});
230
+ });
231
+
232
+ it("returns a 401 JSON response when the message is Unauthorized", () => {
233
+ apiUnauthorizedMiddleware(
234
+ new Error("Unauthorized"),
235
+ req,
236
+ res as unknown as Response,
237
+ next as unknown as NextFunction
238
+ );
239
+ expect(res.status).toHaveBeenCalledWith(401);
240
+ expect(res.json).toHaveBeenCalledWith({status: 401, title: "Unauthorized"});
241
+ expect(res.send).toHaveBeenCalled();
242
+ expect(next).not.toHaveBeenCalled();
243
+ });
244
+
245
+ it("forwards other errors to next", () => {
246
+ const err = new Error("Something else");
247
+ apiUnauthorizedMiddleware(
248
+ err,
249
+ req,
250
+ res as unknown as Response,
251
+ next as unknown as NextFunction
252
+ );
253
+ expect(next).toHaveBeenCalledWith(err);
254
+ expect(res.status).not.toHaveBeenCalled();
255
+ });
256
+ });
257
+
258
+ describe("apiErrorMiddleware", () => {
259
+ let res: MockResponse;
260
+ let next: ReturnType<typeof mock>;
261
+ const req = {} as Request;
262
+ const captureExceptionSpy = Sentry.captureException as unknown as ReturnType<typeof mock>;
263
+
264
+ beforeEach(() => {
265
+ res = buildResponse();
266
+ next = mock(() => {});
267
+ captureExceptionSpy.mockClear?.();
268
+ });
269
+
270
+ it("responds with the APIError status and body", () => {
271
+ const err = new APIError({detail: "missing", status: 404, title: "Not Found"});
272
+ apiErrorMiddleware(err, req, res as unknown as Response, next as unknown as NextFunction);
273
+ expect(res.status).toHaveBeenCalledWith(404);
274
+ expect(res.json).toHaveBeenCalledWith(getAPIErrorBody(err));
275
+ expect(res.send).toHaveBeenCalled();
276
+ expect(next).not.toHaveBeenCalled();
277
+ });
278
+
279
+ it("captures the exception with Sentry by default", () => {
280
+ const err = new APIError({status: 500, title: "Boom"});
281
+ apiErrorMiddleware(err, req, res as unknown as Response, next as unknown as NextFunction);
282
+ expect(captureExceptionSpy).toHaveBeenCalledWith(err);
283
+ });
284
+
285
+ it("does not capture the exception when disableExternalErrorTracking is true", () => {
286
+ const err = new APIError({
287
+ disableExternalErrorTracking: true,
288
+ status: 500,
289
+ title: "Quiet",
290
+ });
291
+ apiErrorMiddleware(err, req, res as unknown as Response, next as unknown as NextFunction);
292
+ expect(captureExceptionSpy).not.toHaveBeenCalled();
293
+ expect(res.status).toHaveBeenCalledWith(500);
294
+ });
295
+
296
+ it("forwards non-APIError errors to next", () => {
297
+ const err = new Error("not an api error");
298
+ apiErrorMiddleware(err, req, res as unknown as Response, next as unknown as NextFunction);
299
+ expect(next).toHaveBeenCalledWith(err);
300
+ expect(res.status).not.toHaveBeenCalled();
301
+ });
302
+ });
package/src/errors.ts CHANGED
@@ -137,7 +137,7 @@ export class APIError extends Error {
137
137
 
138
138
  // Create an errors field for storing error information in a JSONAPI compatible form directly on a
139
139
  // model.
140
- export function errorsPlugin(schema: Schema): void {
140
+ export const errorsPlugin = (schema: Schema): void => {
141
141
  const errorSchema = new Schema({
142
142
  code: {description: "Application-specific error code", type: String},
143
143
  detail: {description: "Human-readable explanation of the error", type: String},
@@ -160,18 +160,18 @@ export function errorsPlugin(schema: Schema): void {
160
160
  });
161
161
 
162
162
  schema.add({apiErrors: errorSchema});
163
- }
163
+ };
164
164
 
165
- export function isAPIError(error: Error): error is APIError {
165
+ export const isAPIError = (error: Error): error is APIError => {
166
166
  return error.name === "APIError";
167
- }
167
+ };
168
168
 
169
169
  /**
170
170
  * Safely extracts the disableExternalErrorTracking property from an error.
171
171
  * Works with both APIError instances and regular Error objects that may have
172
172
  * this property attached.
173
173
  */
174
- export function getDisableExternalErrorTracking(error: unknown): boolean | undefined {
174
+ export const getDisableExternalErrorTracking = (error: unknown): boolean | undefined => {
175
175
  if (error instanceof Error) {
176
176
  if (isAPIError(error)) {
177
177
  return error.disableExternalErrorTracking;
@@ -181,12 +181,12 @@ export function getDisableExternalErrorTracking(error: unknown): boolean | undef
181
181
  return (error as {disableExternalErrorTracking?: boolean}).disableExternalErrorTracking;
182
182
  }
183
183
  return undefined;
184
- }
184
+ };
185
185
 
186
186
  // Creates an APIError body to send to clients as JSON. Errors don't have a toJSON defined,
187
187
  // and we want to strip out things like message, name, and stack for the client.
188
188
  // There is almost certainly a more elegant solution to this.
189
- export function getAPIErrorBody(error: APIError): {[id: string]: any} {
189
+ export const getAPIErrorBody = (error: APIError): {[id: string]: any} => {
190
190
  const errorData = {status: error.status, title: error.title};
191
191
  for (const key of [
192
192
  "id",
@@ -203,23 +203,28 @@ export function getAPIErrorBody(error: APIError): {[id: string]: any} {
203
203
  }
204
204
  }
205
205
  return errorData;
206
- }
206
+ };
207
207
 
208
- export function apiUnauthorizedMiddleware(
208
+ export const apiUnauthorizedMiddleware = (
209
209
  err: Error,
210
210
  _req: Request,
211
211
  res: Response,
212
212
  next: NextFunction
213
- ) {
213
+ ) => {
214
214
  if (err.message === "Unauthorized") {
215
215
  // not using the actual APIError class here because we don't want to log it as an error.
216
216
  res.status(401).json({status: 401, title: "Unauthorized"}).send();
217
217
  } else {
218
218
  next(err);
219
219
  }
220
- }
220
+ };
221
221
 
222
- export function apiErrorMiddleware(err: Error, _req: Request, res: Response, next: NextFunction) {
222
+ export const apiErrorMiddleware = (
223
+ err: Error,
224
+ _req: Request,
225
+ res: Response,
226
+ next: NextFunction
227
+ ) => {
223
228
  if (isAPIError(err)) {
224
229
  if (!err.disableExternalErrorTracking) {
225
230
  Sentry.captureException(err);
@@ -228,4 +233,4 @@ export function apiErrorMiddleware(err: Error, _req: Request, res: Response, nex
228
233
  } else {
229
234
  next(err);
230
235
  }
231
- }
236
+ };
package/src/middleware.ts CHANGED
@@ -9,10 +9,14 @@ import type {NextFunction, Request, Response} from "express";
9
9
  *
10
10
  * Expected header: `App-Version`
11
11
  */
12
- export function sentryAppVersionMiddleware(req: Request, _res: Response, next: NextFunction): void {
12
+ export const sentryAppVersionMiddleware = (
13
+ req: Request,
14
+ _res: Response,
15
+ next: NextFunction
16
+ ): void => {
13
17
  const appVersion = req.get("App-Version");
14
18
  if (appVersion) {
15
19
  Sentry.getCurrentScope().setTag("app_version", appVersion);
16
20
  }
17
21
  next();
18
- }
22
+ };
@@ -95,13 +95,14 @@ export const sendToZoom = async (
95
95
  },
96
96
  }
97
97
  );
98
- } catch (error: any) {
99
- logger.error(`Error posting to Zoom: ${error.text ?? error.message}`);
98
+ } catch (error: unknown) {
99
+ const errorMessage = error instanceof Error ? error.message : String(error);
100
+ logger.error(`Error posting to Zoom: ${errorMessage}`);
100
101
  Sentry.captureException(error);
101
102
  if (shouldThrow) {
102
103
  throw new APIError({
103
104
  status: 500,
104
- title: `Error posting to Zoom: ${error.text ?? error.message}`,
105
+ title: `Error posting to Zoom: ${errorMessage}`,
105
106
  });
106
107
  }
107
108
  }
@@ -110,7 +110,7 @@ const patchRouterStack = (stack: any[]): void => {
110
110
  */
111
111
  export const patchAppUse = (app: any): void => {
112
112
  const originalUse = app.use.bind(app);
113
- app.use = function patchedUse(...args: any[]) {
113
+ const patchedUse = (...args: any[]): unknown => {
114
114
  // Track stack length before the call
115
115
  const router = app._router || app.router;
116
116
  const stackBefore = router?.stack?.length ?? 0;
@@ -132,6 +132,7 @@ export const patchAppUse = (app: any): void => {
132
132
 
133
133
  return result;
134
134
  };
135
+ app.use = patchedUse;
135
136
  };
136
137
 
137
138
  /**
@@ -6,7 +6,7 @@ import type {NextFunction, Request, Response} from "express";
6
6
  * This middleware should be added before the @wesleytodd/openapi middleware
7
7
  * to intercept requests to /openapi.json and add conditional request support.
8
8
  */
9
- export function openApiEtagMiddleware(req: Request, res: Response, next: NextFunction): void {
9
+ export const openApiEtagMiddleware = (req: Request, res: Response, next: NextFunction): void => {
10
10
  // Only handle GET requests to /openapi.json
11
11
  if (req.method !== "GET" || req.path !== "/openapi.json") {
12
12
  next();
@@ -37,4 +37,4 @@ export function openApiEtagMiddleware(req: Request, res: Response, next: NextFun
37
37
  };
38
38
 
39
39
  next();
40
- }
40
+ };