@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/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