@vida-global/core 2.0.1 → 2.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/README.md +8 -7
- package/config/newrelic-config.js +2 -0
- package/lib/activeRecord/README.md +220 -111
- package/lib/http/README.md +84 -19
- package/lib/jobQueue/README.md +139 -46
- package/lib/logger/README.md +48 -5
- package/lib/server/README.md +282 -129
- package/lib/server/controllerImporter.js +1 -1
- package/lib/server/controllerMixins/callbacks.js +132 -0
- package/lib/server/controllerMixins/documentation.js +36 -0
- package/lib/server/controllerMixins/renderer.js +315 -0
- package/lib/server/controllerMixins/requestDetails.js +82 -0
- package/lib/server/controllerMixins/routing.js +108 -0
- package/lib/server/controllerMixins/validations.js +114 -0
- package/lib/server/server.js +2 -0
- package/lib/server/serverController.js +68 -672
- package/package.json +8 -5
- package/test/server/serverController.test.js +822 -22
- package/test/apm/agent.test.js +0 -56
- package/test/apm/utils.test.js +0 -121
package/README.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# Vida/Core
|
|
2
|
-
This package
|
|
2
|
+
This package contains core elements used across all Vida Apps. It is included in the `vida-apps-tools` package, so customers developing Vida Apps have access to it.
|
|
3
3
|
|
|
4
|
-
# Guides
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
- [
|
|
9
|
-
- [
|
|
10
|
-
- [
|
|
5
|
+
## Guides
|
|
6
|
+
|
|
7
|
+
- [ActiveRecord](lib/activeRecord) — relational model layer over Sequelize with configuration, migrations, querying, virtual fields, and optional Redis caching.
|
|
8
|
+
- [HttpClient](lib/http) — base class for building typed HTTP clients with shared headers, request/response handling, aborts, and error inspection.
|
|
9
|
+
- [Logger](lib/logger) — scoped, child-aware logger with environment-driven levels.
|
|
10
|
+
- [VidaServer](lib/server) — Express-based server with auto-loaded controllers, validation, rendering, callbacks, cookies, and SSE streaming.
|
|
11
|
+
- [JobQueue](lib/jobQueue) — BullMQ-backed queue with workers, retries/backoff, progress reporting, and queue inspection helpers.
|
|
@@ -86,6 +86,8 @@ exports.config = {
|
|
|
86
86
|
// the platform's log pipeline.
|
|
87
87
|
application_logging: { enabled: false },
|
|
88
88
|
|
|
89
|
+
logging: { enabled: false },
|
|
90
|
+
|
|
89
91
|
/**
|
|
90
92
|
* Ignore routine, non-business routes so they don't dominate transaction
|
|
91
93
|
* lists or skew apdex. Patterns are anchored regexes matched against the
|
|
@@ -1,118 +1,104 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# ActiveRecord
|
|
2
|
+
`ActiveRecord` is a thin wrapper around [Sequelize](https://sequelize.org/docs/v6/) that adds Vida conventions: snake_cased schema, automatic `id`/`created_at`/`updated_at` columns, optional Redis-backed record caching, virtual/override field accessors, and a migration CLI. Direct Sequelize features (associations, scopes, validators, hooks) continue to work as documented upstream.
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
Create `config/db/config.js` at the root of your project. The file maps **database IDs** (the `default` ID is used unless a model overrides `static get databaseId`) to per-environment connection details.
|
|
8
|
+
|
|
9
|
+
```js
|
|
4
10
|
module.exports = {
|
|
5
11
|
default: {
|
|
6
12
|
development: {
|
|
7
13
|
database: process.env.DATABASE,
|
|
8
|
-
host:
|
|
14
|
+
host: process.env.DB_HOST,
|
|
9
15
|
password: process.env.DB_PASSWORD,
|
|
10
|
-
username: process.env.DB_USERNAME
|
|
11
|
-
},
|
|
12
|
-
production: {
|
|
13
|
-
...
|
|
16
|
+
username: process.env.DB_USERNAME,
|
|
14
17
|
},
|
|
15
|
-
...
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
Add additional entries for multiple databases
|
|
21
|
-
```
|
|
22
|
-
module.exports = {
|
|
23
|
-
default: {
|
|
24
|
-
...
|
|
18
|
+
production: { /* ... */ },
|
|
25
19
|
},
|
|
20
|
+
|
|
21
|
+
// Additional databases get their own top-level key.
|
|
26
22
|
metrics: {
|
|
27
|
-
development: {
|
|
28
|
-
|
|
29
|
-
host: process.env.DB_HOST,
|
|
30
|
-
password: process.env.DB_PASSWORD,
|
|
31
|
-
username: process.env.DB_USERNAME
|
|
32
|
-
},
|
|
33
|
-
production: {
|
|
34
|
-
...
|
|
35
|
-
},
|
|
36
|
-
...
|
|
23
|
+
development: { /* ... */ },
|
|
24
|
+
production: { /* ... */ },
|
|
37
25
|
},
|
|
38
|
-
...
|
|
39
26
|
}
|
|
40
27
|
```
|
|
41
28
|
|
|
29
|
+
### Connection pooling
|
|
42
30
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
```
|
|
31
|
+
Min/max pool sizes default to `0` and `5`. Override per environment.
|
|
32
|
+
|
|
33
|
+
```js
|
|
46
34
|
development: {
|
|
47
35
|
database: process.env.DATABASE,
|
|
48
|
-
host:
|
|
36
|
+
host: process.env.DB_HOST,
|
|
49
37
|
password: process.env.DB_PASSWORD,
|
|
50
38
|
username: process.env.DB_USERNAME,
|
|
51
|
-
pool:
|
|
52
|
-
min: 1,
|
|
53
|
-
max: 10
|
|
54
|
-
}
|
|
39
|
+
pool: { min: 1, max: 10 },
|
|
55
40
|
}
|
|
56
41
|
```
|
|
57
42
|
|
|
43
|
+
### Read replicas
|
|
58
44
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
```
|
|
45
|
+
Add a `readers` array to route reads against replicas.
|
|
46
|
+
|
|
47
|
+
```js
|
|
62
48
|
development: {
|
|
63
49
|
database: process.env.DATABASE,
|
|
64
|
-
host:
|
|
50
|
+
host: process.env.DB_HOST,
|
|
65
51
|
password: process.env.DB_PASSWORD,
|
|
66
52
|
username: process.env.DB_USERNAME,
|
|
67
53
|
readers: [
|
|
68
|
-
{
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
password: ...
|
|
72
|
-
username: ...
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
...
|
|
76
|
-
}
|
|
77
|
-
}
|
|
54
|
+
{ database: '...', host: '...', password: '...', username: '...' },
|
|
55
|
+
{ /* ... */ },
|
|
56
|
+
],
|
|
78
57
|
}
|
|
79
58
|
```
|
|
80
59
|
|
|
81
60
|
|
|
82
|
-
|
|
83
|
-
To prepare your project to run database migrations, add the following lines to the `scripts` section of your `package.json`:
|
|
84
|
-
```
|
|
85
|
-
"db:create_migration": "node node_modules/@vida-global/core/scripts/activeRecord/migrate.js create_migration",
|
|
86
|
-
"db:migrate": "node node_modules/@vida-global/core/scripts/activeRecord/migrate.js migrate",
|
|
87
|
-
"db:rollback": "node node_modules/@vida-global/core/scripts/activeRecord/migrate.js rollback"
|
|
88
|
-
```
|
|
61
|
+
## Migrations
|
|
89
62
|
|
|
90
|
-
|
|
63
|
+
Wire up the migration CLI by adding scripts to `package.json`:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
"scripts": {
|
|
67
|
+
"db:create_migration": "node node_modules/@vida-global/core/scripts/activeRecord/migrate.js create_migration",
|
|
68
|
+
"db:migrate": "node node_modules/@vida-global/core/scripts/activeRecord/migrate.js migrate",
|
|
69
|
+
"db:rollback": "node node_modules/@vida-global/core/scripts/activeRecord/migrate.js rollback"
|
|
70
|
+
}
|
|
91
71
|
```
|
|
72
|
+
|
|
73
|
+
Generate a migration with `npm run db:create_migration createUsers`. Pass `-- --databaseId metrics` to scope the migration to a non-default database. Run `npm run db:migrate` to apply migrations or `npm run db:rollback` to undo the most recent one.
|
|
74
|
+
|
|
75
|
+
```js
|
|
92
76
|
module.exports = {
|
|
93
77
|
up: async function() {
|
|
94
78
|
await this.createTable('users', {
|
|
95
|
-
email:
|
|
96
|
-
team_id:
|
|
79
|
+
email: this.DataTypes.STRING,
|
|
80
|
+
team_id: this.DataTypes.INTEGER,
|
|
97
81
|
is_admin: {
|
|
98
|
-
type:
|
|
82
|
+
type: this.DataTypes.BOOLEAN,
|
|
99
83
|
defaultValue: false,
|
|
100
|
-
allowNull:
|
|
84
|
+
allowNull: false,
|
|
101
85
|
}
|
|
102
86
|
});
|
|
103
87
|
|
|
104
|
-
await this.addIndex('users', ['team_id'], {where: {team_id: {[this.Operators.ne]: null}}});
|
|
105
|
-
await this.addIndex('users', ['email'],
|
|
88
|
+
await this.addIndex('users', ['team_id'], { where: { team_id: { [this.Operators.ne]: null } } });
|
|
89
|
+
await this.addIndex('users', ['email'], { unique: true });
|
|
106
90
|
},
|
|
107
91
|
|
|
108
|
-
down: async function
|
|
92
|
+
down: async function() {
|
|
109
93
|
await this.dropTable('users');
|
|
110
94
|
}
|
|
111
95
|
}
|
|
112
96
|
```
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
97
|
+
|
|
98
|
+
Available migration helpers:
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
createTable(tableName, details, { timestamps })
|
|
116
102
|
dropTable(tableName)
|
|
117
103
|
addColumn(tableName, columnName, columnDetails)
|
|
118
104
|
removeColumn(tableName, columnName)
|
|
@@ -121,22 +107,26 @@ removeIndex(tableName, indexNameOrAttributes, concurrently=false)
|
|
|
121
107
|
renameColumn(tableName, oldName, newName)
|
|
122
108
|
changeColumn(tableName, columnName, dataTypeOrOptions)
|
|
123
109
|
```
|
|
124
|
-
To run your migrations, run `npm run db:migrate` or `npm run db:rollback` to rollback a single migration.
|
|
125
110
|
|
|
126
|
-
**
|
|
111
|
+
**Conventions:**
|
|
127
112
|
|
|
113
|
+
- `createTable` adds an auto-increment `id` primary key and `created_at` / `updated_at` columns by default. Pass `{ timestamps: false }` to skip the timestamps.
|
|
114
|
+
- Column names are automatically converted to snake_case, so `teamId` in the details object becomes `team_id` in the database.
|
|
115
|
+
- Each migration runs inside a transaction.
|
|
128
116
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
117
|
+
|
|
118
|
+
## Models
|
|
119
|
+
|
|
120
|
+
Subclass `BaseRecord` and call `initialize()`. The framework reads the table schema and configures the model.
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
const { ActiveRecord } = require('@vida-global/core');
|
|
133
124
|
|
|
134
125
|
class User extends ActiveRecord.BaseRecord {
|
|
135
126
|
}
|
|
136
|
-
|
|
137
127
|
User.initialize();
|
|
138
128
|
|
|
139
|
-
const user = new User({email: 'mark@vida.inc', team_id: 1, is_admin: false});
|
|
129
|
+
const user = new User({ email: 'mark@vida.inc', team_id: 1, is_admin: false });
|
|
140
130
|
await user.save();
|
|
141
131
|
|
|
142
132
|
user.is_admin = true;
|
|
@@ -145,61 +135,180 @@ await user.save();
|
|
|
145
135
|
await user.destroy();
|
|
146
136
|
```
|
|
147
137
|
|
|
138
|
+
### Querying
|
|
148
139
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
const users = await User.findAll{where: {team_id: 1}});
|
|
153
|
-
const users = await User.findAll{where: User.Operator
|
|
140
|
+
```js
|
|
141
|
+
const allUsers = await User.findAll();
|
|
142
|
+
const teamUsers = await User.findAll({ where: { team_id: 1 } });
|
|
154
143
|
|
|
155
|
-
const
|
|
156
|
-
team_id: {
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
}});
|
|
144
|
+
const eitherTeam = await User.findAll({ where: {
|
|
145
|
+
team_id: { [User.Operators.or]: [1, 2] }
|
|
146
|
+
} });
|
|
160
147
|
|
|
161
|
-
|
|
148
|
+
const { count, rows } = await User.findAndCountAll({
|
|
149
|
+
where: { email: { [User.Operators.like]: '%@vida.inc' } },
|
|
150
|
+
offset: 0,
|
|
151
|
+
limit: 10,
|
|
152
|
+
});
|
|
162
153
|
|
|
154
|
+
const totalCount = await User.count({ where: { team_id: 1 } });
|
|
163
155
|
|
|
164
|
-
|
|
156
|
+
// Convenience for findByPk / list-by-pk
|
|
157
|
+
const byId = await User.find(1);
|
|
158
|
+
const byIds = await User.find([1, 2, 3]);
|
|
165
159
|
```
|
|
160
|
+
|
|
161
|
+
More query examples: [Sequelize model querying basics](https://sequelize.org/docs/v6/core-concepts/model-querying-basics/).
|
|
162
|
+
|
|
163
|
+
### Aggregators and functions
|
|
164
|
+
|
|
165
|
+
Sequelize's `fn` and `col` helpers are exposed on the model class:
|
|
166
|
+
|
|
167
|
+
```js
|
|
166
168
|
const rows = await User.findAll({
|
|
167
169
|
attributes: ['team_id', [User.fn('MAX', User.col('id')), 'max_id']],
|
|
168
|
-
group:
|
|
170
|
+
group: 'team_id',
|
|
169
171
|
});
|
|
172
|
+
```
|
|
170
173
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
174
|
+
### API serialization
|
|
175
|
+
|
|
176
|
+
`record.toApiResponse()` returns a plain object containing every column with overridden getters applied. The server's response renderer calls this automatically when a record is included in an action's return value.
|
|
177
|
+
|
|
178
|
+
### Closing connections
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
await User.closeConnection(); // close the connection for this model's database
|
|
182
|
+
await User.closeAllConnections(); // close every connection (all databases, all models)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
## Custom field accessors
|
|
187
|
+
|
|
188
|
+
Define `_get${ColumnName}` / `_set${ColumnName}` on the class to override the read/write behavior of a column. The framework wires them up automatically when the model initializes.
|
|
189
|
+
|
|
190
|
+
```js
|
|
191
|
+
class User extends ActiveRecord.BaseRecord {
|
|
192
|
+
_getEmail() {
|
|
193
|
+
return this.getDataValue('email')?.toLowerCase();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
_setEmail(value) {
|
|
197
|
+
this.setDataValue('email', value?.trim());
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
User.initialize();
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
## Virtual fields
|
|
205
|
+
|
|
206
|
+
Add a derived attribute by defining `_virtualGet${PropertyName}` (and optionally `_virtualSet${PropertyName}`). The framework registers a Sequelize `VIRTUAL` column for it, so the property appears in `dataValues`, `toJSON()`, and `toApiResponse()`. Virtual accessors are inherited through the class hierarchy.
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
class User extends ActiveRecord.BaseRecord {
|
|
210
|
+
_virtualGetFullName() {
|
|
211
|
+
return `${this.first_name} ${this.last_name}`;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
User.initialize();
|
|
215
|
+
|
|
216
|
+
const user = await User.find(1);
|
|
217
|
+
user.fullName; // "Ada Lovelace"
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
## Caching
|
|
222
|
+
|
|
223
|
+
Opt a model in to Redis-backed caching by overriding `isCacheable`. With caching on:
|
|
224
|
+
|
|
225
|
+
- `Model.find(id)` and `Model.find([id1, id2])` consult Redis first and fall back to the database for misses, repopulating the cache for next time.
|
|
226
|
+
- `afterSave` and `afterDestroy` hooks automatically invalidate the cached record.
|
|
227
|
+
- Cache keys are namespaced as `AR:<ClassName>` (`<ClassName>#find:<id>`).
|
|
228
|
+
|
|
229
|
+
```js
|
|
230
|
+
class User extends ActiveRecord.BaseRecord {
|
|
231
|
+
static get isCacheable() { return true; }
|
|
232
|
+
}
|
|
233
|
+
User.initialize();
|
|
234
|
+
|
|
235
|
+
const user = await User.find(1); // cache miss; reads DB and caches
|
|
236
|
+
const same = await User.find(1); // cache hit
|
|
237
|
+
const fresh = await User.find(1, { clear: true }); // forces DB and refreshes the cache
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Use `cachedIds(key, where)` to cache the result of a list query keyed by an arbitrary string:
|
|
241
|
+
|
|
242
|
+
```js
|
|
243
|
+
const adminIds = await User.cachedIds('admins', { is_admin: true });
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
A record can be evicted manually:
|
|
247
|
+
|
|
248
|
+
```js
|
|
249
|
+
await user.clearSelfCache();
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Override `updateCache(options)` on the model to perform additional cache invalidation alongside the automatic single-record eviction.
|
|
253
|
+
|
|
254
|
+
This feature requires the Redis client wired up via `lib/redis`.
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
## Hooks
|
|
258
|
+
|
|
259
|
+
`BaseRecord` inherits Sequelize's full hook system. Register a hook on the model class with `addHook`:
|
|
260
|
+
|
|
261
|
+
```js
|
|
262
|
+
User.addHook('beforeCreate', async (user, options) => {
|
|
263
|
+
user.api_token = await generateToken();
|
|
179
264
|
});
|
|
180
265
|
|
|
181
|
-
|
|
266
|
+
User.addHook('afterSave', async (user, options) => {
|
|
267
|
+
await broadcast('users.changed', { id: user.id });
|
|
268
|
+
});
|
|
182
269
|
```
|
|
183
|
-
More examples can be found here, https://sequelize.org/docs/v6/core-concepts/model-querying-basics/.
|
|
184
270
|
|
|
185
|
-
|
|
271
|
+
See [Sequelize hooks](https://sequelize.org/docs/v6/other-topics/hooks/) for the full list of hook points. The caching layer above uses `afterSave` and `afterDestroy` under the hood when `isCacheable` is true.
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
## Associations
|
|
275
|
+
|
|
276
|
+
Define associations using Sequelize's standard API after the model is initialized:
|
|
277
|
+
|
|
278
|
+
```js
|
|
279
|
+
User.initialize();
|
|
280
|
+
Team.initialize();
|
|
186
281
|
|
|
282
|
+
User.belongsTo(Team, { foreignKey: 'team_id' });
|
|
283
|
+
Team.hasMany(User, { foreignKey: 'team_id' });
|
|
284
|
+
```
|
|
187
285
|
|
|
188
|
-
|
|
189
|
-
**TODO**
|
|
286
|
+
See [Sequelize associations](https://sequelize.org/docs/v6/core-concepts/assocs/) for the full reference.
|
|
190
287
|
|
|
191
288
|
|
|
192
|
-
|
|
193
|
-
**TODO**
|
|
289
|
+
## Validators
|
|
194
290
|
|
|
291
|
+
Sequelize's column-level validators continue to work — declare them on the model's column definition or via the `validate` option:
|
|
195
292
|
|
|
196
|
-
|
|
197
|
-
|
|
293
|
+
```js
|
|
294
|
+
class User extends ActiveRecord.BaseRecord {
|
|
295
|
+
static get initializationOptions() {
|
|
296
|
+
const opts = super.initializationOptions;
|
|
297
|
+
opts.options.validate = {
|
|
298
|
+
adminMustHaveTeam() {
|
|
299
|
+
if (this.is_admin && !this.team_id) {
|
|
300
|
+
throw new Error('admin users must belong to a team');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
return opts;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
```
|
|
198
308
|
|
|
309
|
+
See [Sequelize validators](https://sequelize.org/docs/v6/core-concepts/validations-and-constraints/).
|
|
199
310
|
|
|
200
|
-
### Paranoid
|
|
201
|
-
**TODO**
|
|
202
311
|
|
|
312
|
+
## TODO
|
|
203
313
|
|
|
204
|
-
|
|
205
|
-
When cleaning up your application, close all open connections with `User.closeAllConnections()`. This will close all connections for all models, not just the `User` model.
|
|
314
|
+
- **Paranoid (soft delete).** The infrastructure for a `deleted_at` column is wired in `initializationOptions`, but `paranoid: true` is not set, so soft deletes are not yet usable out of the box.
|
package/lib/http/README.md
CHANGED
|
@@ -1,42 +1,107 @@
|
|
|
1
1
|
# HttpClient
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
`HttpClient` is a generic client for making HTTP requests. It is best used as a base class for typed wrappers around a specific API: subclasses override `urlRoot` and `defaultHeaders` and expose one method per endpoint, which keeps third-party SDK details out of the rest of the codebase.
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
## Core Concepts
|
|
6
|
+
|
|
7
|
+
Every HTTP call is made through one of four verbs (`get`, `post`, `put`, `delete`) defined on `HttpClient`. Each verb takes an endpoint path and an options object; the client composes that with the subclass's `urlRoot` and `defaultHeaders`. Responses with a status under 300 resolve as `{ data, status }`. Anything else throws an `HttpError`; aborts throw an `HttpAbortError`.
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
Subclass `HttpClient` and expose endpoint methods.
|
|
13
|
+
|
|
14
|
+
```js
|
|
15
|
+
const { HttpClient } = require('@vida-global/core');
|
|
16
|
+
|
|
4
17
|
class MyApiClient extends HttpClient {
|
|
5
|
-
constructor({dev, token}) {
|
|
18
|
+
constructor({ dev, token }) {
|
|
19
|
+
super();
|
|
6
20
|
this.#token = token;
|
|
7
21
|
this.#dev = dev;
|
|
8
22
|
}
|
|
9
23
|
|
|
24
|
+
get urlRoot() {
|
|
25
|
+
return this.#dev ? 'https://staging.foo.com' : 'https://foo.com';
|
|
26
|
+
}
|
|
27
|
+
|
|
10
28
|
get defaultHeaders() {
|
|
11
29
|
const headers = super.defaultHeaders;
|
|
12
30
|
headers.Authorization = `Bearer ${this.#token}`;
|
|
13
31
|
return headers;
|
|
14
32
|
}
|
|
15
33
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return "https://staging.foo.com";
|
|
19
|
-
} else {
|
|
20
|
-
return "https://foo.com";
|
|
21
|
-
}
|
|
34
|
+
async getTickets({ page, pageSize }) {
|
|
35
|
+
return await this.get('/tickets', { requestParams: { page, pageSize } });
|
|
22
36
|
}
|
|
23
|
-
}
|
|
24
37
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
38
|
+
async createTicket(ticket) {
|
|
39
|
+
return await this.post('/tickets', { body: ticket });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
28
43
|
|
|
29
|
-
|
|
30
|
-
const
|
|
44
|
+
```js
|
|
45
|
+
const client = new MyApiClient({ dev: true, token: '123abctoken' });
|
|
46
|
+
const response = await client.getTickets({ page: 1, pageSize: 25 });
|
|
47
|
+
// response = { data: <parsed JSON>, status: 200 }
|
|
31
48
|
```
|
|
32
49
|
|
|
50
|
+
### Verbs and options
|
|
51
|
+
|
|
52
|
+
| Method | Options |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `get(endpoint, opts)` | `requestParams`, `headers`, `timeout`, `signal` |
|
|
55
|
+
| `post(endpoint, opts)` | `body`, `headers`, `timeout`, `signal` |
|
|
56
|
+
| `put(endpoint, opts)` | `body`, `headers`, `timeout`, `signal` |
|
|
57
|
+
| `delete(endpoint, opts)` | `requestParams`, `headers`, `timeout`, `signal` |
|
|
58
|
+
|
|
59
|
+
For `GET`/`DELETE`, `requestParams` becomes the query string. For `POST`/`PUT`, `body` becomes the request body (serialized as JSON by default).
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
### Query string encoding
|
|
63
|
+
|
|
64
|
+
For `GET`/`DELETE`, the way `requestParams` is appended to the URL depends on the `Content-Type` header:
|
|
65
|
+
|
|
66
|
+
- `application/json` (the default) — non-primitive values are `JSON.stringify`'d into a single query-string value.
|
|
67
|
+
- `application/x-www-form-urlencoded` — arrays expand to repeated keys (`?tag=a&tag=b`).
|
|
68
|
+
|
|
69
|
+
Override `defaultHeaders` (or pass a one-off `headers` option) to switch encoding strategies.
|
|
70
|
+
|
|
71
|
+
|
|
33
72
|
## Aborting requests
|
|
34
|
-
All request methods accept `timeout` (ms) and/or a caller-supplied `signal` (`AbortSignal`). When both are provided, they are composed via `AbortSignal.any` — whichever fires first aborts the request. If the request is aborted (by the timeout or the external signal), an `HttpAbortError` is thrown with `reason` set to either `'timed out'` or `'aborted'`.
|
|
35
73
|
|
|
36
|
-
|
|
37
|
-
|
|
74
|
+
All request methods accept `timeout` (ms) and/or a caller-supplied `signal` (`AbortSignal`). When both are provided they are composed via `AbortSignal.any` — whichever fires first aborts the request. If the request is aborted, an `HttpAbortError` is thrown with `reason` set to either `'timed out'` or `'aborted'`.
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
await client.post('/foo/bar', { body, timeout: 5000 });
|
|
38
78
|
|
|
39
79
|
const ctrl = new AbortController();
|
|
40
|
-
await client.get(
|
|
80
|
+
await client.get('/foo/bar', { signal: ctrl.signal });
|
|
81
|
+
ctrl.abort();
|
|
41
82
|
```
|
|
42
83
|
|
|
84
|
+
|
|
85
|
+
## Errors
|
|
86
|
+
|
|
87
|
+
Non-2xx responses throw `HttpError`. Inspect:
|
|
88
|
+
|
|
89
|
+
| Property | Description |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `err.status` | Numeric HTTP status. |
|
|
92
|
+
| `err.statusText` | HTTP reason phrase. |
|
|
93
|
+
| `err.url` | Full request URL. |
|
|
94
|
+
| `err.responseHeaders` | Cloned response headers. |
|
|
95
|
+
| `err.requestPayload` | Cloned outgoing payload (method, headers, body). |
|
|
96
|
+
| `await err.data()` | Parsed response body — JSON when possible, raw text otherwise. |
|
|
97
|
+
|
|
98
|
+
`HttpAbortError` exposes `err.reason` (`'timed out'` or `'aborted'`) and `err.requestPayload`.
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
## Advanced
|
|
102
|
+
|
|
103
|
+
Subclasses customize behavior through getters on the class:
|
|
104
|
+
|
|
105
|
+
- `urlRoot` — base URL prepended to every endpoint.
|
|
106
|
+
- `defaultHeaders` — merged into every request (per-call `headers` override).
|
|
107
|
+
- `logger` — scoped logger used for the `API CALL: ...` debug line emitted on each request (defaults to `logger.http`).
|