@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/LICENSE +204 -0
- package/README.md +187 -0
- package/docs/api.md +706 -0
- package/docs/build.sh +24 -0
- package/docs/jsdoc.config.json +22 -0
- package/package.json +93 -0
- package/src/api/api.js +852 -0
- package/src/api/db-api.js +109 -0
- package/src/api/exception.js +344 -0
- package/src/api/response.js +8 -0
- package/src/component-registrar.js +27 -0
- package/src/fetch-wrapper.js +30 -0
- package/src/index.js +13 -0
- package/src/make-app.js +223 -0
- package/src/make-logger.js +27 -0
- package/src/plugins/compress.js +28 -0
- package/src/plugins/content-parser.js +21 -0
- package/src/plugins/cookie.js +17 -0
- package/src/plugins/error-handler.js +180 -0
- package/src/plugins/health-check.js +19 -0
- package/src/plugins/latency-tracker.js +34 -0
- package/src/plugins/sentry-rate-limit.js +37 -0
- package/src/plugins/swagger.js +50 -0
- package/test/base-test.js +203 -0
- package/test/environment.js +3 -0
package/docs/api.md
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
# API Definition Library <!-- omit in toc -->
|
|
2
|
+
This library is used to define APIs.
|
|
3
|
+
|
|
4
|
+
## Topics
|
|
5
|
+
- [Core Concepts](#core-concepts)
|
|
6
|
+
- [Minimal API](#minimal-api)
|
|
7
|
+
- [API Input Data](#api-input-data)
|
|
8
|
+
- [API Output Data](#api-output-data)
|
|
9
|
+
- [Short-circuit Response Processing](#short-circuit-response-processing)
|
|
10
|
+
- [Returning Errors](#returning-errors)
|
|
11
|
+
- [Custom Errors](#custom-errors)
|
|
12
|
+
- [Dynamic Errors](#dynamic-errors)
|
|
13
|
+
- [Return Success Response](#return-success-response)
|
|
14
|
+
- [Custom Success Status](#custom-success-status)
|
|
15
|
+
- [Database Transactions](#database-transactions)
|
|
16
|
+
- [Pre and Post Commit Processing](#pre-and-post-commit-processing)
|
|
17
|
+
- [Gotcha: Shared State](#gotcha-shared-state)
|
|
18
|
+
- [Gotcha: Expensive Computation](#gotcha-expensive-computation)
|
|
19
|
+
- [API Path](#api-path)
|
|
20
|
+
- [Long API Descriptions](#long-api-descriptions)
|
|
21
|
+
- [Swagger Interactive Documentation](#swagger-interactive-documentation)
|
|
22
|
+
- [One-Time Setup](#one-time-setup)
|
|
23
|
+
- [Asynchronous Processing](#asynchronous-processing)
|
|
24
|
+
- [Cross Origin (CORS)](#cross-origin-cors)
|
|
25
|
+
- [Calling other APIs](#calling-other-apis)
|
|
26
|
+
- [Sentry Context](#sentry-context)
|
|
27
|
+
- [Niche Concepts](#niche-concepts)
|
|
28
|
+
- [Other API Input Data Options](#other-api-input-data-options)
|
|
29
|
+
- [Custom Middleware](#custom-middleware)
|
|
30
|
+
- [Gotcha: Sharing Schemas](#gotcha-sharing-schemas)
|
|
31
|
+
- [Localhost Cross-Origin Resource Sharing (CORS)](#localhost-cross-origin-resource-sharing-cors)
|
|
32
|
+
- [Appendix](#appendix)
|
|
33
|
+
|
|
34
|
+
# Core Concepts
|
|
35
|
+
|
|
36
|
+
## Minimal API
|
|
37
|
+
Define a new API by subclassing `UnauthenticatedAPI` and implementing at
|
|
38
|
+
least these required members:
|
|
39
|
+
|
|
40
|
+
* `PATH` - the HTTP path used to call the API (more on this
|
|
41
|
+
[later](#api-path)). Should be camel-case (no underscores).
|
|
42
|
+
* `DESC` - a human-readable description of what the API does
|
|
43
|
+
* `RESPONSE` - describes the shape of the output JSON (more on this later)
|
|
44
|
+
* `computeResponse()` - processes the request and returns the response
|
|
45
|
+
```javascript <!-- embed:../examples/docs.js:scope:WhatTimeIsItAPI -->
|
|
46
|
+
class WhatTimeIsItAPI extends API {
|
|
47
|
+
static PATH = '/whatTimeIsIt'
|
|
48
|
+
static DESC = 'Returns the current date string'
|
|
49
|
+
static RESPONSE = S.obj().prop('epoch', S.double)
|
|
50
|
+
async computeResponse () {
|
|
51
|
+
return { epoch: new Date().getTime() / 1000 }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
## API Input Data
|
|
58
|
+
APIs can receive input data from a JSON-formatted HTTP request body. API
|
|
59
|
+
input MUST ALWAYS be validated. To streamline this, API inputs must be
|
|
60
|
+
described using [Schema](https://github.com/pbv-public/js-schema) (`S`)
|
|
61
|
+
like this:
|
|
62
|
+
```javascript <!-- embed:../examples/docs.js:scope:AddNumbersAPI -->
|
|
63
|
+
class AddNumbersAPI extends API {
|
|
64
|
+
static PATH = '/add'
|
|
65
|
+
static DESC = 'returns the sum of a bunch of numbers'
|
|
66
|
+
// this API only takes numbers and arrays of numbers, but a schema
|
|
67
|
+
// can describe arbitrary JSON data including complex objects
|
|
68
|
+
static BODY = {
|
|
69
|
+
num1: S.double.desc('some number'),
|
|
70
|
+
num2: S.double.default(10)
|
|
71
|
+
.desc('another number; if omitted, a default value will be used'),
|
|
72
|
+
more: S.arr(S.double).optional()
|
|
73
|
+
.desc('optional array of more numbers to add')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
static RESPONSE = RESPONSES.UNVALIDATED
|
|
77
|
+
|
|
78
|
+
// Input data is validated prior to computeResponse() being called. If any
|
|
79
|
+
// input is invalid, then an HTTP 400 response is returned. On test servers,
|
|
80
|
+
// the response body will also include a description of the error.
|
|
81
|
+
async computeResponse () {
|
|
82
|
+
const numbers = (this.req.body.more || []).concat([
|
|
83
|
+
this.req.body.num1, this.req.body.num2])
|
|
84
|
+
let sum = 0
|
|
85
|
+
for (let i = 0; i < numbers.length; i++) {
|
|
86
|
+
sum += numbers[i]
|
|
87
|
+
}
|
|
88
|
+
return sum
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Notice that inputs can be configured to use a default value if none is provided
|
|
94
|
+
(see `num2`). Inputs can also be configured to just have their value be
|
|
95
|
+
undefined if omitted with the `optional()` marker (see `more`).
|
|
96
|
+
|
|
97
|
+
The `BODY`, `PATH_PARAMS`, `HEADERS`, and `QS` properties all support a mapping of field names to Schema objects. For advanced usages like specifying a default value or description, the mapping can be replaced with an Object schema `S.obj({})`. For example:
|
|
98
|
+
```javascript
|
|
99
|
+
static BODY = S.obj({ a: S.str })
|
|
100
|
+
```
|
|
101
|
+
is equivalent to
|
|
102
|
+
```javascript
|
|
103
|
+
static BODY = { a: S.str }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**Gotcha**: `desc()` should be called on the _type_ to describe a property.
|
|
107
|
+
```javascript
|
|
108
|
+
static BODY = S.obj({
|
|
109
|
+
x: S.double.desc('describes x'),
|
|
110
|
+
y: S.double
|
|
111
|
+
}).desc('describes BODY')
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## API Output Data
|
|
115
|
+
APIs can send output data in a JSON-formatted HTTP response body. To do
|
|
116
|
+
this, simply return an object from the `computeResponse()` method:
|
|
117
|
+
```javascript <!-- embed:../examples/docs.js:scope:class ReplyWithValidatedObjectAPI:when an object is returned -->
|
|
118
|
+
// when an object is returned, it is automatically converted to a JSON string
|
|
119
|
+
// and the HTTP response's Content-Type header is set to application/json
|
|
120
|
+
async computeResponse () {
|
|
121
|
+
return {
|
|
122
|
+
canHaveArbitraryJSONContent: true,
|
|
123
|
+
hello: 'world',
|
|
124
|
+
address: {
|
|
125
|
+
houseNumber: 123,
|
|
126
|
+
street: 'Bush St'
|
|
127
|
+
},
|
|
128
|
+
walkScore: 3.14
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
JSON response bodies must also be validated. The response schema documents the
|
|
134
|
+
expected output, as well as double-checks the correctness of output generated
|
|
135
|
+
by the API. Unexpected keys are considered as invalid. If you do not define an
|
|
136
|
+
output schema, then no output is permitted. Output validation can be performed
|
|
137
|
+
the same as input validation:
|
|
138
|
+
```javascript <!-- embed:../examples/docs.js:section:response example start:response example end -->
|
|
139
|
+
// The RESPONSE getter defines which properties should be present in the
|
|
140
|
+
// returned data. Only the defined properties are validated and sent. Any
|
|
141
|
+
// extra properties would result in a 500 response.
|
|
142
|
+
static RESPONSE = {
|
|
143
|
+
canHaveArbitraryJSONContent: S.bool,
|
|
144
|
+
hello: S.str,
|
|
145
|
+
address: S.obj({
|
|
146
|
+
apartmentNumber: S.int.optional(),
|
|
147
|
+
houseNumber: S.int,
|
|
148
|
+
street: S.str
|
|
149
|
+
}),
|
|
150
|
+
walkScore: S.double
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The response schema values can optionally be documented like this:
|
|
155
|
+
```javascript <!-- embed:../examples/docs.js:section:another resp example start:another resp example end -->
|
|
156
|
+
static RESPONSE = S.obj({
|
|
157
|
+
dragons: S.arr(S.str.desc('Dragon ID')).desc('Your dragons'),
|
|
158
|
+
guineaPigs: S.arr(S.str.desc('name').desc('Your dragons')),
|
|
159
|
+
optionalValue: S.int.desc('describe optional fields like this')
|
|
160
|
+
.optional()
|
|
161
|
+
}).desc('the mythic creatures you have collected')
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
If the API has no response body, then `RESPONSE` should be omitted.
|
|
165
|
+
|
|
166
|
+
## Short-circuit Response Processing
|
|
167
|
+
Complex APIs will have a `computeResponse()` which calls other functions. When
|
|
168
|
+
an error occurs deep in the call stack, it can be unwieldy and error-prone to
|
|
169
|
+
pass that error information back up the call stack simply to return that
|
|
170
|
+
information from `computeResponse()`. It can be useful to simply end request
|
|
171
|
+
processing at any point by throwing an exception which specifies the output.
|
|
172
|
+
|
|
173
|
+
```javascript <!-- embed:../examples/docs.js:scope:ThrowToReplyAPI -->
|
|
174
|
+
class ThrowToReplyAPI extends API {
|
|
175
|
+
static PATH = '/throwToReturn'
|
|
176
|
+
static DESC = 'returns some data by throwing'
|
|
177
|
+
static QS = { shouldError: S.bool }
|
|
178
|
+
static RESPONSE = RESPONSES.UNVALIDATED
|
|
179
|
+
|
|
180
|
+
async computeResponse () {
|
|
181
|
+
this.help1()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
help1 () { this.help2() }
|
|
185
|
+
|
|
186
|
+
help2 () {
|
|
187
|
+
// imagine we're deep in a complicated call stack but discover we know the
|
|
188
|
+
// response we need to send... instead of painstakingly passing it back up
|
|
189
|
+
// the call stack, we should use an exception to simply send the output
|
|
190
|
+
// from here
|
|
191
|
+
if (this.req.query.shouldError) {
|
|
192
|
+
throw new BadRequestException('run away!')
|
|
193
|
+
} else {
|
|
194
|
+
throw new RequestOkay({ hello: 'world' })
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
These may also be thrown from an API's constructor.
|
|
201
|
+
|
|
202
|
+
### Returning Errors
|
|
203
|
+
This library defines several exceptions classes, for example:
|
|
204
|
+
- InternalFailureException
|
|
205
|
+
- BadRequestException
|
|
206
|
+
- UnauthorizedException
|
|
207
|
+
- ...
|
|
208
|
+
|
|
209
|
+
These exceptions are exported in the `EXCEPTIONS` variable.
|
|
210
|
+
```javascript
|
|
211
|
+
import { EXCEPTIONS } from '@pbvision/fastify-firestore-service'
|
|
212
|
+
const { UnauthorizedException } = EXCEPTIONS
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
APIs must list the errors they may throw in `ERRORS` field.
|
|
216
|
+
```javascript <!-- embed:../examples/docs.js:scope:DupErrorCodeAPI -->
|
|
217
|
+
class DupErrorCodeAPI extends API {
|
|
218
|
+
static PATH = '/dupErrorCode'
|
|
219
|
+
static DESC = 'API with multiple error with the same status code.'
|
|
220
|
+
static BODY = {
|
|
221
|
+
exception: S.str
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
static ERRORS = {
|
|
225
|
+
NotFoundException,
|
|
226
|
+
DupNotFoundException
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async computeResponse (req) {
|
|
230
|
+
this.setSentryTag('test', 'me')
|
|
231
|
+
this.setSentryTag('another', 'test')
|
|
232
|
+
this.setSentryUserInfo({ email: 'x@example.com', id: '123' })
|
|
233
|
+
if (req.body.exception === 'notfound') {
|
|
234
|
+
throw new NotFoundException() // default error message "Not found"
|
|
235
|
+
} else {
|
|
236
|
+
throw new DupNotFoundException('custom message')
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Custom Errors
|
|
243
|
+
Custom errors should subclass `RequestError` base class, and provide `STATUS`
|
|
244
|
+
and `SCHEMA`.
|
|
245
|
+
```javascript
|
|
246
|
+
class SessionExpiredException extends RequestError {
|
|
247
|
+
static STATUS = 403
|
|
248
|
+
static SCHEMA = S.obj().max(0)
|
|
249
|
+
|
|
250
|
+
constructor (message = 'session expired', data = {}) {
|
|
251
|
+
super(message, data)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Dynamic Errors
|
|
257
|
+
APIs may need to re-throw an exception returned from an external API.
|
|
258
|
+
Dynamic error exceptions can be instantiated with
|
|
259
|
+
`new RequestError(message, data, code)`.
|
|
260
|
+
|
|
261
|
+
### Return Success Response
|
|
262
|
+
Similar to short circuiting return errors, exceptions can be thrown to
|
|
263
|
+
return success responses. It is useful to avoid a long stack of return
|
|
264
|
+
statements. Success responses should throw `new RequestOkay(data)`. Data must
|
|
265
|
+
match the `RESPONSE` schema.
|
|
266
|
+
|
|
267
|
+
### Custom Success Status
|
|
268
|
+
A non-standard 2xx status code can be used to indicate request success. The
|
|
269
|
+
`RESPONSE` field must be assigned with a subclass of `RequestDone` like this
|
|
270
|
+
```javascript <!-- embed:../examples/docs.js:scope:NonStandardResponse -->
|
|
271
|
+
class NonStandardResponse extends RequestDone {
|
|
272
|
+
static STATUS = 201
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Database Transactions
|
|
277
|
+
`DatabaseAPI` wraps your request in a database transaction context from the
|
|
278
|
+
[Firestore ORM library](https://github.com/pbv-public/firestore-orm):
|
|
279
|
+
```javascript
|
|
280
|
+
class SomeAPI extends DatabaseAPI {
|
|
281
|
+
static IS_READ_ONLY = false
|
|
282
|
+
async computeResponse () {
|
|
283
|
+
const someDoc = await this.tx.get(SomeModel, ...)
|
|
284
|
+
// changes are automatically saved when the request completes (assuming the
|
|
285
|
+
// transaction succeeds, i.e., it doesn't encounter excessive contention or
|
|
286
|
+
// some other problem)
|
|
287
|
+
someDoc.someField += 1
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
The per-request transaction _only_ attempts to commit if the response code is
|
|
293
|
+
less than 400 (e.g., HTTP 200 "OK" or HTTP 302 "Moved Temporarily"). Response
|
|
294
|
+
codes 400 and higher are considered errors, and the transaction will be aborted
|
|
295
|
+
(no changes will be saved).
|
|
296
|
+
|
|
297
|
+
By default, `DatabaseAPI` uses a _read-only_ transaction. Set `IS_READ_ONLY` to
|
|
298
|
+
`false` (like in the above example) to allow database writes.
|
|
299
|
+
|
|
300
|
+
Other options for the database context can be set in the `CONTEXT_OPTIONS`
|
|
301
|
+
object:
|
|
302
|
+
```javascript
|
|
303
|
+
static CONTEXT_OPTIONS = { retries: 3 }
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Note: If you leave the API read-only and add the `inconsistentReads: true` to
|
|
307
|
+
`CONTEXT_OPTIONS` then the database context will not be a transaction. See
|
|
308
|
+
Firestore ORM library's documentation for more detail.
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
### Pre and Post Commit Processing
|
|
312
|
+
You can perform extra processing just before the per-request transaction
|
|
313
|
+
_attempts_ to commit by overriding the `preCommit(respData)` function (not
|
|
314
|
+
called if the transaction is being aborted because the HTTP response code
|
|
315
|
+
indicates an error as discussed in the previous section):
|
|
316
|
+
```javascript
|
|
317
|
+
async preCommit (respData) {
|
|
318
|
+
// you can do more work within the original transaction if desired
|
|
319
|
+
assert(this.tx !== undefined) // tx is still available
|
|
320
|
+
|
|
321
|
+
const user = await tx.get(User, this.req.body.userID)
|
|
322
|
+
if (!user) {
|
|
323
|
+
// you can change the response code (and data)
|
|
324
|
+
throw new RequestError('unknown user')
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
// you can also just modify the response data
|
|
328
|
+
respData.userName = user.name
|
|
329
|
+
}
|
|
330
|
+
return respData
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Similarly, you can perform extra processing just after the per-request
|
|
335
|
+
transaction _successfully_ commits by overriding the `postCommit(respData)`
|
|
336
|
+
function (not called if the transaction fails to commit):
|
|
337
|
+
```javascript
|
|
338
|
+
async postCommit (respData) {
|
|
339
|
+
// you can do more work UNRELATED to the original transaction (this.tx is
|
|
340
|
+
// not even available here because it isn't valid here)
|
|
341
|
+
assert(this.tx === undefined) // tx has already ended!
|
|
342
|
+
|
|
343
|
+
// like preCommit(), you can modify response data (or throw RequestDone or
|
|
344
|
+
// any of its subclasses to change both the response code and data)
|
|
345
|
+
respData.postCommitMsg = 'commit succeeded'
|
|
346
|
+
return respData
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
```diff
|
|
350
|
+
--WARNING--
|
|
351
|
+
- In rare cases, the post-commit hook **MAY NOT RUN** after a transaction runs
|
|
352
|
+
- (e.g., the machine processing the request loses power after the database
|
|
353
|
+
- commits the transaction but before the post-commit hook runs). Take care to
|
|
354
|
+
- ONLY use the post-commit handler whose logic is okay to not run (on
|
|
355
|
+
- rare occasions).
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
### Gotcha: Shared State
|
|
360
|
+
Transactions sometimes retry due to contention, etc. It's important to not
|
|
361
|
+
store state on `this`, `req` or other heap variables while your transaction
|
|
362
|
+
runs, and then reference that data in a retry on accident.
|
|
363
|
+
```javascript <!-- embed:../examples/db.js:scope:RememberingTooMuchAPI -->
|
|
364
|
+
class RememberingTooMuchAPI extends DatabaseAPI {
|
|
365
|
+
static NAME = 'unwise memory use'
|
|
366
|
+
static DESC = 'shares state across tx attempts and requests'
|
|
367
|
+
static PATH = '/overshare'
|
|
368
|
+
static IS_READ_ONLY = false
|
|
369
|
+
static CONTEXT_OPTIONS = { retries: 3 }
|
|
370
|
+
static BODY = {
|
|
371
|
+
numTries: S.int.min(0)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
static RESPONSE = {
|
|
375
|
+
numTries: S.int.min(0),
|
|
376
|
+
numTriesOnThisMachine: S.int
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
static numTriesOnThisMachine = 0
|
|
380
|
+
constructor (fastify, req, reply) {
|
|
381
|
+
super(fastify, req, reply)
|
|
382
|
+
this.numTries = 0
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async computeResponse (req) {
|
|
386
|
+
// The API instance (this) is created ONCE for each request. It isn't
|
|
387
|
+
// recreated if the transaction retries. So any changes you make to
|
|
388
|
+
// `this` will persist and be visible across retries!
|
|
389
|
+
this.numTries += 1
|
|
390
|
+
|
|
391
|
+
// Updating a static variable like this will affect ALL requests being
|
|
392
|
+
// processed by the same machine! Module variables are stored in RAM and
|
|
393
|
+
// are never cleared. They're only in their initial state when a machine
|
|
394
|
+
// first starts. (This is the case regardless of whether you're using
|
|
395
|
+
// transactions).
|
|
396
|
+
this.constructor.numTriesOnThisMachine += 1
|
|
397
|
+
|
|
398
|
+
if (this.numTries < req.body.numTries) {
|
|
399
|
+
// force the tx to retry to demonstrate a point
|
|
400
|
+
const err = new Error()
|
|
401
|
+
err.retryable = true
|
|
402
|
+
throw err
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
numTries: this.numTries,
|
|
406
|
+
numTriesOnThisMachine: this.constructor.numTriesOnThisMachine
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
### Gotcha: Expensive Computation
|
|
414
|
+
It's best to do expensive computation _outside_ transactions in order to
|
|
415
|
+
minimize how long the transaction runs and thus minimize the opportunity for
|
|
416
|
+
contention. Of course, this is only possible if the computation is not
|
|
417
|
+
dependent on data from the database.
|
|
418
|
+
|
|
419
|
+
Your request _should_ do expensive computation unrelated to the database in its
|
|
420
|
+
constructor (`tx` is not defined at that point, and no transaction is running
|
|
421
|
+
and so transaction retries will not cause your constructor to re-run).
|
|
422
|
+
```javascript
|
|
423
|
+
class SomeAPI extends DatabaseAPI {
|
|
424
|
+
constructor (fastify, req, reply) {
|
|
425
|
+
super(fastify, req, reply)
|
|
426
|
+
this.doSomeExpensiveComputation()
|
|
427
|
+
this.computeCalls = 0
|
|
428
|
+
this.postComputeCalls = 0
|
|
429
|
+
|
|
430
|
+
// the transaction hasn't started yet (or even been set yet) so the
|
|
431
|
+
// expensive computation will never be re-run, even if the tx retries
|
|
432
|
+
assert(this.tx === undefined) // tx hasn't started yet!
|
|
433
|
+
}
|
|
434
|
+
// ...
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
You can also do expensive pre-computation in the `async preTxStart()` function.
|
|
439
|
+
This is more flexible than the constructor in that it is `async` and so you can
|
|
440
|
+
use `async/await` in it, unlike the constructor:
|
|
441
|
+
```javascript
|
|
442
|
+
// this runs after the constructor but before the tx starts
|
|
443
|
+
async preTxStart () {
|
|
444
|
+
assert(this.tx === undefined) // tx hasn't started yet!
|
|
445
|
+
// ...
|
|
446
|
+
}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
## API Path
|
|
450
|
+
An API's request path is of the form `/<Service Name><API PATH>`. For
|
|
451
|
+
example, a leaderboard service might have an API to get leaderboard entries
|
|
452
|
+
at the path `/leaderboard/entriesById`.
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
## Long API Descriptions
|
|
456
|
+
An API description may sometimes be too long to fit on a single line. Use
|
|
457
|
+
Node's multiline string for this (also, keep in mind that markdown can be used
|
|
458
|
+
in descriptions):
|
|
459
|
+
```javascript
|
|
460
|
+
static DESC = `
|
|
461
|
+
this will
|
|
462
|
+
get combined
|
|
463
|
+
into **one** string`
|
|
464
|
+
// The APIs description would be: "this will get combined into **one** string"
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
## Swagger Interactive Documentation
|
|
469
|
+
With the help of
|
|
470
|
+
[fastify-swagger](https://github.com/fastify/fastify-swagger), APIs are
|
|
471
|
+
automatically documented using Swagger UI. In addition to reviewing an APIs
|
|
472
|
+
specification, you can also _try_ the API from your browser (making API
|
|
473
|
+
requests and seeing their output right in your browser!). If your service
|
|
474
|
+
exposes many APIs, consider specifying a tag to group them in the generated
|
|
475
|
+
documentation:
|
|
476
|
+
```javascript
|
|
477
|
+
static TAG = 'Some Group Name'
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
If `TAG` isn't specified, it will be "default". If `TAG` is set to `null` then
|
|
481
|
+
the API will be omitted from the Swagger documentation.
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
## One-Time Setup
|
|
485
|
+
Some APIs may need to do some one-time initialization work. This sort of thing
|
|
486
|
+
can be done by defining a static `setup()` method on your API:
|
|
487
|
+
```javascript
|
|
488
|
+
static async setup (fastify) {
|
|
489
|
+
fastify.redis.defineCommand(/* add some custom local redis command... */)
|
|
490
|
+
}
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
## Asynchronous Processing
|
|
495
|
+
A service can accept _multiple_ requests for processing at once. However,
|
|
496
|
+
request processing is single-threaded (in a single process). JavaScript's
|
|
497
|
+
`async`/`await` syntax allows us to achieve lightweight concurrency;
|
|
498
|
+
understanding this concurrency model is important, but beyond the scope of this
|
|
499
|
+
documentation so check
|
|
500
|
+
[this async/await primer](https://medium.com/@garyo_83013/javascript-promises-and-async-await-for-c-programmers-aa349026f2e7)
|
|
501
|
+
to learn more.
|
|
502
|
+
|
|
503
|
+
This is a common setup because APIs tend to spend a lot of their time blocked
|
|
504
|
+
waiting on I/O, typically in the form of HTTP requests to other services (e.g.,
|
|
505
|
+
the database, cache or other API services). It is critical to never perform
|
|
506
|
+
synchronous I/O as this would stall the CPU (and every other request) until
|
|
507
|
+
the I/O completes. Use `async`/`await` to asynchronously block on I/O and
|
|
508
|
+
enable the server to continue productively using the CPU to process other requests.
|
|
509
|
+
Services with long-running CPU-bound requests may need to tweak (lower) request
|
|
510
|
+
processing concurrency to reduce queuing delays as the CPU works on one request
|
|
511
|
+
for an extended time, ignoring and starving other requests and possibly leading
|
|
512
|
+
to unacceptably high latency.
|
|
513
|
+
```javascript
|
|
514
|
+
// the "async" keyword on this method is actually very important!
|
|
515
|
+
async computeResponse() {
|
|
516
|
+
// whenever possible, asynchronous work should be done in PARALLEL (rather
|
|
517
|
+
// than serially); in this example we make two API calls at the same time
|
|
518
|
+
// and then yield until both API calls have returned
|
|
519
|
+
const results = await Promise.all([
|
|
520
|
+
this.callAPI({/* params omitted from example */}),
|
|
521
|
+
this.callAPI({/* params omitted again */})
|
|
522
|
+
])
|
|
523
|
+
// use the two results to compute our response...
|
|
524
|
+
}
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
## Cross Origin (CORS)
|
|
528
|
+
APIs which need be accessed from a web browser must indicate which hostnames
|
|
529
|
+
they may be accessed from. This is required for compatibility with browser
|
|
530
|
+
safety features. Without it, browsers will prevent the APIs from being
|
|
531
|
+
requested.
|
|
532
|
+
|
|
533
|
+
You can specify the CORS origin through the `CORS_ORIGIN` flag. For example,
|
|
534
|
+
```js
|
|
535
|
+
static CORS_ORIGIN = 'some.example.com'
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
You may also set the API to allow any origin like this:
|
|
539
|
+
```js
|
|
540
|
+
static CORS_ORIGIN = '*'
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
## Calling other APIs
|
|
544
|
+
* If you want to have one API call another, see the `callAPI()` helper method.
|
|
545
|
+
* If you want to have your API redirect to a web application and optionally
|
|
546
|
+
pass some information in a cookie, see the `redirectToWebApp()` helper method.
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
## Sentry Context
|
|
550
|
+
By default, query parameter, path parameter and body parameters are included as
|
|
551
|
+
context for reports to Sentry. To disable or customize this, override the `getInputsToTrackWithSentry()` method. You can also use the
|
|
552
|
+
`setSentryContext()`, `setSentryTag()` and `setSentryUserInfo()` methods to
|
|
553
|
+
add custom metadata for the request.
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# Niche Concepts
|
|
557
|
+
This section explains niche functionality.
|
|
558
|
+
|
|
559
|
+
## Other API Input Data Options
|
|
560
|
+
APIs are typically requested via the HTTP POST method. API-specific
|
|
561
|
+
inputs are typically sent in the request body in JSON format (though some
|
|
562
|
+
universal inputs are sent in HTTP headers, such as which app and user sent the
|
|
563
|
+
request). API outputs are typically transmitted as JSON data in the HTTP
|
|
564
|
+
response body.
|
|
565
|
+
|
|
566
|
+
Occasionally, it may be necessary for an API to use a different HTTP method, or
|
|
567
|
+
different input or output types or formats. One possibility is when integrating
|
|
568
|
+
with a third-party who requires this. However, APIs should generally be
|
|
569
|
+
consistent and stick to HTTP POST data with HTTP request and response bodies
|
|
570
|
+
being JSON formatted.
|
|
571
|
+
|
|
572
|
+
Here is an example API which differs from our convention in every way:
|
|
573
|
+
* It is requested via the HTTP PUT method
|
|
574
|
+
* It receives input from many different sources:
|
|
575
|
+
* query string
|
|
576
|
+
* request body (in a custom format, not JSON)
|
|
577
|
+
* headers
|
|
578
|
+
* request path
|
|
579
|
+
* Its response body is XML formatted
|
|
580
|
+
```javascript <!-- embed:../examples/docs.js:scope:NonStandardAddNumbersAPI -->
|
|
581
|
+
class NonStandardAddNumbersAPI extends API {
|
|
582
|
+
static METHOD = 'PUT'
|
|
583
|
+
static PATH = '/add/:num6/:num7'
|
|
584
|
+
static DESC = 'returns the sum of a bunch of numbers'
|
|
585
|
+
|
|
586
|
+
// we can send input as part of the query string (e.g., ?num=1&num2=...)
|
|
587
|
+
// sensitive data (such as a password hash) should not be sent in the query
|
|
588
|
+
// string as query strings are logged in request logs
|
|
589
|
+
static QS = {
|
|
590
|
+
num1: S.double.desc('some number'),
|
|
591
|
+
num2: S.double.desc('another number')
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// input can also be sent in the body (for POST and PUT requests); this is
|
|
595
|
+
// preferred for large of complex data types like JSON objects
|
|
596
|
+
static BODY = {
|
|
597
|
+
num3: S.double.desc('yet another number'),
|
|
598
|
+
num4: S.double.desc('a 4th number to add'),
|
|
599
|
+
more: S.arr(S.double).optional()
|
|
600
|
+
.desc('optional array of more numbers to add')
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// headers may also be used to communicate inputs, but are not typically used
|
|
604
|
+
// by APIs; they're primarily used for data sent on every request like user
|
|
605
|
+
// credentials
|
|
606
|
+
static HEADERS = {
|
|
607
|
+
num5: S.double.desc('a fifth number to add')
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// input data may also be communicated in the path itself
|
|
611
|
+
static PATH_PARAMS = {
|
|
612
|
+
num6: S.double,
|
|
613
|
+
num7: S.double
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
static RESPONSE = RESPONSES.UNVALIDATED
|
|
617
|
+
|
|
618
|
+
// Input data is validated prior to computeResponse() being called. If any
|
|
619
|
+
// input is invalid, then an HTTP 400 response is returned. On test servers,
|
|
620
|
+
// the response body will also include a description of the error.
|
|
621
|
+
async computeResponse () {
|
|
622
|
+
const numbers = (this.req.body.more || []).concat([
|
|
623
|
+
this.req.query.num1, this.req.query.num2,
|
|
624
|
+
this.req.body.num3, this.req.body.num4,
|
|
625
|
+
this.req.headers.num5,
|
|
626
|
+
this.req.params.num6, this.req.params.num7
|
|
627
|
+
])
|
|
628
|
+
let sum = 0
|
|
629
|
+
for (let i = 0; i < numbers.length; i++) {
|
|
630
|
+
sum += numbers[i]
|
|
631
|
+
}
|
|
632
|
+
// respond with an XML string instead of JSON
|
|
633
|
+
return `<sum>${sum}</sum>`
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// add a custom content-type parser that will be available to just this API
|
|
637
|
+
// (to handle the custom data format sent to this API)
|
|
638
|
+
static async registerAPIWithFastify (fastify, fullPath) {
|
|
639
|
+
function parser (req, body, done) {
|
|
640
|
+
if (!body.startsWith('wacky!')) {
|
|
641
|
+
done(new InternalFailureException('invalid wacky format'))
|
|
642
|
+
} else {
|
|
643
|
+
try {
|
|
644
|
+
const jsonStr = body.substring(6)
|
|
645
|
+
done(null, JSON.parse(jsonStr))
|
|
646
|
+
} catch (err) {
|
|
647
|
+
err.statusCode = 400
|
|
648
|
+
done(err, undefined)
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
fastify.addContentTypeParser(
|
|
653
|
+
'wackyCustom_thing', { parseAs: 'string' }, parser)
|
|
654
|
+
super.registerAPIWithFastify(fastify, fullPath)
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
## Custom Middleware
|
|
661
|
+
If desired, each API can be run with custom middleware and other fastify
|
|
662
|
+
options. To do this, you'll need to override the `register()` method and
|
|
663
|
+
call `app.register()` as desired.
|
|
664
|
+
|
|
665
|
+
## Gotcha: Sharing Schemas
|
|
666
|
+
In order for multiple APIs to share these parameters via inheritance:
|
|
667
|
+
```javascript
|
|
668
|
+
static get BODY () {
|
|
669
|
+
return S.obj({
|
|
670
|
+
leaderboard: S.str,
|
|
671
|
+
hasTiebreakers: S.bool.default(true).desc(
|
|
672
|
+
'must be set to true if the leaderboard uses tiebreakers')
|
|
673
|
+
})
|
|
674
|
+
}
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
Notice that `BODY` is defined as a getter function, not a static member
|
|
678
|
+
variable. The reason is that subclasses will call `super.BODY` to extend
|
|
679
|
+
the object. They each need their own copy of the schema (which the getter
|
|
680
|
+
creates and returns). If they both built from the same copy, it would cause
|
|
681
|
+
internal problems with the schema object.
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
## Localhost Cross-Origin Resource Sharing (CORS)
|
|
685
|
+
When running a service locally for testing purposes, it will enable CORS
|
|
686
|
+
requests for all APIs from `http://localhost:3000`. This facilitates testing of
|
|
687
|
+
web applications also running locally that depend on the service. In the cloud,
|
|
688
|
+
CORS is not enabled (unless you choose to enable it).
|
|
689
|
+
|
|
690
|
+
# Appendix
|
|
691
|
+
The samples in this readme can be found in the APIs defined for unit testing
|
|
692
|
+
this library in `../examples/docs.js`.
|
|
693
|
+
|
|
694
|
+
When necessary it is possible to use static getter functions instead of static
|
|
695
|
+
member variables. For example, here's a contrived example that only accepts "n"
|
|
696
|
+
as an input on localhost:
|
|
697
|
+
```javascript
|
|
698
|
+
// we use a getter function here so that we can more convenient define th
|
|
699
|
+
// body using more complex logic than usual
|
|
700
|
+
static get BODY () {
|
|
701
|
+
if (process.env.NODE_ENV === 'localhost') {
|
|
702
|
+
return S.obj({ n: S.int })
|
|
703
|
+
}
|
|
704
|
+
return S.obj()
|
|
705
|
+
}
|
|
706
|
+
```
|