@pbvision/fastify-firestore-service 0.0.50
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/LICENSE +204 -0
- package/README.md +187 -0
- package/docs/api.md +706 -0
- package/docs/build.sh +24 -0
- package/docs/jsdoc.config.json +22 -0
- package/package.json +93 -0
- package/src/api/api.js +852 -0
- package/src/api/db-api.js +109 -0
- package/src/api/exception.js +344 -0
- package/src/api/response.js +8 -0
- package/src/component-registrar.js +27 -0
- package/src/fetch-wrapper.js +30 -0
- package/src/index.js +13 -0
- package/src/make-app.js +223 -0
- package/src/make-logger.js +27 -0
- package/src/plugins/compress.js +28 -0
- package/src/plugins/content-parser.js +21 -0
- package/src/plugins/cookie.js +17 -0
- package/src/plugins/error-handler.js +180 -0
- package/src/plugins/health-check.js +19 -0
- package/src/plugins/latency-tracker.js +34 -0
- package/src/plugins/sentry-rate-limit.js +37 -0
- package/src/plugins/swagger.js +50 -0
- package/test/base-test.js +203 -0
- package/test/environment.js +3 -0
package/src/api/api.js
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import querystring from 'node:querystring'
|
|
3
|
+
|
|
4
|
+
import S from '@pbvision/schema'
|
|
5
|
+
|
|
6
|
+
import fetchWrapper from '../fetch-wrapper.js'
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
BadRequestException,
|
|
10
|
+
InvalidInputException,
|
|
11
|
+
InternalFailureException,
|
|
12
|
+
RedirectException,
|
|
13
|
+
RequestDone,
|
|
14
|
+
RequestError
|
|
15
|
+
} from './exception.js'
|
|
16
|
+
import RESPONSES from './response.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Given an object, return a new object with undefined keys removed.
|
|
20
|
+
* @param {*} obj Input object, with potentially undefined keys
|
|
21
|
+
* @returns new obj with no undefined keys
|
|
22
|
+
* @private
|
|
23
|
+
*/
|
|
24
|
+
function pruneUndefined (obj) {
|
|
25
|
+
return Object.entries(obj).reduce((all, [key, val]) => {
|
|
26
|
+
if (val !== undefined) {
|
|
27
|
+
all[key] = val
|
|
28
|
+
}
|
|
29
|
+
return all
|
|
30
|
+
}, {})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Public APIs (accessible without any user credentials) should be defined as
|
|
35
|
+
* a subclass of @this.
|
|
36
|
+
*
|
|
37
|
+
* Override METHOD, PATH, QS, etc. to define the API. Swagger documentation
|
|
38
|
+
* will be automatically generated for your API.
|
|
39
|
+
*
|
|
40
|
+
* Don't instantiate API instances yourself. Define the subclass, and pass it
|
|
41
|
+
* to the service creation function from make-app.js and it will take care of
|
|
42
|
+
* setting up routing for your API, etc.
|
|
43
|
+
*
|
|
44
|
+
* @public
|
|
45
|
+
* @class
|
|
46
|
+
*/
|
|
47
|
+
class API {
|
|
48
|
+
/**
|
|
49
|
+
* The API's human-readable name.
|
|
50
|
+
*
|
|
51
|
+
* By default, this is computed by de-camel-casing the class name.
|
|
52
|
+
* @public
|
|
53
|
+
*/
|
|
54
|
+
static get NAME () {
|
|
55
|
+
// the name is the de-camel-cased class name
|
|
56
|
+
const clsName = this.name
|
|
57
|
+
const ret = []
|
|
58
|
+
let cur = []
|
|
59
|
+
|
|
60
|
+
function wordDone () {
|
|
61
|
+
if (cur.length) {
|
|
62
|
+
ret.push(cur.join(''))
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let prevWasUpper = false
|
|
67
|
+
let isAcronym = false
|
|
68
|
+
const len = clsName.length - (clsName.endsWith('API') ? 3 : 0)
|
|
69
|
+
for (let i = 0; i < len; i++) {
|
|
70
|
+
const ch = clsName[i]
|
|
71
|
+
const code = ch.charCodeAt(0)
|
|
72
|
+
if (code >= 65 && code <= 90) {
|
|
73
|
+
if (prevWasUpper) {
|
|
74
|
+
// multiple uppercase in a row is an acronym; keep it together
|
|
75
|
+
isAcronym = true
|
|
76
|
+
cur.push(ch)
|
|
77
|
+
} else {
|
|
78
|
+
// previous word done
|
|
79
|
+
wordDone()
|
|
80
|
+
cur = [ch]
|
|
81
|
+
}
|
|
82
|
+
prevWasUpper = true
|
|
83
|
+
} else {
|
|
84
|
+
if (isAcronym) {
|
|
85
|
+
// the last uppercase letter is part of the next word (not part of
|
|
86
|
+
// the acronym)
|
|
87
|
+
const lastUpper = cur.splice(cur.length - 1, 1)[0]
|
|
88
|
+
wordDone()
|
|
89
|
+
cur = [lastUpper]
|
|
90
|
+
isAcronym = false
|
|
91
|
+
}
|
|
92
|
+
cur.push(ch)
|
|
93
|
+
prevWasUpper = false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
wordDone()
|
|
97
|
+
return ret.join(' ')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* istanbul ignore next */
|
|
101
|
+
/**
|
|
102
|
+
* The HTTP method used to request this API (e.g., GET, POST).
|
|
103
|
+
* @public
|
|
104
|
+
*/
|
|
105
|
+
static METHOD = 'POST'
|
|
106
|
+
|
|
107
|
+
/* istanbul ignore next */
|
|
108
|
+
/**
|
|
109
|
+
* The HTTP path suffix used to request this API.
|
|
110
|
+
* @public
|
|
111
|
+
* @abstract
|
|
112
|
+
*/
|
|
113
|
+
static get PATH () {
|
|
114
|
+
assert.fail('PATH must be overridden in ' + this.name)
|
|
115
|
+
return undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* istanbul ignore next */
|
|
119
|
+
/**
|
|
120
|
+
* A human-readable description of what this API does. It is only used when
|
|
121
|
+
* automatically generating the Swagger documentation for the API.
|
|
122
|
+
*
|
|
123
|
+
* When used with the Swagger docs, newlines will be replaced with a single
|
|
124
|
+
* space character. May use Markdown formatting.
|
|
125
|
+
* @public
|
|
126
|
+
* @abstract
|
|
127
|
+
*/
|
|
128
|
+
static get DESC () {
|
|
129
|
+
assert.fail('DESC must be overridden')
|
|
130
|
+
return undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Returns DESC as a string ready for output as Markdown.
|
|
135
|
+
* @private
|
|
136
|
+
*/
|
|
137
|
+
static getDescMarkdownString () {
|
|
138
|
+
return this.DESC.trim().replace(/\n/g, ' ')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Tag used to group APIs in the generated Swagger documentation. Set to null
|
|
143
|
+
* to exclude it from the generated Swagger documentation.
|
|
144
|
+
* @returns {String} a string tag
|
|
145
|
+
* @public
|
|
146
|
+
*/
|
|
147
|
+
static TAG = undefined
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The Schema describing the HTTP headers this API handles, if any.
|
|
151
|
+
* @public
|
|
152
|
+
*/
|
|
153
|
+
static HEADERS = undefined
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The Schema describing the path params this API handles, if any.
|
|
157
|
+
* @public
|
|
158
|
+
*/
|
|
159
|
+
static PATH_PARAMS = undefined
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The Schema describing the query string this API handles, if any.
|
|
163
|
+
* @public
|
|
164
|
+
*/
|
|
165
|
+
static QS = undefined
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The Schema describing the request body this API handles. Note that
|
|
169
|
+
* GET requests should not have request bodies. Use HTTP POST requests if
|
|
170
|
+
* request data size is too big to fit in the query string.
|
|
171
|
+
* @public
|
|
172
|
+
*/
|
|
173
|
+
static BODY = undefined
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The schema describes the response body. This is used to verify that the
|
|
177
|
+
* implementation produces a correctly shaped output. It also speeds up JSON
|
|
178
|
+
* output serialization by 10-20%.
|
|
179
|
+
*
|
|
180
|
+
* This can return one of two things:
|
|
181
|
+
* 1) {@see ResponseSchema} - responses with HTTP 200 status codes
|
|
182
|
+
* will be validated against this schema. Non-200 responses can be
|
|
183
|
+
* anything. This is the typical return value.
|
|
184
|
+
* 2) A subclass of {@see RequestDone}.
|
|
185
|
+
*
|
|
186
|
+
* The default is to not allow any output on HTTP 200 responses.
|
|
187
|
+
* @public
|
|
188
|
+
*/
|
|
189
|
+
static RESPONSE = RESPONSES.NO_OUTPUT
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Errors that may be returned from the API. Errors are subclasses
|
|
193
|
+
* of RequestError.
|
|
194
|
+
* @public
|
|
195
|
+
*/
|
|
196
|
+
static ERRORS = {}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Whether to log request body to Sentry when error.
|
|
200
|
+
* For API with sensitive user data, this shouldn't set to true.
|
|
201
|
+
* @public
|
|
202
|
+
*/
|
|
203
|
+
static LOG_REQUEST_BODY_ON_ERROR = false
|
|
204
|
+
|
|
205
|
+
constructor (fastify, req, reply) {
|
|
206
|
+
this.fastify = fastify
|
|
207
|
+
this.log = fastify.log
|
|
208
|
+
this.redis = fastify.redis
|
|
209
|
+
this.req = req
|
|
210
|
+
this.__reply = reply
|
|
211
|
+
if (this.constructor.CORS_ORIGIN) {
|
|
212
|
+
this.__setCORSHeaders(
|
|
213
|
+
this.constructor.getCORSOrigin(), this.constructor.CORS_HEADERS)
|
|
214
|
+
}
|
|
215
|
+
reply.logRequestBodyOnError = this.constructor.LOG_REQUEST_BODY_ON_ERROR
|
|
216
|
+
reply.apiName = this.constructor.name
|
|
217
|
+
this.req.__sentry = { context: {}, tags: {} }
|
|
218
|
+
this.__trackInputsWithSentry()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The hostname from which this API can be called in a browser. By default
|
|
223
|
+
* this API cannot be called from a browser due to CORS policy (combined with
|
|
224
|
+
* the fact that no web application runs on our API subdomain).
|
|
225
|
+
* @public
|
|
226
|
+
*/
|
|
227
|
+
static CORS_ORIGIN = undefined
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Returns the CORS origin to use. On localhost, a non-wildcard CORS_ORIGIN
|
|
231
|
+
* is returned as the localhost web app domain instead.
|
|
232
|
+
* @private
|
|
233
|
+
*/
|
|
234
|
+
static getCORSOrigin () {
|
|
235
|
+
const origin = this.CORS_ORIGIN
|
|
236
|
+
if (origin && origin !== '*' && origin !== 'null') {
|
|
237
|
+
/* istanbul ignore else */
|
|
238
|
+
if (process.env.NODE_ENV === 'localhost') {
|
|
239
|
+
return 'http://localhost:3000'
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return origin
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The header(s) which are allowed in CORS requests.
|
|
247
|
+
* @public
|
|
248
|
+
*/
|
|
249
|
+
static CORS_HEADERS = ['Content-Type']
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Set headers to allow this API to be used in a browser from another origin.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} origin the hostname from which this API can be called via
|
|
255
|
+
* CORS. On localhost, the origin is ignored and localhost is used instead.
|
|
256
|
+
* @param {Array<string>} headers an optional list of headers to allow when
|
|
257
|
+
* this API is requested via CORS
|
|
258
|
+
* @private
|
|
259
|
+
*/
|
|
260
|
+
__setCORSHeaders (origin, headers) {
|
|
261
|
+
this.constructor.setCORSHeadersOnReply(this.__reply, origin, headers)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
static setCORSHeadersOnReply (reply, origin, headers) {
|
|
265
|
+
reply.header('Access-Control-Allow-Origin', origin)
|
|
266
|
+
if (headers && headers.length) {
|
|
267
|
+
reply.header('Access-Control-Allow-Headers', headers.join(', '))
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Calls computeResponse() inside _callAndHandleRequestDone().
|
|
273
|
+
* @returns {Object|String} the HTTP response body
|
|
274
|
+
* @private
|
|
275
|
+
*/
|
|
276
|
+
async _computeResponse () {
|
|
277
|
+
return this._callAndHandleRequestDone(this.computeResponse, this.req)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Runs func(...args) and catches and handles RequestDone, if it is thrown.
|
|
282
|
+
* @param {Function} func the function to run
|
|
283
|
+
* @param {...any} args the arguments to call func with
|
|
284
|
+
* @returns the response data
|
|
285
|
+
* @private
|
|
286
|
+
*/
|
|
287
|
+
async _callAndHandleRequestDone (func, ...args) {
|
|
288
|
+
return this.constructor._callAndHandleRequestDone(
|
|
289
|
+
this.__reply, async () => func.call(this, ...args))
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Runs func and catches and handles RequestDone, if it is thrown.
|
|
294
|
+
* @param {fastify-reply} reply the reply object
|
|
295
|
+
* @param {Function} func the function to run
|
|
296
|
+
* @returns the response data
|
|
297
|
+
* @private
|
|
298
|
+
*/
|
|
299
|
+
static async _callAndHandleRequestDone (reply, func) {
|
|
300
|
+
try {
|
|
301
|
+
return await func()
|
|
302
|
+
} catch (e) {
|
|
303
|
+
if (e instanceof RequestDone) {
|
|
304
|
+
reply.code(e.httpCode)
|
|
305
|
+
return e.respData
|
|
306
|
+
} else {
|
|
307
|
+
if (e.statusCode) {
|
|
308
|
+
delete e.statusCode
|
|
309
|
+
e.httpCode = 500
|
|
310
|
+
}
|
|
311
|
+
throw e
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/* istanbul ignore next */
|
|
317
|
+
/**
|
|
318
|
+
* The API logic. This method is called after all inputs are validated
|
|
319
|
+
* according to the schemas specified by the API definition.
|
|
320
|
+
* @arg {Request} req the fastify request object being handled
|
|
321
|
+
* @returns {Object|String} optional JSON-able object or string to send back
|
|
322
|
+
* as the HTTP response body
|
|
323
|
+
* @abstract
|
|
324
|
+
* @protected
|
|
325
|
+
*/
|
|
326
|
+
async computeResponse (req) {
|
|
327
|
+
assert.fail('API not implemented')
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Call an API.
|
|
332
|
+
*
|
|
333
|
+
* Any headers to forward (that were sent to the request which is calling
|
|
334
|
+
* this function) will be added to the headers specified.
|
|
335
|
+
*
|
|
336
|
+
* @typedef {Object} callAPIOptions
|
|
337
|
+
* @param {String} method The HTTP method to use
|
|
338
|
+
* @param {Map<String, String>} headers HTTP headers to send
|
|
339
|
+
* @param {String} url the URL to request
|
|
340
|
+
* @param {String} body the body to send (if it's a string, it is sent as-is;
|
|
341
|
+
* otherwise, it is assumed to be JSON and encoded as such and the
|
|
342
|
+
* appropriate Content-Type header is set)
|
|
343
|
+
* @param {Map<String, String>} qsParams query string parameters
|
|
344
|
+
*
|
|
345
|
+
* @param {callAPIOptions} options describe the HTTP request to make
|
|
346
|
+
* @returns {Object|string} the HTTP response body, parsed if it was JSON
|
|
347
|
+
* @protected
|
|
348
|
+
*/
|
|
349
|
+
async callAPI ({
|
|
350
|
+
method = 'POST', headers = {}, url, body, qsParams, ignoreRespBody = false
|
|
351
|
+
}) {
|
|
352
|
+
headers = {
|
|
353
|
+
...headers,
|
|
354
|
+
...this.getHeadersToForward()
|
|
355
|
+
}
|
|
356
|
+
const request = {
|
|
357
|
+
headers,
|
|
358
|
+
method,
|
|
359
|
+
url,
|
|
360
|
+
qsParams
|
|
361
|
+
}
|
|
362
|
+
// istanbul ignore if
|
|
363
|
+
if (body) {
|
|
364
|
+
if (typeof body === 'string') {
|
|
365
|
+
request.body = body
|
|
366
|
+
} else {
|
|
367
|
+
request.json = body
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const resp = await fetchWrapper(request)
|
|
371
|
+
const ret = {
|
|
372
|
+
code: resp.status,
|
|
373
|
+
isOk: resp.status >= 200 && resp.status <= 299
|
|
374
|
+
}
|
|
375
|
+
if (!ignoreRespBody) {
|
|
376
|
+
const respBody = await resp.text()
|
|
377
|
+
if (respBody) {
|
|
378
|
+
if (resp.headers.get('content-type')?.startsWith('application/json')) {
|
|
379
|
+
try {
|
|
380
|
+
ret.data = JSON.parse(respBody)
|
|
381
|
+
} catch (e) {
|
|
382
|
+
// istanbul ignore next
|
|
383
|
+
throw new Error(`JSON.parse failed on ${respBody} with reason ${e}.`)
|
|
384
|
+
}
|
|
385
|
+
} else {
|
|
386
|
+
ret.data = respBody
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return ret
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Gets headers from this request that should be forwarded.
|
|
395
|
+
*
|
|
396
|
+
* By default, this is all headers defined in HEADERS.
|
|
397
|
+
*
|
|
398
|
+
* @protected
|
|
399
|
+
*/
|
|
400
|
+
getHeadersToForward () {
|
|
401
|
+
// forward permission headers, if present
|
|
402
|
+
const ret = {}
|
|
403
|
+
const headersToForward = this.constructor._HEADERS_TO_FORWARD
|
|
404
|
+
for (let i = 0; i < headersToForward.length; i++) {
|
|
405
|
+
const header = headersToForward[i]
|
|
406
|
+
const headerValue = this.req.headers[header]
|
|
407
|
+
if (headerValue !== undefined) {
|
|
408
|
+
ret[header] = headerValue
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return ret
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Adds inputs from the query string, path params and body to the Sentry
|
|
416
|
+
* context. This provides helpful debugging information but should only be
|
|
417
|
+
* used if these fields don't include sensitive information.
|
|
418
|
+
*/
|
|
419
|
+
__trackInputsWithSentry () {
|
|
420
|
+
const inputs = this.constructor.getInputsToTrackWithSentry(this.req)
|
|
421
|
+
// istanbul ignore else
|
|
422
|
+
if (inputs) {
|
|
423
|
+
const inputsSerialized = {}
|
|
424
|
+
for (const [k, v] of Object.entries(inputs)) {
|
|
425
|
+
if (typeof v === 'object') {
|
|
426
|
+
inputsSerialized[k] = JSON.stringify(v)
|
|
427
|
+
} else {
|
|
428
|
+
inputsSerialized[k] = v
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
this.setSentryContext('inputs', inputs)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Returns a map of inputs to track in Sentry's context.
|
|
437
|
+
*
|
|
438
|
+
* By default, includes the query string, path params and body. This provides
|
|
439
|
+
* helpful debugging information but should only be
|
|
440
|
+
* used if these fields don't include sensitive information.
|
|
441
|
+
*/
|
|
442
|
+
static getInputsToTrackWithSentry (req) {
|
|
443
|
+
return {
|
|
444
|
+
...req.query,
|
|
445
|
+
...req.params,
|
|
446
|
+
...req.body
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Sets context to be transmitted to Sentry if this request fails. */
|
|
451
|
+
setSentryContext (key, contextObject) {
|
|
452
|
+
this.req.__sentry.context[key] = contextObject
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Sets a tag to be transmitted to Sentry if this request fails. */
|
|
456
|
+
setSentryTag (tag, tagValue) {
|
|
457
|
+
this.req.__sentry.tags[tag] = tagValue
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Sets user info to be transmitted to Sentry if this request fails. */
|
|
461
|
+
setSentryUserInfo (userInfo) {
|
|
462
|
+
this.req.__sentry.userInfo = userInfo
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Redirects to a URL optionally with query string and cookie.
|
|
467
|
+
*
|
|
468
|
+
* Any headers to forward (that were sent to the request which is calling
|
|
469
|
+
* this function) will be added to the existing cookie's JSON data, if any.
|
|
470
|
+
*
|
|
471
|
+
* @param {String} [schemeAndHost] e.g., https://my-webapp.example.com
|
|
472
|
+
* @param {String} [path] the version of the web application to launch;
|
|
473
|
+
* can be overridden by query parameter "version" but otherwise defaults to
|
|
474
|
+
* the version of the service which served the web app
|
|
475
|
+
* @param {Object} [qsParams] the query string parameters to launch with
|
|
476
|
+
* @param {Object} [cookie] cookie data to send (good for sensitive values
|
|
477
|
+
* which should not be passed in the query string)
|
|
478
|
+
* @protected
|
|
479
|
+
*/
|
|
480
|
+
redirectToWebApp ({
|
|
481
|
+
schemeAndHost,
|
|
482
|
+
path = '/',
|
|
483
|
+
qsParams = undefined,
|
|
484
|
+
cookie
|
|
485
|
+
}) {
|
|
486
|
+
const qStr = qsParams
|
|
487
|
+
? '?' + querystring.stringify(qsParams)
|
|
488
|
+
: ''
|
|
489
|
+
|
|
490
|
+
const isLocalhost = process.env.NODE_ENV === 'localhost'
|
|
491
|
+
/* istanbul ignore else */
|
|
492
|
+
if (isLocalhost) {
|
|
493
|
+
schemeAndHost = 'http://localhost:3000'
|
|
494
|
+
}
|
|
495
|
+
if (cookie) {
|
|
496
|
+
const { values, domain, name } = cookie
|
|
497
|
+
// istanbul ignore next
|
|
498
|
+
const cookieDomain = isLocalhost ? '' : domain
|
|
499
|
+
// any headers which should be forwarded are added to the cookie (these
|
|
500
|
+
// overwrite existing values with those names, if any)
|
|
501
|
+
const newCookieData = JSON.stringify({
|
|
502
|
+
...(values ?? {}),
|
|
503
|
+
...this.getHeadersToForward()
|
|
504
|
+
})
|
|
505
|
+
this.__reply.setCookie(name, newCookieData, {
|
|
506
|
+
domain: cookieDomain,
|
|
507
|
+
maxAge: 604800, // one week
|
|
508
|
+
path: '/',
|
|
509
|
+
secure: !isLocalhost,
|
|
510
|
+
signed: true
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
this.__reply.redirect(schemeAndHost + path + qStr)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** @package */
|
|
517
|
+
static async register (registrar) {
|
|
518
|
+
await registrar.registerAPI(this)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Registers this API with the fastify app. This is called by internal
|
|
523
|
+
* implementation details in make-app.js. In rare occasions, subclasses
|
|
524
|
+
* may override this functionality to register the API with special
|
|
525
|
+
* middleware or other custom options.
|
|
526
|
+
*
|
|
527
|
+
* If the API allows CORS requests, then an OPTIONS API with the same path
|
|
528
|
+
* will also be registered to support browsers' CORS preflight requests.
|
|
529
|
+
*
|
|
530
|
+
* @param {*} app The fastify app to service this API from.
|
|
531
|
+
* @param {String} service The service this API belongs to.
|
|
532
|
+
* @package
|
|
533
|
+
*/
|
|
534
|
+
static registerAPI (registrar) {
|
|
535
|
+
const { app } = registrar
|
|
536
|
+
if (this.setup) {
|
|
537
|
+
app.register(this.setup)
|
|
538
|
+
}
|
|
539
|
+
const cls = this
|
|
540
|
+
app.register(async (fastify) => {
|
|
541
|
+
try {
|
|
542
|
+
await cls.registerAPIWithFastify(fastify, cls.PATH)
|
|
543
|
+
} catch (e) {
|
|
544
|
+
if (e instanceof assert.AssertionError) {
|
|
545
|
+
e.message = `${cls.name}: ${e.message}`
|
|
546
|
+
}
|
|
547
|
+
console.error(`failed to register API: ${cls.name}`)
|
|
548
|
+
throw e
|
|
549
|
+
}
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
if (this.CORS_ORIGIN) {
|
|
553
|
+
// create an OPTIONS method API to support CORS preflight requests from
|
|
554
|
+
// browsers
|
|
555
|
+
const path = cls.PATH
|
|
556
|
+
const { params, querystring } = cls.swaggerSchema
|
|
557
|
+
const schema = { hide: true, params, querystring }
|
|
558
|
+
app.options(path, { schema: pruneUndefined(schema) },
|
|
559
|
+
async (req, reply) => {
|
|
560
|
+
reply.header('Access-Control-Allow-Origin', this.getCORSOrigin())
|
|
561
|
+
if (this.CORS_HEADERS) {
|
|
562
|
+
reply.header('Access-Control-Allow-Headers',
|
|
563
|
+
this.CORS_HEADERS.join(', '))
|
|
564
|
+
}
|
|
565
|
+
await reply.send()
|
|
566
|
+
})
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Removes the required marker from any top-level properties which have a
|
|
572
|
+
* default value.
|
|
573
|
+
* @param {Schema} schema a Schema object
|
|
574
|
+
* @private
|
|
575
|
+
*/
|
|
576
|
+
static __makeParamsWithDefaultValuesOptional (schema) {
|
|
577
|
+
schema = schema.jsonSchema()
|
|
578
|
+
assert.ok(schema.type === 'object', 'param schemas must be an S.obj')
|
|
579
|
+
const requiredKeys = schema.required || []
|
|
580
|
+
const requiredKeysSet = new Set(requiredKeys)
|
|
581
|
+
assert.ok(requiredKeysSet.size === requiredKeys.length,
|
|
582
|
+
`${this.name} required has a dupe; is description() in the wrong place?`)
|
|
583
|
+
for (let i = requiredKeys.length - 1; i >= 0; i--) {
|
|
584
|
+
const requiredKey = requiredKeys[i]
|
|
585
|
+
const prop = schema.properties[requiredKey]
|
|
586
|
+
if (Object.hasOwnProperty.call(prop, 'default')) {
|
|
587
|
+
// a param with a default value is NOT required
|
|
588
|
+
requiredKeys.splice(i, 1)
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return schema
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Returns the headers schema.
|
|
596
|
+
* @private
|
|
597
|
+
*/
|
|
598
|
+
static _getHeaders () {
|
|
599
|
+
let headers = this.HEADERS
|
|
600
|
+
if (headers) {
|
|
601
|
+
headers = headers.isSchema
|
|
602
|
+
? headers
|
|
603
|
+
: S.obj(headers)
|
|
604
|
+
}
|
|
605
|
+
this._HEADERS_TO_FORWARD = (
|
|
606
|
+
headers
|
|
607
|
+
? Object.keys(headers.objectSchemas)
|
|
608
|
+
: [])
|
|
609
|
+
return headers
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Returns the body schema.
|
|
614
|
+
* @private
|
|
615
|
+
*/
|
|
616
|
+
static _getBody () {
|
|
617
|
+
return this.BODY
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Return a response wrapped in a subclass of RequestDone.
|
|
622
|
+
* @private
|
|
623
|
+
*/
|
|
624
|
+
static _getResponse () {
|
|
625
|
+
const response = this.RESPONSE
|
|
626
|
+
let ret
|
|
627
|
+
if (response === RESPONSES.UNVALIDATED) {
|
|
628
|
+
ret = undefined
|
|
629
|
+
} else if (response.prototype instanceof RequestDone) {
|
|
630
|
+
ret = response
|
|
631
|
+
} else {
|
|
632
|
+
ret = class extends RequestDone {
|
|
633
|
+
static STATUS = 200
|
|
634
|
+
static SCHEMA = response
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return ret
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Return all errors an API may return. Common base classes may use this
|
|
642
|
+
* method to add common errors, so users of the common base class can specify
|
|
643
|
+
* ERRORS without special considerations.
|
|
644
|
+
* @private
|
|
645
|
+
*/
|
|
646
|
+
static _getErrors () {
|
|
647
|
+
return {
|
|
648
|
+
InternalFailureException,
|
|
649
|
+
BadRequestException,
|
|
650
|
+
InvalidInputException,
|
|
651
|
+
...this.ERRORS
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Return a mapping from status code to Schemas.
|
|
657
|
+
* @private
|
|
658
|
+
*/
|
|
659
|
+
static _getResponseSchemas () {
|
|
660
|
+
const schemas = {}
|
|
661
|
+
|
|
662
|
+
const response = this._getResponse()
|
|
663
|
+
if (response) {
|
|
664
|
+
schemas[response.STATUS] = response.schema
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
for (const error of Object.values(this._getErrors())) {
|
|
668
|
+
schemas[error.STATUS] = error.respSchema
|
|
669
|
+
}
|
|
670
|
+
return schemas
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Return a default empty response matching the type specified in success
|
|
675
|
+
* response schema.
|
|
676
|
+
* @private
|
|
677
|
+
*/
|
|
678
|
+
static _getEmptySuccessResponse () {
|
|
679
|
+
const response = this._getResponse()
|
|
680
|
+
if (!response) {
|
|
681
|
+
return ''
|
|
682
|
+
}
|
|
683
|
+
const jsonSchema = response.schema.jsonSchema()
|
|
684
|
+
return {
|
|
685
|
+
string: '',
|
|
686
|
+
object: {},
|
|
687
|
+
array: []
|
|
688
|
+
}[jsonSchema.type]
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Handle an error thrown from API. Raise exceptions when errors that were
|
|
693
|
+
* not explicitly listed in ERRORS is thrown in test environment. Re-throw
|
|
694
|
+
* exceptions as 400 and 500 errors in production environment.
|
|
695
|
+
* @param {Error} err A subclass of Error.
|
|
696
|
+
* @param {Object} reply Reply object
|
|
697
|
+
* @private
|
|
698
|
+
*/
|
|
699
|
+
static async _handleError (err, reply) {
|
|
700
|
+
const errorName = err.constructor.name
|
|
701
|
+
const isUntrackedError = err instanceof RequestError &&
|
|
702
|
+
errorName !== RequestError.name &&
|
|
703
|
+
!this._getErrors()[errorName]
|
|
704
|
+
// istanbul ignore if
|
|
705
|
+
if (isUntrackedError) {
|
|
706
|
+
const errorMessage = `API ${this.name} emitted untracked error ` +
|
|
707
|
+
`${err.constructor.name}`
|
|
708
|
+
if (process.env.NODE_ENV !== 'prod') {
|
|
709
|
+
throw new Error(errorMessage)
|
|
710
|
+
}
|
|
711
|
+
console.error(errorMessage)
|
|
712
|
+
if (err.httpCode >= 500) {
|
|
713
|
+
throw new InternalFailureException(err.message, err.data)
|
|
714
|
+
} else if (err.httpCode >= 400) {
|
|
715
|
+
throw new BadRequestException(err.message, err.data)
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
if (err instanceof RedirectException) {
|
|
719
|
+
reply.code(err.httpCode).redirect(err.url)
|
|
720
|
+
} else {
|
|
721
|
+
throw err
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** @protected */
|
|
726
|
+
static get swaggerSecurityConfig () {
|
|
727
|
+
return []
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** @private */
|
|
731
|
+
static get swaggerSchema () {
|
|
732
|
+
const wrapInSchema = (x) => {
|
|
733
|
+
return x.isSchema ? x : S.obj(x)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// istanbul ignore next
|
|
737
|
+
const tags = [this.TAG || 'default']
|
|
738
|
+
const schema = {
|
|
739
|
+
summary: this.NAME,
|
|
740
|
+
description: this.getDescMarkdownString(),
|
|
741
|
+
tags,
|
|
742
|
+
response: {}
|
|
743
|
+
}
|
|
744
|
+
let headers = this._getHeaders()
|
|
745
|
+
if (headers) {
|
|
746
|
+
// Make sure extra header fields are passed along
|
|
747
|
+
headers = wrapInSchema(headers).jsonSchema()
|
|
748
|
+
headers.additionalProperties = true // Hack to enable additional props
|
|
749
|
+
schema.headers = headers
|
|
750
|
+
}
|
|
751
|
+
if (this.TAG === null) {
|
|
752
|
+
schema.hide = true
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const pathParams = this.PATH_PARAMS
|
|
756
|
+
if (pathParams) {
|
|
757
|
+
schema.params = this.__makeParamsWithDefaultValuesOptional(
|
|
758
|
+
wrapInSchema(pathParams))
|
|
759
|
+
}
|
|
760
|
+
const qs = this.QS
|
|
761
|
+
if (qs) {
|
|
762
|
+
schema.querystring = this.__makeParamsWithDefaultValuesOptional(
|
|
763
|
+
wrapInSchema(qs))
|
|
764
|
+
}
|
|
765
|
+
const body = this._getBody()
|
|
766
|
+
if (body) {
|
|
767
|
+
schema.body = this.__makeParamsWithDefaultValuesOptional(
|
|
768
|
+
wrapInSchema(body))
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const respSchemas = this._getResponseSchemas()
|
|
772
|
+
for (const statusCode in respSchemas) {
|
|
773
|
+
const schemaForStatusCode = respSchemas[statusCode]
|
|
774
|
+
const compiledSchema = schemaForStatusCode.getValidatorAndJSONSchema(
|
|
775
|
+
`${schema.summary} HTTP ${statusCode} Response`)
|
|
776
|
+
schema.response[statusCode] = compiledSchema.jsonSchema
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
schema.security = this.swaggerSecurityConfig
|
|
780
|
+
return pruneUndefined(schema)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Registers the API with fastify's router.
|
|
785
|
+
* @param {*} fastify The fastify library object from app.register().
|
|
786
|
+
* @private
|
|
787
|
+
*/
|
|
788
|
+
static async registerAPIWithFastify (fastify, fullPath) {
|
|
789
|
+
assert.ok(this.DESC, 'DESC is missing')
|
|
790
|
+
assert.ok(this.PATH && this.PATH.startsWith('/'),
|
|
791
|
+
'API path must start with a "/"')
|
|
792
|
+
|
|
793
|
+
// convert our proprietary schema format to the fast-json-stringify format
|
|
794
|
+
const respSchemas = this._getResponseSchemas()
|
|
795
|
+
const responseValidators = {}
|
|
796
|
+
for (const statusCode in respSchemas) {
|
|
797
|
+
const schemaForStatusCode = respSchemas[statusCode]
|
|
798
|
+
const compiledSchema = schemaForStatusCode.getValidatorAndJSONSchema(
|
|
799
|
+
`${this.NAME} HTTP ${statusCode} Response`)
|
|
800
|
+
responseValidators[statusCode] = compiledSchema.assertValid
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const method = this.METHOD.toLowerCase()
|
|
804
|
+
fastify[method](fullPath, {
|
|
805
|
+
schema: this.swaggerSchema,
|
|
806
|
+
attachValidation: true
|
|
807
|
+
},
|
|
808
|
+
async (req, reply) => {
|
|
809
|
+
let ret
|
|
810
|
+
try {
|
|
811
|
+
if (req.validationError) {
|
|
812
|
+
req.__sentry = { context: {} }
|
|
813
|
+
const errCopy = { ...req.validationError }
|
|
814
|
+
errCopy.validation = JSON.stringify(errCopy.validation, null, 4)
|
|
815
|
+
req.__sentry.context.validationError = errCopy
|
|
816
|
+
try {
|
|
817
|
+
req.__sentry.context.inputs = this.getInputsToTrackWithSentry(req)
|
|
818
|
+
} catch (err) {
|
|
819
|
+
// istanbul ignore next
|
|
820
|
+
console.warn('trying to get inputs to track failed', err)
|
|
821
|
+
}
|
|
822
|
+
if (this.CORS_ORIGIN) {
|
|
823
|
+
this.setCORSHeadersOnReply(
|
|
824
|
+
reply, this.getCORSOrigin(), this.CORS_HEADERS)
|
|
825
|
+
}
|
|
826
|
+
throw new InvalidInputException(req.validationError)
|
|
827
|
+
}
|
|
828
|
+
ret = await this._callAndHandleRequestDone(reply, async () => {
|
|
829
|
+
const handler = new this(fastify, req, reply)
|
|
830
|
+
return handler._computeResponse()
|
|
831
|
+
})
|
|
832
|
+
} catch (err) {
|
|
833
|
+
await this._handleError(err, reply)
|
|
834
|
+
}
|
|
835
|
+
if (!reply.sent) {
|
|
836
|
+
// convert an undefined return to an empty output (or fastify will hang
|
|
837
|
+
// and wait for output forever)
|
|
838
|
+
if (ret === undefined) {
|
|
839
|
+
ret = this._getEmptySuccessResponse()
|
|
840
|
+
}
|
|
841
|
+
// verify the output we received is consistent with the schema
|
|
842
|
+
const assertValidResponse = responseValidators[reply.statusCode]
|
|
843
|
+
if (assertValidResponse) {
|
|
844
|
+
assertValidResponse(ret)
|
|
845
|
+
}
|
|
846
|
+
return ret
|
|
847
|
+
}
|
|
848
|
+
})
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
export default API
|