@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,119 @@
1
+ // istanbul ignore file
2
+ import querystring from 'node:querystring'
3
+
4
+ import pino from 'pino'
5
+ import wrap from 'word-wrap'
6
+
7
+ // for unit testing, output to the console INSTEAD of to stdout
8
+ // via pino (so that jest captures the output and groups it with the right test
9
+ // suite)
10
+ function getUnitTestLogFormatOverrides () {
11
+ const prefixText = '/' + process.env.SERVICE + '/src/'
12
+ function prettifier () {
13
+ return (obj) => {
14
+ if (obj.req) {
15
+ const indent = ''
16
+ if (!obj.status) {
17
+ console.log(indent, obj.req.method, obj.req.path)
18
+ } else {
19
+ // output the status code and (if any) error message
20
+ const msgs = wrap(obj.msg || '', { width: 80, indent }).split('\n')
21
+ console.log(indent, ' \u2514 HTTP', obj.status, msgs[0])
22
+ msgs.slice(1).forEach(msg => {
23
+ console.log(indent, msg)
24
+ })
25
+ if (obj.error && obj.status >= 500) {
26
+ console.log(`${indent}Error: ${JSON.stringify(obj.error, null, 2)}`)
27
+ }
28
+ if (obj.stack) {
29
+ for (let i = 0; i < obj.stack.length; i++) {
30
+ console.log(' ', obj.stack[i])
31
+ }
32
+ }
33
+ }
34
+ } else {
35
+ // 1) Get the filename and line number the log message is from
36
+ // the index of the first non-pino line may change in future versions
37
+ // of fastify or pino; might be better to search each line until we
38
+ // find /<service>/src (if present)
39
+ const firstNonPinoLine = (new Error()).stack.split('\n')[5]
40
+ const appFolderIdx = firstNonPinoLine.indexOf(prefixText)
41
+ let prefix = ''
42
+ if (appFolderIdx !== -1) {
43
+ // prefix log message with filename and line number when logs
44
+ // originated in our app's source code
45
+ prefix = firstNonPinoLine.substring(
46
+ appFolderIdx + 9,
47
+ firstNonPinoLine.lastIndexOf(':')) + ' '
48
+ }
49
+
50
+ // 2) Create the logm msg: file, line, log message and log object
51
+ const indent = ' \u2502 '
52
+ const logObj = {}
53
+ const ignoredKeys = ['level', 'msg', 'reqId', 'time', 'v']
54
+ Object.getOwnPropertyNames(obj).forEach(key => {
55
+ if (ignoredKeys.indexOf(key) === -1) {
56
+ logObj[key] = obj[key]
57
+ }
58
+ })
59
+ let msg = indent + prefix + (obj.msg || '')
60
+ if (Object.keys(logObj).length) {
61
+ msg += (obj.msg ? ' ' : '') + JSON.stringify(logObj)
62
+ }
63
+ if (obj.level < 30) {
64
+ console.debug(msg)
65
+ } else if (obj.level >= 50) {
66
+ console.error(msg)
67
+ } else if (obj.level >= 40) {
68
+ console.warn(msg)
69
+ } else {
70
+ console.log(msg)
71
+ }
72
+ }
73
+ return '' // skip pino logging
74
+ }
75
+ }
76
+ return {
77
+ prettifier,
78
+ transport: {
79
+ target: 'pino-pretty',
80
+ options: {
81
+ colorize: true
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ export default function makeCustomLogger (useUnitTestLogFormat) {
88
+ function serializeReq (req) {
89
+ const q = req.query
90
+ if (req.raw) {
91
+ req = req.raw
92
+ }
93
+ const path = req.path
94
+ const qs = req.qs
95
+ return {
96
+ app: req.headers['x-app'] || '',
97
+ uid: req.headers['x-uid'] || '',
98
+ method: req.method,
99
+ ua: req.headers['user-agent'] || '',
100
+ path,
101
+ q: q || querystring.decode(qs)
102
+ }
103
+ }
104
+ const options = {
105
+ base: null, // omit pino default fields like pid and hostname
106
+ level: (process.env.NODE_ENV === 'prod') ? 'info' : 'debug',
107
+ serializers: {
108
+ req: serializeReq,
109
+ res: (res) => {
110
+ return { status: res.statusCode, req: serializeReq(res.req) }
111
+ }
112
+ }
113
+ }
114
+ // on localhost, we customize the logs to optimize for console-based debugging
115
+ if (useUnitTestLogFormat) {
116
+ Object.assign(options, getUnitTestLogFormatOverrides())
117
+ }
118
+ return pino(options)
119
+ }
@@ -0,0 +1,28 @@
1
+ // eslint babel made changes to chained expressions which is breaking ast-types
2
+ // https://github.com/babel/babel/issues/11908
3
+
4
+ // ast-types has a fix, but it is not yet in a versioned release
5
+ // https://github.com/benjamn/ast-types/pull/399
6
+
7
+ // to circumvent problems caused by importing zlib, we do it in a separate file
8
+ // so we can ignore it in linting
9
+ import zlib from 'node:zlib'
10
+
11
+ import { fastifyCompress } from '@fastify/compress'
12
+ import fp from 'fastify-plugin'
13
+
14
+ // TODO: fine tune configurations once we have a reliable usage pattern
15
+ export default fp(function (fastify, options, next) {
16
+ fastify.register(fastifyCompress, {
17
+ threshold: 20,
18
+ brotliOptions: {
19
+ params: {
20
+ [zlib.constants.BROTLI_PARAM_QUALITY]: 4
21
+ }
22
+ }
23
+ })
24
+ next()
25
+ }, {
26
+ fastify: '>=3.x',
27
+ name: 'compress'
28
+ })
@@ -0,0 +1,21 @@
1
+ import fp from 'fastify-plugin'
2
+
3
+ function addContentParser (fastify, options, next) {
4
+ fastify.addContentTypeParser('application/json',
5
+ { parseAs: 'string' },
6
+ function (req, body, done) {
7
+ try {
8
+ const json = JSON.parse(body || '{}')
9
+ done(null, json)
10
+ } catch (err) {
11
+ err.statusCode = 400
12
+ done(err, undefined)
13
+ }
14
+ })
15
+ next()
16
+ }
17
+
18
+ export default fp(addContentParser, {
19
+ fastify: '>=3.x',
20
+ name: 'content-parser'
21
+ })
@@ -0,0 +1,17 @@
1
+ import cookiePlugin from '@fastify/cookie'
2
+ import fp from 'fastify-plugin'
3
+
4
+ function addCookiePlugin (fastify, options, next) {
5
+ // istanbul ignore if
6
+ if (options.cookie.disabled) {
7
+ next()
8
+ return
9
+ }
10
+ fastify.register(cookiePlugin, { secret: options.cookie.secret })
11
+ next()
12
+ }
13
+
14
+ export default fp(addCookiePlugin, {
15
+ fastify: '>=3.x',
16
+ name: 'content-parser'
17
+ })
@@ -0,0 +1,140 @@
1
+ import S from '@pbvision/schema'
2
+ import * as Sentry from '@sentry/node'
3
+ import fp from 'fastify-plugin'
4
+
5
+ import { InvalidInputException } from '../api/exception.js'
6
+
7
+ export default fp(function (fastify, options, next) {
8
+ const isLocalhost = process.env.NODE_ENV === 'localhost'
9
+ const sentryDSN = options.errorHandler.sentryDSN
10
+ // istanbul ignore next
11
+ const isSentryEnabled = sentryDSN && !isLocalhost
12
+ Sentry.init({
13
+ dsn: sentryDSN,
14
+ enabled: isSentryEnabled,
15
+ environment: process.env.NODE_ENV,
16
+ release: process.env.GIT_HASH,
17
+ serverName: options.errorHandler.serverName
18
+ })
19
+
20
+ const returnErrorDetail = options.errorHandler.returnErrorDetail
21
+ // log any exception which occurs
22
+ fastify.setErrorHandler(async (error, req, reply) => {
23
+ // extract the relevant bit of the traceback: remove fastify lines
24
+ const traceback = error.stack.split('\n')
25
+ const errorMessage = error.message.split('\n')
26
+ traceback.splice(0, errorMessage.length)
27
+ let removeFromIdx
28
+ if (error instanceof InvalidInputException) {
29
+ removeFromIdx = 1
30
+ } else {
31
+ for (let i = traceback.length - 1; i > 0; i--) {
32
+ const tbLine = traceback[i]
33
+ if (tbLine.indexOf('/fastify/lib') !== -1) {
34
+ removeFromIdx = i + 1
35
+ break
36
+ }
37
+ }
38
+ }
39
+ removeFromIdx = removeFromIdx ?? traceback.length
40
+
41
+ traceback.splice(removeFromIdx, traceback.length - removeFromIdx)
42
+
43
+ const response = reply.raw
44
+ /* istanbul ignore next */
45
+ const message = error.message || 'empty error message'
46
+ const statusCode = error.httpCode ?? error.statusCode ?? 500
47
+ reply.code(statusCode)
48
+ const errInfo = {
49
+ msg: message,
50
+ req,
51
+ status: statusCode,
52
+ stack: traceback
53
+ }
54
+
55
+ // improve the error emitted from bad requests (invalid input)
56
+ const isCrash = errInfo.status >= 500
57
+ let customFingerprint = false
58
+ if (!isCrash) {
59
+ const firstTB = traceback[0]
60
+ /* istanbul ignore else */
61
+ if (firstTB) {
62
+ /* istanbul ignore else */
63
+ if (firstTB.indexOf('fastify/lib/contentTypeParser.js') !== -1) {
64
+ customFingerprint = 'Content-Type Not Permitted'
65
+ } else if (error instanceof S.ValidationError) {
66
+ customFingerprint = message
67
+ }
68
+ /* istanbul ignore next */
69
+ if (customFingerprint) {
70
+ if (customFingerprint.indexOf(errInfo.msg) === -1) {
71
+ // prefix the error message with the custom fingerprint text if the
72
+ // fingerprint didn't already contain all of the error message text
73
+ errInfo.msg = customFingerprint + ': ' + errInfo.msg
74
+ }
75
+ // changing the error name results in a cleaner description on the
76
+ // Sentry dashboard
77
+ error.name = customFingerprint
78
+ }
79
+ }
80
+ }
81
+
82
+ Object.getOwnPropertyNames(error).forEach(key => {
83
+ if (key !== 'stack' && key !== 'message') {
84
+ if (!errInfo.error) {
85
+ errInfo.error = {}
86
+ }
87
+ errInfo.error[key] = error[key]
88
+ }
89
+ })
90
+ response.logged = true // don't double-log
91
+ if (statusCode >= 500) {
92
+ reply.log.error(errInfo)
93
+ } else {
94
+ reply.log.info(errInfo)
95
+ }
96
+
97
+ Sentry.withScope(function (scope) {
98
+ if (customFingerprint) {
99
+ scope.setFingerprint(customFingerprint)
100
+ }
101
+ const user = {}
102
+ // istanbul ignore if
103
+ if (req.headers['x-uid']) {
104
+ user.id = req.headers['x-uid']
105
+ } else {
106
+ user.ip = req.ip
107
+ }
108
+ scope.setLevel(isCrash ? 'error' : 'warning')
109
+ scope.setUser(user)
110
+ scope.setTags({
111
+ method: req.method,
112
+ url: req.url,
113
+ status: errInfo.status
114
+ })
115
+ scope.setExtras({
116
+ msg: errInfo.message,
117
+ reqId: req.id,
118
+ userAgent: req.headers['user-agent'] ?? 'not set'
119
+ })
120
+ Sentry.captureException(error)
121
+ })
122
+
123
+ const errorData = error.respData ?? {
124
+ code: error.constructor.name,
125
+ message: customFingerprint || error.message
126
+ }
127
+
128
+ // istanbul ignore else
129
+ if (returnErrorDetail && !error.respData) {
130
+ errorData.detail = errInfo.msg
131
+ errorData.stack = errInfo.stack
132
+ }
133
+
134
+ await reply.header('Content-Type', 'application/json; charset=utf-8')
135
+ .serializer(o => JSON.stringify(o, null, 2))
136
+ .send(errorData)
137
+ })
138
+
139
+ next()
140
+ })
@@ -0,0 +1,19 @@
1
+ import fp from 'fastify-plugin'
2
+
3
+ export default fp(function (fastify, options, next) {
4
+ // istanbul ignore if
5
+ if (options.healthCheck.disabled) {
6
+ next()
7
+ return
8
+ }
9
+ // istanbul ignore next
10
+ const path = options.healthCheck.path ?? '/'
11
+ fastify.get(path, { schema: { hide: true } },
12
+ async (req, reply) => {
13
+ await reply.send()
14
+ })
15
+ next()
16
+ }, {
17
+ fastify: '>=3.x',
18
+ name: 'healthCheck'
19
+ })
@@ -0,0 +1,45 @@
1
+ import fp from 'fastify-plugin'
2
+ const symbolRequestTime = Symbol('RequestTimer')
3
+
4
+ export default fp(function (fastify, options, next) {
5
+ // istanbul ignore if
6
+ if (options.latencyTracker.disabled) {
7
+ next()
8
+ return
9
+ }
10
+
11
+ const header = options.latencyTracker.header
12
+ fastify.addHook('onRequest', function onRequestHandler (req, reply, done) {
13
+ // Start the recording of process time
14
+ req.raw[symbolRequestTime] = process.hrtime.bigint()
15
+
16
+ done()
17
+ })
18
+
19
+ fastify.addHook('onSend', async (req, reply, payload) => {
20
+ const fixedDigits = 3
21
+ // Calculate the duration, in nanoseconds
22
+ const hrDuration = process.hrtime.bigint() - req.raw[symbolRequestTime]
23
+ // convert it to milliseconds
24
+ const duration = (Number(hrDuration) / 1e6).toFixed(fixedDigits)
25
+ // add the header to the response
26
+ reply.header(header, duration)
27
+ return payload
28
+ })
29
+
30
+ fastify.addHook('onResponse', (req, reply, done) => {
31
+ // if errored, then it was already logged
32
+ if (!reply.raw.logged) {
33
+ req.log.info({
34
+ req,
35
+ status: reply.raw.statusCode,
36
+ latency: reply.getHeader(header)
37
+ })
38
+ }
39
+ done()
40
+ })
41
+ next()
42
+ }, {
43
+ fastify: '>=3.x',
44
+ name: 'latency-tracker'
45
+ })
@@ -0,0 +1,50 @@
1
+ // istanbul ignore file
2
+ import swagger from '@fastify/swagger'
3
+ import swaggerUI from '@fastify/swagger-ui'
4
+ import fp from 'fastify-plugin'
5
+
6
+ export default fp(function (fastify, options, next) {
7
+ if (options.swagger.disabled) {
8
+ next()
9
+ return
10
+ }
11
+
12
+ const authHeaders = options.swagger.authHeaders
13
+ fastify.register(swagger, {
14
+ openapi: {
15
+ servers: options.swagger.servers.map(x => { return { url: x } }),
16
+ info: {
17
+ title: `${options.swagger.service.toUpperCase()} Service`
18
+ },
19
+ consumes: ['application/json'],
20
+ produces: ['application/json'],
21
+ components: {
22
+ securitySchemes: authHeaders.reduce((all, c) => {
23
+ all[c.replace('x-', '')] = {
24
+ type: 'apiKey',
25
+ name: c,
26
+ in: 'header'
27
+ }
28
+ return all
29
+ }, {})
30
+ }
31
+ }
32
+ })
33
+ fastify.register(swaggerUI, {
34
+ routePrefix: options.swagger.routePrefix,
35
+ exposeRoute: true,
36
+ uiConfig: {
37
+ docExpansion: 'list',
38
+ deepLinking: false,
39
+ filter: true
40
+ }
41
+ })
42
+ // fastify.addHook('onReady', function (done) {
43
+ // fastify.swagger()
44
+ // done()
45
+ // })
46
+ next()
47
+ }, {
48
+ fastify: '>=3.x',
49
+ name: 'swagger'
50
+ })
@@ -0,0 +1,154 @@
1
+ import zlib from 'node:zlib'
2
+
3
+ import { jest } from '@jest/globals'
4
+ import { BaseTest, runTests } from '@pbvision/jest-unit-test'
5
+ import superagentDefaults from 'superagent-defaults'
6
+ import supertest from 'supertest'
7
+
8
+ import makeService from '../src/app.js'
9
+
10
+ let FASTIFY_CACHE
11
+
12
+ afterAll(async () => {
13
+ await FASTIFY_CACHE?.close()
14
+ FASTIFY_CACHE = undefined
15
+ })
16
+
17
+ class BaseAppTest extends BaseTest {
18
+ async beforeAll () {
19
+ this.fastify = FASTIFY_CACHE ?? await makeService()
20
+ FASTIFY_CACHE = this.fastify
21
+
22
+ await Promise.all([super.beforeAll(), this.fastify.ready()])
23
+
24
+ /**
25
+ * Avoid having to consider compression in unit tests
26
+ * by removing the `accept-encoding` header,
27
+ * which is added by default from SuperTest
28
+ */
29
+ const superTest = superagentDefaults(supertest(this.fastify.server))
30
+ superTest.set('accept-encoding', null)
31
+ this.app = new Proxy(superTest, {
32
+ get: (target, prop, receiver) => {
33
+ if (['get', 'post', 'put', 'delete'].includes(prop)) {
34
+ return (...requestParams) => {
35
+ const test = target[prop](...requestParams)
36
+ const originalExpect = test.expect
37
+ test.expect = async (...expectParams) => {
38
+ console.log(' \u2502 Expecting', expectParams[0])
39
+ return originalExpect.call(test, ...expectParams)
40
+ }
41
+ return test
42
+ }
43
+ } else {
44
+ return target[prop]
45
+ }
46
+ }
47
+ })
48
+ }
49
+ }
50
+
51
+ // the promise input conveniently matches the promise produced by supertest
52
+ // so you can pass the output of app.post(), etc. as the promise here as is
53
+ function makeGotMockValueFromPromise (promise) {
54
+ const mockValue = new Promise(resolve => {
55
+ promise.then(desiredHttpResponse => {
56
+ let body = desiredHttpResponse.text || desiredHttpResponse.body || ''
57
+ if (typeof body !== 'string') {
58
+ body = JSON.stringify(body)
59
+ }
60
+ mockValue.text = async () => body
61
+ resolve({ statusCode: desiredHttpResponse.status || 200 })
62
+ })
63
+ })
64
+ return mockValue
65
+ }
66
+
67
+ function makeGotMockValue (body, statusCode, callback) {
68
+ const mockValue = new Promise(resolve => {
69
+ // setTimeout is used so that this promise does not synchronously resolve
70
+ // because unmocked got will NEVER return synchronously. This ensures
71
+ // functions which call got() never resolve synchronously (which can
72
+ // change their behavior... e.g., allow them to throw when called,
73
+ // instead of only rejecting later when await'ed).
74
+ // https://github.com/facebook/jest/issues/6028 (since jest 21.x)
75
+ setTimeout(() => {
76
+ mockValue.text = async () => {
77
+ if (callback) {
78
+ callback()
79
+ }
80
+ if (typeof body === 'string') {
81
+ return body
82
+ }
83
+ return JSON.stringify(body)
84
+ }
85
+ resolve({ statusCode })
86
+ }, 0)
87
+ })
88
+ mockValue.text = async () => {
89
+ throw new Error('cannot use text() before calling await() on the response')
90
+ }
91
+ return mockValue
92
+ }
93
+
94
+ function mockGot () {
95
+ const gotMock = jest.fn().mockImplementation(({ body }) => {
96
+ expect(body).toEqual(zlib.brotliCompressSync('321'))
97
+ })
98
+
99
+ gotMock.mockResp = (body = '', statusCode = 200, callback) => {
100
+ gotMock.mockReturnValue(makeGotMockValue(body, statusCode, callback))
101
+ }
102
+
103
+ /**
104
+ * Determine the mock response to use when the request is made.
105
+ *
106
+ * @param {...function (request)} callbacks a list of callbacks in the order
107
+ * to check and see if they have a mock response to use; if no callback,
108
+ * provides a mock response then an error will be thrown
109
+ */
110
+ gotMock.mockRespWithCallback = (...callbacks) => {
111
+ gotMock.mockImplementation(request => {
112
+ for (const callback of callbacks) {
113
+ const desiredHTTPResponse = callback(request)
114
+ if (desiredHTTPResponse === true) {
115
+ const unmockedGot = jest.requireActual('../src/got')
116
+ return unmockedGot(request)
117
+ }
118
+ if (desiredHTTPResponse) {
119
+ if (desiredHTTPResponse.then) {
120
+ return makeGotMockValueFromPromise(desiredHTTPResponse)
121
+ }
122
+ return makeGotMockValue(
123
+ // follows the names from supertest
124
+ desiredHTTPResponse.text || desiredHTTPResponse.body || '',
125
+ desiredHTTPResponse.status || 200)
126
+ }
127
+ }
128
+ throw new Error(`un-mocked got() request: ${JSON.stringify(request)}`)
129
+ })
130
+ }
131
+
132
+ // will respond to the next N requests with the specified N responses
133
+ gotMock.mockRespMulti = (...responses) => {
134
+ let idx = 0
135
+ function setupNextResponse () {
136
+ if (idx < responses.length) {
137
+ gotMock.mockResp(...responses[idx], setupNextResponse)
138
+ } else if (idx > responses.length) {
139
+ // exactly equal means we just got our last callback (okay)
140
+ throw new Error('more requests made than we had mock responses')
141
+ }
142
+ idx += 1
143
+ }
144
+ setupNextResponse()
145
+ }
146
+ return gotMock
147
+ }
148
+
149
+ export {
150
+ BaseAppTest,
151
+ BaseTest,
152
+ mockGot,
153
+ runTests
154
+ }
@@ -0,0 +1,3 @@
1
+ import 'firestore-orm/environment.js'
2
+
3
+ process.env.NODE_ENV = 'localhost'