@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.
@@ -0,0 +1,109 @@
1
+ import db from '@pbvision/firestore-orm'
2
+
3
+ import API from './api.js'
4
+ import { __RequestDone } from './exception.js'
5
+
6
+ /**
7
+ * Thrown to avoid committing a transaction when an error occurs.
8
+ * @access private
9
+ */
10
+ export class RequestDoneAbortTransaction extends __RequestDone {
11
+ constructor (code = 200, data = '') {
12
+ super(undefined, data, code)
13
+ this.retryable = false
14
+ }
15
+ }
16
+
17
+ /**
18
+ * An API whose response is computed inside a transaction.
19
+ * @public
20
+ * @class
21
+ */
22
+ class DatabaseAPI extends API {
23
+ /**
24
+ * Whether this API can only read from Firestore. If false, the API's
25
+ * computeResponse() method will run in a Firestore transaction.
26
+ * @public
27
+ */
28
+ static IS_READ_ONLY = true
29
+
30
+ /**
31
+ * Options to pass the Firestore db.Context which wraps computeResponse().
32
+ * @public
33
+ */
34
+ static CONTEXT_OPTIONS = {}
35
+
36
+ async _computeResponse () {
37
+ await this.preTxStart()
38
+
39
+ let ret
40
+ try {
41
+ const opts = {
42
+ ...this.constructor.CONTEXT_OPTIONS,
43
+ readOnly: this.constructor.IS_READ_ONLY
44
+ }
45
+ ret = await db.Context.run(opts, async tx => {
46
+ this.tx = tx
47
+ this.req.tx = tx
48
+ let respData = await super._computeResponse()
49
+ if (this.__reply.statusCode < 400) {
50
+ // pre-commit hook may change the response data (and status code!)
51
+ respData = await this._callAndHandleRequestDone(
52
+ this.preCommit, respData)
53
+ }
54
+ // if the response code indicates an error, then don't commit
55
+ if (this.__reply.statusCode >= 400) {
56
+ throw new RequestDoneAbortTransaction(
57
+ this.__reply.statusCode, respData)
58
+ }
59
+ return respData
60
+ })
61
+ } catch (e) {
62
+ if (e instanceof RequestDoneAbortTransaction) {
63
+ return e.respData
64
+ } else {
65
+ throw e
66
+ }
67
+ } finally {
68
+ delete this.tx
69
+ delete this.req.tx
70
+ }
71
+
72
+ // _computeResponse() is called within _callAndHandleRequestDone; so we
73
+ // don't need to wrap this call to postCommit() in it (redundant)
74
+ return this.postCommit(ret)
75
+ }
76
+
77
+ /**
78
+ * Called just before the transaction starts. Useful to do async computation
79
+ * outside the transaction (reducing the window for contention).
80
+ * @protected
81
+ */
82
+ async preTxStart () {}
83
+
84
+ /**
85
+ * Called just before the transaction ATTEMPTS to commit. May alter the
86
+ * response by returning a new response value, or throwing RequestDone.
87
+ * Throwing an error will be propagated and handled by Transaction.run(),
88
+ * and will prevent the transaction from committing (unless the error is
89
+ * retryable).
90
+ *
91
+ * @param {*} respData the response data
92
+ * @returns {*} the (possibly updated) response data
93
+ * @protected
94
+ */
95
+ async preCommit (respData) { return respData }
96
+
97
+ /**
98
+ * Called after the transaction commits (and ONLY if it commits
99
+ * successfully). May alter the response by returning a new response value,
100
+ * or throwing RequestDone.
101
+ *
102
+ * @param {*} respData the response data
103
+ * @returns {*} the (possibly updated) response data
104
+ * @protected
105
+ */
106
+ async postCommit (respData) { return respData }
107
+ }
108
+
109
+ export default DatabaseAPI
@@ -0,0 +1,344 @@
1
+ import assert from 'node:assert'
2
+
3
+ import S from '@pbvision/schema'
4
+
5
+ import RESPONSES from './response.js'
6
+
7
+ /**
8
+ * @namespace Exceptions
9
+ * @public
10
+ */
11
+ /**
12
+ * Thrown to shortcut request handling.
13
+ *
14
+ * {@link API} will catch this exception and send the HTTP response code
15
+ * and (optional) data. This is useful to immediately stop request processing,
16
+ * especially from deeply nested locations in the call stack where it would
17
+ * be cumbersome to pass return information (especially errors) all the way
18
+ * back up the call stack.
19
+ *
20
+ * Typically, users should throw {@link RequestError} for error responses or
21
+ * @see RequestOkay for non-error responses (not @this).
22
+ *
23
+ * @arg {Number} httpCode The HTTP status code to respond with.
24
+ * @arg {String|Object} [respData=''] The object (to JSON.stringify()) or
25
+ * string to send in the HTTP response body.
26
+ * @package
27
+ * @memberof Exceptions
28
+ */
29
+ class __RequestDone extends Error {
30
+ static STATUS = undefined
31
+ static SCHEMA = RESPONSES.NO_OUTPUT
32
+
33
+ static get schemaValidator () {
34
+ const cacheKey = '_CACHED_SCHEMA_VALIDATOR'
35
+ if (!Object.prototype.hasOwnProperty.call(this, cacheKey)) {
36
+ this[cacheKey] = this.schema.compile(`data input to ${this.name}`)
37
+ }
38
+ return this[cacheKey]
39
+ }
40
+
41
+ /**
42
+ * Gets a Schema object.
43
+ */
44
+ static get schema () {
45
+ let schema = this.SCHEMA
46
+ if (!schema.isSchema) {
47
+ schema = S.obj(schema)
48
+ }
49
+ return schema
50
+ }
51
+
52
+ /**
53
+ * Create an exception.
54
+ * @param {String} message An error message
55
+ * @param {Object} data A JSON object containing additional data
56
+ * @param {Integer} code An optional code to override exception's default
57
+ * STATUS
58
+ */
59
+ constructor (message, data = undefined, code = undefined) {
60
+ super(message)
61
+ this.httpCode = code ?? this.constructor.STATUS
62
+ assert(this.httpCode !== undefined, 'Status must be defined')
63
+ if (this.constructor.SCHEMA !== undefined) {
64
+ this.constructor.schemaValidator?.(data)
65
+ }
66
+ this.data = data
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Concrete class to indicate request is completed ok.
72
+ * @public
73
+ * @memberof Exceptions
74
+ */
75
+ class RequestDone extends __RequestDone {
76
+ static STATUS = 200
77
+ static SCHEMA = S.obj() // optional
78
+
79
+ static get schemaValidator () {
80
+ const orig = super.schemaValidator
81
+ // istanbul ignore else
82
+ if (this.SCHEMA === RequestDone.SCHEMA) {
83
+ return data => {
84
+ if (data !== undefined) {
85
+ orig(data)
86
+ }
87
+ }
88
+ }
89
+ // istanbul ignore next
90
+ return orig
91
+ }
92
+
93
+ /**
94
+ * Return data to return to caller.
95
+ */
96
+ get respData () {
97
+ return this.data
98
+ }
99
+
100
+ /**
101
+ * Create a success response. Status must be smaller than 400.
102
+ * @param {Object} data Data to return to caller
103
+ * @param {Integer} code A status to override default status.
104
+ */
105
+ constructor (data = undefined, code = undefined) {
106
+ super(undefined, data, code)
107
+ assert(this.httpCode < 300, 'Status code must be less than 300')
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Thrown to shortcut request handling and return an HTTP success code (200).
113
+ *
114
+ * @arg {String|Object} [respData=''] The object (to JSON.stringify()) or
115
+ * string to send in the HTTP response body.
116
+ * @public
117
+ * @memberof Exceptions
118
+ * @see RequestDone
119
+ */
120
+ class RequestOkay extends RequestDone {
121
+ static STATUS = 200
122
+ // request okay response will be verified like a regular response
123
+ static SCHEMA = undefined
124
+ constructor (data = undefined) {
125
+ super(data, 200)
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Thrown to shortcut request handling and return an HTTP error.
131
+ *
132
+ * @arg {String} message The human-readable error message to send back in the
133
+ * JSON response data (in the "error" key).
134
+ * @arg {Object} [data={}] Optional additional JSON data to send back
135
+ * in the error response body.
136
+ * @arg {Number} [code=400] The HTTP status code to respond with; defaults
137
+ * to STATUS
138
+ * @public
139
+ * @memberof Exceptions
140
+ * @see RequestDone
141
+ */
142
+ class RequestError extends __RequestDone {
143
+ static SCHEMA = S.obj() // optional
144
+
145
+ static get schemaValidator () {
146
+ const orig = super.schemaValidator
147
+ // istanbul ignore else
148
+ if (this.SCHEMA === RequestError.SCHEMA) {
149
+ return data => {
150
+ if (data !== undefined) {
151
+ orig(data)
152
+ }
153
+ }
154
+ }
155
+ return orig
156
+ }
157
+
158
+ constructor (message, data = undefined, code = undefined) {
159
+ super(message, data, code)
160
+ assert(this.httpCode >= 300, 'Status code must be at least 300')
161
+ }
162
+
163
+ /**
164
+ * Schema to use on returned data
165
+ */
166
+ static get respSchema () {
167
+ return S.obj({
168
+ code: S.str,
169
+ message: S.str.optional(),
170
+ data: S.obj().optional() // Data is validated in constructor, don't validate again.
171
+ })
172
+ }
173
+
174
+ /**
175
+ * Data to return to the caller
176
+ */
177
+ get respData () {
178
+ return {
179
+ code: this.constructor.name,
180
+ message: this.message,
181
+ data: this.data
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Mark this error for rate-limited Sentry reporting. Useful when a known
187
+ * dependency outage causes the same error to fire repeatedly -- Cloud Tasks
188
+ * retries, etc. The HTTP response and logs are unaffected; only the Sentry
189
+ * report is throttled so the error budget doesn't get exhausted during the
190
+ * outage. Returns `this`, so it can be chained at the throw site:
191
+ *
192
+ * throw new RequestError('failed to send email', { err }, 550)
193
+ * .rateLimitSentry()
194
+ *
195
+ * Rate-limit keying follows the Sentry fingerprint we'd have used anyway
196
+ * (so Sentry grouping and our suppression agree on what "the same error" is).
197
+ *
198
+ * @param {Number} [windowMs=300000] rate-limit window in ms; at most one
199
+ * Sentry report is sent per window per key (default 5 minutes).
200
+ * @returns {RequestError} this
201
+ */
202
+ rateLimitSentry (windowMs = 5 * 60 * 1000) {
203
+ this._sentryRateLimitMs = windowMs
204
+ return this
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Thrown when a redirect is required.
210
+ * @public
211
+ * @memberof Exceptions
212
+ */
213
+ class RedirectException extends RequestError {
214
+ static STATUS = 302
215
+ static SCHEMA = S.str.max(0)
216
+
217
+ constructor (url, code) {
218
+ super('', '', code)
219
+ assert(this.httpCode < 400, 'Status code must be less than 400')
220
+ assert(typeof url === 'string' && url.length)
221
+ this.url = url
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Thrown when a error is induced by client.
227
+ * @public
228
+ * @memberof Exceptions
229
+ */
230
+ class BadRequestException extends RequestError {
231
+ static STATUS = 400
232
+ }
233
+
234
+ /**
235
+ * Wraps Request Validation failures into a RequestError object for processing.
236
+ * @public
237
+ * @memberof Exceptions
238
+ */
239
+ class InvalidInputException extends BadRequestException {
240
+ static STATUS = 400
241
+ static __ERROR_PREFIX = {
242
+ headers: 'Header Validation Failure',
243
+ body: 'Body Validation Failure',
244
+ querystring: 'Query Validation Failure',
245
+ params: 'Path Validation Failure'
246
+ }
247
+
248
+ constructor (schemaError) {
249
+ const prefixMap = InvalidInputException.__ERROR_PREFIX
250
+ const prefix = prefixMap[schemaError.validationContext] ??
251
+ 'Unknown Validation Failure'
252
+ super(`${prefix}: ${schemaError.message}`)
253
+ this.name = this.constructor.name
254
+ // istanbul ignore else
255
+ if (process.env.NODE_ENV !== 'prod' && schemaError.validation) {
256
+ for (const err of schemaError.validation) {
257
+ console.log(JSON.stringify(err))
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Thrown when a client is not authorized to access the requested resource. For
265
+ * example, user is trying to log in using invalid credentials.
266
+ * @public
267
+ * @memberof Exceptions
268
+ */
269
+ class UnauthorizedException extends RequestError {
270
+ static STATUS = 401
271
+
272
+ constructor (message = 'access denied') {
273
+ super(message)
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Thrown when an authenticated client does not have enough privilege to access
279
+ * the requested resource. For example, a user tries to change other user's
280
+ * data.
281
+ * @public
282
+ * @memberof Exceptions
283
+ */
284
+ class ForbiddenException extends RequestError {
285
+ static STATUS = 403
286
+
287
+ constructor (message = 'access denied') {
288
+ super(message)
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Thrown when the requested resource is not found, or should be hidden.
294
+ * @public
295
+ * @memberof Exceptions
296
+ */
297
+ class NotFoundException extends RequestError {
298
+ static STATUS = 404
299
+
300
+ constructor () {
301
+ super('Not found')
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Thrown when an error is induced by a server bug.
307
+ * @public
308
+ * @memberof Exceptions
309
+ */
310
+ class InternalFailureException extends RequestError {
311
+ static STATUS = 500
312
+ }
313
+
314
+ /**
315
+ * Thrown when server is temporarily unable to serve the request, due to
316
+ * internal timeouts or service outage.
317
+ * @public
318
+ * @memberof Exceptions
319
+ */
320
+ class ServiceUnavailableException extends RequestError {
321
+ static STATUS = 503
322
+ }
323
+
324
+ export {
325
+ // Base exceptions
326
+ __RequestDone,
327
+ RequestError,
328
+ RequestDone,
329
+
330
+ // Success
331
+ RequestOkay,
332
+
333
+ // Redirect
334
+ RedirectException,
335
+
336
+ // Error
337
+ BadRequestException,
338
+ InvalidInputException,
339
+ ForbiddenException,
340
+ InternalFailureException,
341
+ NotFoundException,
342
+ ServiceUnavailableException,
343
+ UnauthorizedException
344
+ }
@@ -0,0 +1,8 @@
1
+ import S from '@pbvision/schema'
2
+
3
+ const RESPONSES = {
4
+ NO_OUTPUT: S.str.max(0).lock(),
5
+ UNVALIDATED: undefined
6
+ }
7
+
8
+ export default RESPONSES
@@ -0,0 +1,27 @@
1
+ export default class ComponentRegistrar {
2
+ apis = []
3
+
4
+ constructor (app, service) {
5
+ this.app = app
6
+ this.service = service
7
+ }
8
+
9
+ async registerComponents (components) {
10
+ const promises = []
11
+ for (const component of Object.values(components)) {
12
+ if (component.register) {
13
+ promises.push(component.register(this))
14
+ }
15
+ }
16
+ await Promise.all(promises)
17
+ }
18
+
19
+ async registerAPI (api) {
20
+ this.apis.push(api)
21
+ await api.registerAPI(this)
22
+ }
23
+
24
+ async registerModel (model) {
25
+ // nothing to do here
26
+ }
27
+ }
@@ -0,0 +1,30 @@
1
+ import querystring from 'node:querystring'
2
+
3
+ import realFetch from 'node-fetch'
4
+
5
+ async function fetchWrapper (request, mockedFetch) {
6
+ const { compress = true, method = 'POST', url, qsParams, json } = request
7
+ let { body, headers } = request
8
+ headers = { ...headers } // copy the headers before we change them
9
+
10
+ if (json) {
11
+ headers['content-type'] = 'application/json'
12
+ body = JSON.stringify(request.json)
13
+ }
14
+ const fetch = (mockedFetch === false)
15
+ ? realFetch
16
+ : (mockedFetch ?? fetchWrapper.__mock ?? realFetch)
17
+
18
+ // compute the full URL including search params
19
+ let fullURL = url
20
+ if (qsParams) {
21
+ const qsStr = querystring.stringify(qsParams)
22
+ if (qsStr) {
23
+ fullURL += `?${qsStr}`
24
+ }
25
+ }
26
+ const options = { body, headers, method, compress }
27
+ return fetch(fullURL, options)
28
+ }
29
+
30
+ export default fetchWrapper
package/src/index.js ADDED
@@ -0,0 +1,13 @@
1
+ import API from './api/api.js'
2
+ import DatabaseAPI, { RequestDoneAbortTransaction } from './api/db-api.js'
3
+ import * as EXCEPTIONS from './api/exception.js'
4
+ import RESPONSES from './api/response.js'
5
+ import ComponentRegistrar from './component-registrar.js'
6
+ import makeService from './make-app.js'
7
+
8
+ export {
9
+ API, DatabaseAPI, EXCEPTIONS, RESPONSES,
10
+ makeService,
11
+ ComponentRegistrar,
12
+ RequestDoneAbortTransaction
13
+ }