@pbvision/fastify-firestore-service 0.0.3

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