@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.
@@ -0,0 +1,108 @@
1
+ import db from 'firestore-orm'
2
+
3
+ import API from './api.js'
4
+
5
+ /**
6
+ * Thrown to avoid committing a transaction when an error occurs.
7
+ * @access private
8
+ */
9
+ class TransactionAborted extends Error {
10
+ constructor (respData) {
11
+ super()
12
+ this.respData = respData
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 TransactionAborted(respData)
57
+ }
58
+ return respData
59
+ })
60
+ } catch (e) {
61
+ if (e instanceof TransactionAborted) {
62
+ return e.respData
63
+ } else {
64
+ throw e
65
+ }
66
+ } finally {
67
+ delete this.tx
68
+ delete this.req.tx
69
+ }
70
+
71
+ // _computeResponse() is called within _callAndHandleRequestDone; so we
72
+ // don't need to wrap this call to postCommit() in it (redundant)
73
+ return this.postCommit(ret)
74
+ }
75
+
76
+ /**
77
+ * Called just before the transaction starts. Useful to do async computation
78
+ * outside the transaction (reducing the window for contention).
79
+ * @protected
80
+ */
81
+ async preTxStart () {}
82
+
83
+ /**
84
+ * Called just before the transaction ATTEMPTS to commit. May alter the
85
+ * response by returning a new response value, or throwing RequestDone.
86
+ * Throwing an error will be propagated and handled by Transaction.run(),
87
+ * and will prevent the transaction from committing (unless the error is
88
+ * retryable).
89
+ *
90
+ * @param {*} respData the response data
91
+ * @returns {*} the (possibly updated) response data
92
+ * @protected
93
+ */
94
+ async preCommit (respData) { return respData }
95
+
96
+ /**
97
+ * Called after the transaction commits (and ONLY if it commits
98
+ * successfully). May alter the response by returning a new response value,
99
+ * or throwing RequestDone.
100
+ *
101
+ * @param {*} respData the response data
102
+ * @returns {*} the (possibly updated) response data
103
+ * @protected
104
+ */
105
+ async postCommit (respData) { return respData }
106
+ }
107
+
108
+ export default DatabaseAPI
@@ -0,0 +1,288 @@
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 Todea Schema object.
43
+ */
44
+ static get schema () {
45
+ let schema = this.SCHEMA
46
+ if (!schema.isTodeaSchema) {
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 = {}, code = undefined) {
60
+ super(message)
61
+ this.httpCode = code ?? this.constructor.STATUS
62
+ assert(this.httpCode !== undefined, 'Status must be defined')
63
+ assert(this.constructor.SCHEMA !== undefined, 'Schema must be defined')
64
+ this.constructor.schemaValidator(data)
65
+ this.data = data
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Concrete class to indicate request is completed ok.
71
+ * @public
72
+ * @memberof Exceptions
73
+ */
74
+ class RequestDone extends __RequestDone {
75
+ static STATUS = 200
76
+ static SCHEMA = S.obj().optional()
77
+
78
+ /**
79
+ * Return data to return to caller.
80
+ */
81
+ get respData () {
82
+ return this.data
83
+ }
84
+
85
+ /**
86
+ * Create a success response. Status must be smaller than 400.
87
+ * @param {Object} data Data to return to caller
88
+ * @param {Integer} code A status to override default status.
89
+ */
90
+ constructor (data, code = undefined) {
91
+ super(undefined, data, code)
92
+ assert(this.httpCode < 300, 'Status code must be less than 300')
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Thrown to shortcut request handling and return an HTTP success code (200).
98
+ *
99
+ * @arg {String|Object} [respData=''] The object (to JSON.stringify()) or
100
+ * string to send in the HTTP response body.
101
+ * @public
102
+ * @memberof Exceptions
103
+ * @see RequestDone
104
+ */
105
+ class RequestOkay extends RequestDone {
106
+ static STATUS = 200
107
+ }
108
+
109
+ /**
110
+ * Thrown to shortcut request handling and return an HTTP error.
111
+ *
112
+ * @arg {String} message The human-readable error message to send back in the
113
+ * JSON response data (in the "error" key).
114
+ * @arg {Object} [data={}] Optional additional JSON data to send back
115
+ * in the error response body.
116
+ * @arg {Number} [code=400] The HTTP status code to respond with; defaults
117
+ * to STATUS
118
+ * @public
119
+ * @memberof Exceptions
120
+ * @see RequestDone
121
+ */
122
+ class RequestError extends __RequestDone {
123
+ static SCHEMA = S.obj()
124
+
125
+ constructor (message, data = {}, code = undefined) {
126
+ super(message, data, code)
127
+ assert(this.httpCode >= 300, 'Status code must be at least 300')
128
+ }
129
+
130
+ /**
131
+ * Schema to use on returned data
132
+ */
133
+ static get respSchema () {
134
+ return S.obj({
135
+ code: S.str,
136
+ message: S.str.optional(),
137
+ data: S.obj() // Data is validated in constructor, don't validate again.
138
+ })
139
+ }
140
+
141
+ /**
142
+ * Data to return to the caller
143
+ */
144
+ get respData () {
145
+ return {
146
+ code: this.constructor.name,
147
+ message: this.message,
148
+ data: this.data
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Thrown when a redirect is required.
155
+ * @public
156
+ * @memberof Exceptions
157
+ */
158
+ class RedirectException extends RequestError {
159
+ static STATUS = 302
160
+ static SCHEMA = S.str.max(0)
161
+
162
+ constructor (url, code) {
163
+ super('', '', code)
164
+ assert(this.httpCode < 400, 'Status code must be less than 400')
165
+ assert(typeof url === 'string' && url.length)
166
+ this.url = url
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Thrown when a error is induced by client.
172
+ * @public
173
+ * @memberof Exceptions
174
+ */
175
+ class BadRequestException extends RequestError {
176
+ static STATUS = 400
177
+ }
178
+
179
+ /**
180
+ * Wraps Request Validation failures into a RequestError object for processing.
181
+ * @public
182
+ * @memberof Exceptions
183
+ */
184
+ class InvalidInputException extends BadRequestException {
185
+ static STATUS = 400
186
+ static __ERROR_PREFIX = {
187
+ headers: 'Header Validation Failure',
188
+ body: 'Body Validation Failure',
189
+ querystring: 'Query Validation Failure',
190
+ params: 'Path Validation Failure'
191
+ }
192
+
193
+ constructor (schemaError) {
194
+ const prefixMap = InvalidInputException.__ERROR_PREFIX
195
+ const prefix = prefixMap[schemaError.validationContext] ??
196
+ 'Unknown Validation Failure'
197
+ super(`${prefix}: ${schemaError.message}`)
198
+ this.name = this.constructor.name
199
+ // istanbul ignore else
200
+ if (process.env.NODE_ENV !== 'prod' && schemaError.validation) {
201
+ for (const err of schemaError.validation) {
202
+ console.log(JSON.stringify(err))
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Thrown when a client is not authorized to access the requested resource. For
210
+ * example, user is trying to log in using invalid credentials.
211
+ * @public
212
+ * @memberof Exceptions
213
+ */
214
+ class UnauthorizedException extends RequestError {
215
+ static STATUS = 401
216
+
217
+ constructor (message = 'access denied') {
218
+ super(message)
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Thrown when an authenticated client does not have enough privilege to access
224
+ * the requested resource. For example, a user tries to change other user's
225
+ * data.
226
+ * @public
227
+ * @memberof Exceptions
228
+ */
229
+ class ForbiddenException extends RequestError {
230
+ static STATUS = 403
231
+
232
+ constructor (message = 'access denied') {
233
+ super(message)
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Thrown when the requested resource is not found, or should be hidden.
239
+ * @public
240
+ * @memberof Exceptions
241
+ */
242
+ class NotFoundException extends RequestError {
243
+ static STATUS = 404
244
+
245
+ constructor () {
246
+ super('Not found')
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Thrown when an error is induced by a server bug.
252
+ * @public
253
+ * @memberof Exceptions
254
+ */
255
+ class InternalFailureException extends RequestError {
256
+ static STATUS = 500
257
+ }
258
+
259
+ /**
260
+ * Thrown when server is temporarily unable to serve the request, due to
261
+ * internal timeouts or service outage.
262
+ * @public
263
+ * @memberof Exceptions
264
+ */
265
+ class ServiceUnavailableException extends RequestError {
266
+ static STATUS = 503
267
+ }
268
+
269
+ export {
270
+ // Base exceptions
271
+ RequestError,
272
+ RequestDone,
273
+
274
+ // Success
275
+ RequestOkay,
276
+
277
+ // Redirect
278
+ RedirectException,
279
+
280
+ // Error
281
+ BadRequestException,
282
+ InvalidInputException,
283
+ ForbiddenException,
284
+ InternalFailureException,
285
+ NotFoundException,
286
+ ServiceUnavailableException,
287
+ UnauthorizedException
288
+ }
@@ -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,34 @@
1
+ import zlib from 'node:zlib'
2
+
3
+ import realGot from 'got'
4
+
5
+ function gotWrapper (options, mockedGot) {
6
+ options = {
7
+ decompress: true,
8
+ ...options
9
+ }
10
+ if (options.compress) {
11
+ // make a copy of the headers obj before we make changes to it
12
+ const headers = {
13
+ ...options.headers
14
+ }
15
+ options.headers = headers
16
+ if (options.body) {
17
+ options.body = zlib.brotliCompressSync(options.body)
18
+ }
19
+ if (options.json) {
20
+ headers['content-type'] = 'application/json'
21
+ options.body = zlib.brotliCompressSync(JSON.stringify(options.json))
22
+ delete options.json
23
+ }
24
+
25
+ if (options.body) {
26
+ headers['content-encoding'] = 'br'
27
+ }
28
+ }
29
+ // istanbul ignore next
30
+ const got = mockedGot ?? gotWrapper.__mocked_got ?? realGot
31
+ return got(options)
32
+ }
33
+
34
+ export default gotWrapper
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ import API from './api/api.js'
2
+ import DatabaseAPI 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
+ }
@@ -0,0 +1,179 @@
1
+ import fastify from 'fastify'
2
+ import { v4 as uuidv4 } from 'uuid'
3
+
4
+ import ComponentRegistrar from './component-registrar.js'
5
+ import makeLogger from './make-logger.js'
6
+ import compressPlugin from './plugins/compress.js'
7
+ import contentParserPlugin from './plugins/content-parser.js'
8
+ import cookiePlugin from './plugins/cookie.js'
9
+ import errorHandlerPlugin from './plugins/error-handler.js'
10
+ import healthCheckPlugin from './plugins/health-check.js'
11
+ import latencyTrackerPlugin from './plugins/latency-tracker.js'
12
+ import swaggerPlugin from './plugins/swagger.js'
13
+
14
+ /**
15
+ * @typedef {object} CookieConfig
16
+ * @property {boolean} [disabled=false] Adds fastify-cookie plugin
17
+ * @property {string} [secret] A secret to use to secure cookie
18
+ */
19
+ const COOKIE_CONFIG = {
20
+ disabled: false,
21
+ secret: undefined
22
+ }
23
+
24
+ /**
25
+ * @typedef {object} LoggingConfig
26
+ * @property {boolean} [useUnitTestLogFormat=false] Whether output logs to console with
27
+ * pretty printing
28
+ * @property {boolean} [reportAllErrors=false] Whether include all API
29
+ * validation errors in error logging. Recommend to keep it off for production,
30
+ * on for testing.
31
+ * @property {boolean} [reportErrorDetail=false] Whether include all details
32
+ * of an error. Recommend to keep it off for remote testing, on for local
33
+ * testing.
34
+ */
35
+ const LOGGING_CONFIG = {
36
+ useUnitTestLogFormat: false,
37
+ reportAllErrors: false,
38
+ reportErrorDetail: false,
39
+ sentryDSN: null
40
+ }
41
+
42
+ /**
43
+ * @typedef {object} HealthCheckConfig
44
+ * @property {boolean} [disabled=false] Whether to add a health check endpoint
45
+ * that simply returns 200.
46
+ * @property {string} [path='/'] The path to the health check endpoint.
47
+ */
48
+ const HEALTH_CHECK_CONFIG = {
49
+ disabled: false,
50
+ path: '/'
51
+ }
52
+
53
+ /**
54
+ * @typedef {object} SwaggerConfig
55
+ * @property {boolean} [disabled=false] Whether to disable
56
+ * @property {Array<string>} [servers=[]] The host endpoint (scheme + domain) to
57
+ * send requests to
58
+ * @property {Array<string>} [authHeaders=[]] Authentication headers
59
+ * @property {string} [routePrefix='/docs'] Authentication headers
60
+ */
61
+ const SWAGGER_CONFIG = {
62
+ disabled: false,
63
+ servers: [],
64
+ authHeaders: [],
65
+ routePrefix: '/docs'
66
+ }
67
+
68
+ /**
69
+ * @typedef {object} LatencyTrackerConfig
70
+ * @property {boolean} [disabled=false] Whether to add a health check endpoint
71
+ * that simply returns 200.
72
+ * @property {string} [path='/'] The path to the health check endpoint.
73
+ */
74
+ const LATENCY_TRACKER_CONFIG = {
75
+ disabled: false,
76
+ header: 'x-latency-ms'
77
+ }
78
+
79
+ const PARAMS_CONFIG = {
80
+ service: undefined,
81
+ components: undefined,
82
+ RegistrarCls: ComponentRegistrar,
83
+ cookie: {},
84
+ healthCheck: {},
85
+ latencyTracker: {},
86
+ logging: {},
87
+ swagger: {}
88
+ }
89
+
90
+ function loadConfigDefault (config, defaultConfig) {
91
+ for (const key of Object.keys(config)) {
92
+ if (!Object.prototype.hasOwnProperty.call(defaultConfig, key)) {
93
+ throw new Error(`Unknown config ${key}`)
94
+ }
95
+ }
96
+ if (!config.disabled) {
97
+ for (const [key, defaultValue] of Object.entries(defaultConfig)) {
98
+ if (defaultValue === undefined && !config[key]) {
99
+ throw new Error(`Missing required value for ${key}`)
100
+ }
101
+ }
102
+ }
103
+ Object.assign(config, { ...defaultConfig, ...config })
104
+ }
105
+
106
+ /**
107
+ * @param {Object} params
108
+ * @param {string} params.service Name of the service, for example, iam,
109
+ * user-id, leaderboard. This affects API's prefixes.
110
+ * @param {Array<API|Model|component>} params.components A list of
111
+ * components.
112
+ * @param {object} [params.RegistrarCls=ComponentRegistrar] A subclass of
113
+ * ComponentRegistrar
114
+ * @param {CookieConfig} [params.cookie] Configures fastify-cookie.
115
+ * @param {HealthCheckConfig} [params.healthCheck] Configures health check endpoint.
116
+ * @param {LatencyTrackerConfig} [params.latencyTracker]
117
+ * @param {LoggingConfig} [params.logging] Configures logging.
118
+ * @param {SwaggerConfig} [params.swagger] Configures swagger.
119
+ * @returns {Promise<server>} fastify app with configured plugins
120
+ */
121
+ export default async function makeService (params = {}) {
122
+ const configs = [
123
+ [() => params, PARAMS_CONFIG],
124
+ [() => params.cookie, COOKIE_CONFIG],
125
+ [() => params.healthCheck, HEALTH_CHECK_CONFIG],
126
+ [() => params.latencyTracker, LATENCY_TRACKER_CONFIG],
127
+ [() => params.logging, LOGGING_CONFIG],
128
+ [() => params.swagger, SWAGGER_CONFIG]
129
+ ]
130
+ for (const [getter, defaultConfig] of configs) {
131
+ loadConfigDefault(getter(), defaultConfig)
132
+ }
133
+ const {
134
+ service,
135
+ components,
136
+ RegistrarCls,
137
+ cookie,
138
+ healthCheck,
139
+ latencyTracker,
140
+ logging,
141
+ swagger
142
+ } = params
143
+ const fastifyServerId = uuidv4()
144
+ let requestCount = 0
145
+ const app = fastify({
146
+ ignoreTrailingSlash: true,
147
+ disableRequestLogging: true,
148
+ logger: makeLogger(logging.useUnitTestLogFormat),
149
+ genReqId: () => `${fastifyServerId}-${++requestCount}`,
150
+ ajv: {
151
+ customOptions: {
152
+ removeAdditional: false,
153
+ allErrors: logging.reportAllErrors,
154
+ useDefaults: true,
155
+ strictSchema: false,
156
+ strictRequired: true
157
+ }
158
+ }
159
+ })
160
+
161
+ const registrar = new RegistrarCls(app, service)
162
+
163
+ app.register(cookiePlugin, { cookie })
164
+ .register(compressPlugin)
165
+ .register(contentParserPlugin)
166
+ .register(latencyTrackerPlugin, { latencyTracker })
167
+ .register(errorHandlerPlugin, {
168
+ errorHandler: {
169
+ returnErrorDetail: logging.reportErrorDetail,
170
+ sentryDSN: logging.sentryDSN,
171
+ serverName: fastifyServerId
172
+ }
173
+ })
174
+ .register(healthCheckPlugin, { healthCheck })
175
+ .register(swaggerPlugin, { swagger: { service, ...swagger } })
176
+
177
+ await registrar.registerComponents(components)
178
+ return app
179
+ }