@terreno/api 0.18.0 → 0.20.0
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/CHANGELOG.md +25 -0
- package/dist/api.test.js +18 -8
- package/dist/auth.d.ts +5 -5
- package/dist/auth.js +123 -131
- package/dist/configuration.test.js +289 -10
- package/dist/configurationApp.d.ts +72 -5
- package/dist/configurationApp.js +168 -48
- package/dist/configurationPlugin.d.ts +64 -7
- package/dist/configurationPlugin.js +161 -39
- package/dist/configurationPlugin.test.js +238 -1
- package/dist/expressServer.test.js +0 -1
- package/dist/openApi.d.ts +6 -6
- package/dist/openApi.js +21 -21
- package/dist/populate.test.js +23 -0
- package/dist/realtime/queryMatcher.js +0 -6
- package/dist/realtime/queryStore.js +3 -11
- package/dist/realtime/realtime.test.js +41 -34
- package/dist/secretProviders.d.ts +79 -2
- package/dist/secretProviders.js +177 -9
- package/dist/secretProviders.test.d.ts +1 -0
- package/dist/secretProviders.test.js +391 -0
- package/package.json +1 -1
- package/src/actions.openApi.test.ts +1 -1
- package/src/actions.ts +0 -1
- package/src/api.test.ts +10 -2
- package/src/auth.ts +19 -19
- package/src/configuration.test.ts +171 -7
- package/src/configurationApp.ts +213 -30
- package/src/configurationPlugin.test.ts +174 -2
- package/src/configurationPlugin.ts +157 -28
- package/src/expressServer.test.ts +0 -1
- package/src/openApi.ts +21 -21
- package/src/populate.test.ts +25 -0
- package/src/realtime/queryMatcher.ts +0 -6
- package/src/realtime/queryStore.ts +1 -10
- package/src/realtime/realtime.test.ts +24 -24
- package/src/realtime/realtimeApp.ts +0 -1
- package/src/realtime/registry.ts +0 -1
- package/src/realtime/types.ts +0 -4
- package/src/secretProviders.test.ts +186 -0
- package/src/secretProviders.ts +145 -5
|
@@ -11,6 +11,12 @@ export interface SecretFieldMeta {
|
|
|
11
11
|
path: string;
|
|
12
12
|
secretProvider?: string;
|
|
13
13
|
secretName: string;
|
|
14
|
+
/**
|
|
15
|
+
* Optional secret version to pin resolution to. When omitted the provider
|
|
16
|
+
* resolves the latest version. Discovered from the `secretVersion` schema
|
|
17
|
+
* path option.
|
|
18
|
+
*/
|
|
19
|
+
version?: string;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
/**
|
|
@@ -18,7 +24,15 @@ export interface SecretFieldMeta {
|
|
|
18
24
|
*/
|
|
19
25
|
export interface SecretProvider {
|
|
20
26
|
name: string;
|
|
21
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a secret value by name. Returns `null` when the secret is not found.
|
|
29
|
+
*
|
|
30
|
+
* @param secretName - The secret identifier (short name or provider-specific path).
|
|
31
|
+
* @param version - Optional version to pin resolution to. Providers that do not
|
|
32
|
+
* support versioning (e.g. environment variables) ignore this parameter. When
|
|
33
|
+
* omitted, the latest version is resolved.
|
|
34
|
+
*/
|
|
35
|
+
getSecret(secretName: string, version?: string): Promise<string | null>;
|
|
22
36
|
}
|
|
23
37
|
|
|
24
38
|
/**
|
|
@@ -30,6 +44,18 @@ export interface ConfigurationPluginOptions {
|
|
|
30
44
|
* Typically set during app startup so the model can resolve secrets on demand.
|
|
31
45
|
*/
|
|
32
46
|
secretProvider?: SecretProvider;
|
|
47
|
+
/**
|
|
48
|
+
* When `true`, adds a `_singleton` sentinel field with a unique index to
|
|
49
|
+
* enforce the singleton constraint at the database level.
|
|
50
|
+
*
|
|
51
|
+
* Defaults to `false`. Leave this off when the consuming app already enforces
|
|
52
|
+
* a single non-deleted document via the pre-save guard (the default behavior)
|
|
53
|
+
* or via its own indexes/soft-delete plugin, to avoid double-enforcement and
|
|
54
|
+
* conflicting indexes.
|
|
55
|
+
*
|
|
56
|
+
* @defaultValue false
|
|
57
|
+
*/
|
|
58
|
+
enforceSingletonIndex?: boolean;
|
|
33
59
|
}
|
|
34
60
|
|
|
35
61
|
// ---------------------------------------------------------------------------
|
|
@@ -75,14 +101,26 @@ export interface ConfigurationStatics<T extends object> {
|
|
|
75
101
|
getConfig(): Promise<T & Document>;
|
|
76
102
|
/** Get a specific value by dot-notation key. */
|
|
77
103
|
getConfig<P extends Paths<T>>(key: P): Promise<PathValue<T, P>>;
|
|
78
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Update the singleton configuration document.
|
|
106
|
+
*
|
|
107
|
+
* The patch is flattened into MongoDB dotted paths and applied with
|
|
108
|
+
* `findOneAndUpdate({$set})`. This preserves sibling fields inside nested
|
|
109
|
+
* subdocuments when a partial nested patch is supplied, and tolerates legacy /
|
|
110
|
+
* out-of-schema fields already persisted on the document (unlike a full
|
|
111
|
+
* `doc.save()`, which throws under `strict: "throw"`).
|
|
112
|
+
*/
|
|
79
113
|
updateConfig(updates: DeepPartial<T>): Promise<T & Document>;
|
|
80
114
|
/** Get secret field metadata discovered from the schema. */
|
|
81
115
|
getSecretFields(): SecretFieldMeta[];
|
|
82
116
|
/**
|
|
83
117
|
* Resolve all secret field values from a provider.
|
|
84
118
|
* Uses the provider passed here, or falls back to the one configured in the plugin options.
|
|
85
|
-
* Returns
|
|
119
|
+
* Returns an **in-memory** map of path -> value for programmatic use (startup
|
|
120
|
+
* self-checks, request-time resolution).
|
|
121
|
+
*
|
|
122
|
+
* This method never persists resolved values. Secret material must never be
|
|
123
|
+
* written to the configuration document.
|
|
86
124
|
*/
|
|
87
125
|
resolveSecrets(provider?: SecretProvider): Promise<Map<string, string>>;
|
|
88
126
|
}
|
|
@@ -103,6 +141,47 @@ export interface ConfigurationStatics<T extends object> {
|
|
|
103
141
|
*/
|
|
104
142
|
export interface ConfigurationModel<T extends object> extends Model<T>, ConfigurationStatics<T> {}
|
|
105
143
|
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Helpers
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Flattens a nested patch into MongoDB-style dotted paths, recursing into plain
|
|
150
|
+
* objects only; arrays and other values are treated as leaves.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* flattenToDotPaths({a: {b: 1}}) // => [["a.b", 1]]
|
|
154
|
+
*/
|
|
155
|
+
export const flattenToDotPaths = (
|
|
156
|
+
obj: Record<string, unknown>,
|
|
157
|
+
prefix = ""
|
|
158
|
+
): Array<[string, unknown]> => {
|
|
159
|
+
const out: Array<[string, unknown]> = [];
|
|
160
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
161
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
162
|
+
const isPlainObject =
|
|
163
|
+
value !== null &&
|
|
164
|
+
typeof value === "object" &&
|
|
165
|
+
!Array.isArray(value) &&
|
|
166
|
+
Object.getPrototypeOf(value) === Object.prototype;
|
|
167
|
+
if (isPlainObject) {
|
|
168
|
+
out.push(...flattenToDotPaths(value as Record<string, unknown>, path));
|
|
169
|
+
} else {
|
|
170
|
+
out.push([path, value]);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Builds the filter used to locate the singleton document. When the schema is
|
|
178
|
+
* soft-delete aware (has a `deleted` path, e.g. via `isDeletedPlugin`), the
|
|
179
|
+
* singleton is "the one non-deleted document"; otherwise any document matches.
|
|
180
|
+
*/
|
|
181
|
+
const buildSingletonFilter = (schema: Schema): Record<string, unknown> => {
|
|
182
|
+
return schema.path("deleted") ? {deleted: false} : {};
|
|
183
|
+
};
|
|
184
|
+
|
|
106
185
|
// ---------------------------------------------------------------------------
|
|
107
186
|
// Plugin
|
|
108
187
|
// ---------------------------------------------------------------------------
|
|
@@ -111,13 +190,23 @@ export interface ConfigurationModel<T extends object> extends Model<T>, Configur
|
|
|
111
190
|
* Mongoose schema plugin that adds singleton configuration behavior.
|
|
112
191
|
*
|
|
113
192
|
* Adds:
|
|
114
|
-
* - Pre-save hook enforcing exactly one document
|
|
193
|
+
* - Pre-save hook enforcing exactly one non-deleted document (soft-delete aware
|
|
194
|
+
* when the schema has a `deleted` path, e.g. via `isDeletedPlugin`)
|
|
115
195
|
* - `getConfig()` static: fetches or creates the singleton (full doc or keyed value)
|
|
116
|
-
* - `updateConfig(updates)` static: patches the singleton
|
|
196
|
+
* - `updateConfig(updates)` static: patches the singleton via `findOneAndUpdate({$set})`
|
|
197
|
+
* with dotted paths (preserves sibling subdoc fields; tolerates legacy fields)
|
|
117
198
|
* - `getSecretFields()` static: returns metadata for fields with `secret: true`
|
|
118
|
-
* - `resolveSecrets(provider?)` static:
|
|
199
|
+
* - `resolveSecrets(provider?)` static: resolves secret values into an in-memory map,
|
|
200
|
+
* using the plugin provider by default (never persists values)
|
|
201
|
+
* - Hard-delete blockers (`deleteOne`/`deleteMany`/`findOneAndDelete`); soft deletes
|
|
202
|
+
* (setting `deleted: true`) are allowed
|
|
203
|
+
*
|
|
204
|
+
* Soft deletes are allowed and a soft-deleted document does not block creating a
|
|
205
|
+
* new singleton. The `_singleton` unique index is opt-in via
|
|
206
|
+
* `enforceSingletonIndex` (default off).
|
|
119
207
|
*
|
|
120
|
-
* Mark fields as secrets using schema path options
|
|
208
|
+
* Mark fields as secrets using schema path options. Pin a version with the
|
|
209
|
+
* optional `secretVersion` option:
|
|
121
210
|
* ```typescript
|
|
122
211
|
* const configSchema = new Schema({
|
|
123
212
|
* apiKey: {
|
|
@@ -125,6 +214,7 @@ export interface ConfigurationModel<T extends object> extends Model<T>, Configur
|
|
|
125
214
|
* description: "Third-party API key",
|
|
126
215
|
* secret: true,
|
|
127
216
|
* secretName: "my-api-key",
|
|
217
|
+
* secretVersion: "3", // optional — resolves "latest" when omitted
|
|
128
218
|
* },
|
|
129
219
|
* });
|
|
130
220
|
* configSchema.plugin(configurationPlugin, {secretProvider: new EnvSecretProvider()});
|
|
@@ -136,24 +226,31 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
|
|
|
136
226
|
// Apply findOneOrNone so the singleton lookup avoids bare Model.findOne (idempotent).
|
|
137
227
|
findOneOrNone(schema);
|
|
138
228
|
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
229
|
+
// Optionally add a sentinel field with a unique index to enforce the singleton
|
|
230
|
+
// at the database level. This is opt-in (default off) so it does not conflict
|
|
231
|
+
// with consumers that already enforce a single non-deleted document via the
|
|
232
|
+
// pre-save guard below or via their own soft-delete plugin/indexes.
|
|
233
|
+
if (pluginOptions.enforceSingletonIndex) {
|
|
234
|
+
schema.add({
|
|
235
|
+
_singleton: {
|
|
236
|
+
default: "config",
|
|
237
|
+
description: "Sentinel field enforcing singleton constraint",
|
|
238
|
+
immutable: true,
|
|
239
|
+
select: false,
|
|
240
|
+
type: String,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
schema.index({_singleton: 1}, {unique: true});
|
|
244
|
+
}
|
|
151
245
|
|
|
152
|
-
// Enforce singleton: only one document allowed (application-level
|
|
246
|
+
// Enforce singleton: only one non-deleted document allowed (application-level
|
|
247
|
+
// guard). Soft-delete-aware: a soft-deleted document does not block creating a
|
|
248
|
+
// new singleton.
|
|
153
249
|
schema.pre("save", async function () {
|
|
154
250
|
if (this.isNew) {
|
|
251
|
+
const filter = buildSingletonFilter(schema);
|
|
155
252
|
// Cheap existence check — no document needs to be returned.
|
|
156
|
-
const existing = await (this.constructor as Model<unknown>).exists(
|
|
253
|
+
const existing = await (this.constructor as Model<unknown>).exists(filter);
|
|
157
254
|
if (existing) {
|
|
158
255
|
throw new APIError({
|
|
159
256
|
status: 409,
|
|
@@ -183,9 +280,10 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
|
|
|
183
280
|
|
|
184
281
|
// Static: get the singleton configuration document or a value at a path (race-safe via upsert)
|
|
185
282
|
schema.statics.getConfig = async function (key?: string): Promise<unknown> {
|
|
283
|
+
const singletonFilter = buildSingletonFilter(this.schema);
|
|
186
284
|
const findSingleton = (): Promise<Document | null> =>
|
|
187
285
|
(this as unknown as FindOneOrNonePlugin<unknown>).findOneOrNone(
|
|
188
|
-
|
|
286
|
+
singletonFilter
|
|
189
287
|
) as Promise<Document | null>;
|
|
190
288
|
let config: Document | null = await findSingleton();
|
|
191
289
|
if (!config) {
|
|
@@ -222,14 +320,44 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
|
|
|
222
320
|
return value;
|
|
223
321
|
};
|
|
224
322
|
|
|
225
|
-
// Static: update the singleton configuration document
|
|
323
|
+
// Static: update the singleton configuration document via $set dotted paths.
|
|
324
|
+
// Flattening to dotted paths preserves sibling subdoc fields and tolerates
|
|
325
|
+
// legacy/out-of-schema fields already persisted on the document.
|
|
226
326
|
schema.statics.updateConfig = async function (
|
|
227
327
|
updates: Record<string, unknown>
|
|
228
328
|
): Promise<unknown> {
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
329
|
+
const singletonFilter = buildSingletonFilter(this.schema);
|
|
330
|
+
const setFields: Record<string, unknown> = {};
|
|
331
|
+
for (const [path, value] of flattenToDotPaths(updates)) {
|
|
332
|
+
setFields[path] = value;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Nothing to set — return the current singleton (creating it if missing).
|
|
336
|
+
if (Object.keys(setFields).length === 0) {
|
|
337
|
+
return (this as unknown as ConfigurationModel<Record<string, unknown>>).getConfig();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// runValidators keeps schema validation (enum/min/custom validators) on the
|
|
341
|
+
// patched paths, matching the prior doc.save() behavior. Legacy/out-of-schema
|
|
342
|
+
// fields already on the document are untouched (not in $set), so they are not
|
|
343
|
+
// re-validated.
|
|
344
|
+
const updated = await this.findOneAndUpdate(
|
|
345
|
+
singletonFilter,
|
|
346
|
+
{$set: setFields},
|
|
347
|
+
{new: true, runValidators: true}
|
|
348
|
+
);
|
|
349
|
+
if (updated) {
|
|
350
|
+
return updated;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// No singleton yet — create one (with subdocument defaults applied), then
|
|
354
|
+
// apply the patch.
|
|
355
|
+
await (this as unknown as ConfigurationModel<Record<string, unknown>>).getConfig();
|
|
356
|
+
return this.findOneAndUpdate(
|
|
357
|
+
singletonFilter,
|
|
358
|
+
{$set: setFields},
|
|
359
|
+
{new: true, runValidators: true}
|
|
360
|
+
).orFail();
|
|
233
361
|
};
|
|
234
362
|
|
|
235
363
|
// Static: discover secret fields from schema options
|
|
@@ -243,6 +371,7 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
|
|
|
243
371
|
path: prefix ? `${prefix}.${pathName}` : pathName,
|
|
244
372
|
secretName: (opts.secretName as string) ?? pathName,
|
|
245
373
|
secretProvider: opts.secretProvider as string | undefined,
|
|
374
|
+
version: opts.secretVersion as string | undefined,
|
|
246
375
|
});
|
|
247
376
|
}
|
|
248
377
|
// Recurse into subschemas
|
|
@@ -275,7 +404,7 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
|
|
|
275
404
|
|
|
276
405
|
const results = await Promise.allSettled(
|
|
277
406
|
secrets.map(async (meta: SecretFieldMeta) => {
|
|
278
|
-
const value = await resolvedProvider.getSecret(meta.secretName);
|
|
407
|
+
const value = await resolvedProvider.getSecret(meta.secretName, meta.version);
|
|
279
408
|
if (value !== null) {
|
|
280
409
|
resolved.set(meta.path, value);
|
|
281
410
|
}
|
|
@@ -812,7 +812,6 @@ describe("expressServer", () => {
|
|
|
812
812
|
// Mock app.listen on the Express prototype to avoid opening a real port
|
|
813
813
|
const express = await import("express");
|
|
814
814
|
const originalListen = express.default.application.listen;
|
|
815
|
-
// biome-ignore lint/suspicious/noExplicitAny: mocking Express internals requires type escape
|
|
816
815
|
express.default.application.listen = mock(function (this: unknown, ...args: unknown[]) {
|
|
817
816
|
const cb = args.find((a: unknown) => typeof a === "function") as (() => void) | undefined;
|
|
818
817
|
if (cb) {
|
package/src/openApi.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type {ModelRouterOptions, OpenApiMiddleware} from "./api";
|
|
|
8
8
|
import {logger} from "./logger";
|
|
9
9
|
import {getOpenApiSpecForModel} from "./populate";
|
|
10
10
|
|
|
11
|
-
const noop = (_a, _b, next) => next();
|
|
11
|
+
const noop = (_a: unknown, _b: unknown, next: () => void) => next();
|
|
12
12
|
|
|
13
13
|
const m2sOptions = {
|
|
14
14
|
props: ["readOnly", "required", "enum", "default"],
|
|
@@ -44,7 +44,7 @@ export const defaultOpenApiErrorResponses = {
|
|
|
44
44
|
};
|
|
45
45
|
|
|
46
46
|
// We repeat this constantly, so we make it a component so we only have to define it once.
|
|
47
|
-
|
|
47
|
+
const createAPIErrorComponent = (openApi?: OpenApiMiddleware): void => {
|
|
48
48
|
// Create a schema component called APIError
|
|
49
49
|
openApi?.component("schemas", "APIError", {
|
|
50
50
|
properties: {
|
|
@@ -111,12 +111,12 @@ function createAPIErrorComponent(openApi?: OpenApiMiddleware) {
|
|
|
111
111
|
},
|
|
112
112
|
type: "object",
|
|
113
113
|
});
|
|
114
|
-
}
|
|
114
|
+
};
|
|
115
115
|
|
|
116
|
-
export
|
|
116
|
+
export const getOpenApiMiddleware = <T>(
|
|
117
117
|
model: Model<T>,
|
|
118
118
|
options: Partial<ModelRouterOptions<T>>
|
|
119
|
-
): express.RequestHandler {
|
|
119
|
+
): express.RequestHandler => {
|
|
120
120
|
createAPIErrorComponent(options.openApi);
|
|
121
121
|
if (!options.openApi?.path) {
|
|
122
122
|
// Just log this once rather than for each middleware.
|
|
@@ -158,12 +158,12 @@ export function getOpenApiMiddleware<T>(
|
|
|
158
158
|
options.openApiOverwrite?.get ?? {}
|
|
159
159
|
)
|
|
160
160
|
);
|
|
161
|
-
}
|
|
161
|
+
};
|
|
162
162
|
|
|
163
|
-
export
|
|
163
|
+
export const listOpenApiMiddleware = <T>(
|
|
164
164
|
model: Model<T>,
|
|
165
165
|
options: Partial<ModelRouterOptions<T>>
|
|
166
|
-
): express.RequestHandler {
|
|
166
|
+
): express.RequestHandler => {
|
|
167
167
|
if (!options.openApi?.path) {
|
|
168
168
|
return noop;
|
|
169
169
|
}
|
|
@@ -324,12 +324,12 @@ export function listOpenApiMiddleware<T>(
|
|
|
324
324
|
options.openApiOverwrite?.list ?? {}
|
|
325
325
|
)
|
|
326
326
|
);
|
|
327
|
-
}
|
|
327
|
+
};
|
|
328
328
|
|
|
329
|
-
export
|
|
329
|
+
export const createOpenApiMiddleware = <T>(
|
|
330
330
|
model: Model<T>,
|
|
331
331
|
options: Partial<ModelRouterOptions<T>>
|
|
332
|
-
): express.RequestHandler {
|
|
332
|
+
): express.RequestHandler => {
|
|
333
333
|
if (!options.openApi?.path) {
|
|
334
334
|
return noop;
|
|
335
335
|
}
|
|
@@ -376,12 +376,12 @@ export function createOpenApiMiddleware<T>(
|
|
|
376
376
|
options.openApiOverwrite?.create ?? {}
|
|
377
377
|
)
|
|
378
378
|
);
|
|
379
|
-
}
|
|
379
|
+
};
|
|
380
380
|
|
|
381
|
-
export
|
|
381
|
+
export const patchOpenApiMiddleware = <T>(
|
|
382
382
|
model: Model<T>,
|
|
383
383
|
options: Partial<ModelRouterOptions<T>>
|
|
384
|
-
): express.RequestHandler {
|
|
384
|
+
): express.RequestHandler => {
|
|
385
385
|
if (!options.openApi?.path) {
|
|
386
386
|
return noop;
|
|
387
387
|
}
|
|
@@ -428,12 +428,12 @@ export function patchOpenApiMiddleware<T>(
|
|
|
428
428
|
options.openApiOverwrite?.update ?? {}
|
|
429
429
|
)
|
|
430
430
|
);
|
|
431
|
-
}
|
|
431
|
+
};
|
|
432
432
|
|
|
433
|
-
export
|
|
433
|
+
export const deleteOpenApiMiddleware = <T>(
|
|
434
434
|
model: Model<T>,
|
|
435
435
|
options: Partial<ModelRouterOptions<T>>
|
|
436
|
-
): express.RequestHandler {
|
|
436
|
+
): express.RequestHandler => {
|
|
437
437
|
if (!options.openApi?.path) {
|
|
438
438
|
return noop;
|
|
439
439
|
}
|
|
@@ -456,16 +456,16 @@ export function deleteOpenApiMiddleware<T>(
|
|
|
456
456
|
options.openApiOverwrite?.delete ?? {}
|
|
457
457
|
)
|
|
458
458
|
);
|
|
459
|
-
}
|
|
459
|
+
};
|
|
460
460
|
|
|
461
461
|
// This is a generic OpenAPI wrapper for a read that returns any object described by `properties`.
|
|
462
462
|
// Useful for endpoints that don't directly map to a model.
|
|
463
|
-
export
|
|
463
|
+
export const readOpenApiMiddleware = <T>(
|
|
464
464
|
options: Partial<ModelRouterOptions<T>>,
|
|
465
465
|
properties: Record<string, unknown>,
|
|
466
466
|
required: string[],
|
|
467
467
|
queryParameters: Array<Record<string, unknown>>
|
|
468
|
-
): express.RequestHandler {
|
|
468
|
+
): express.RequestHandler => {
|
|
469
469
|
if (!options.openApi?.path) {
|
|
470
470
|
// Just log this once rather than for each middleware.
|
|
471
471
|
logger.debug(
|
|
@@ -502,4 +502,4 @@ export function readOpenApiMiddleware<T>(
|
|
|
502
502
|
options.openApiOverwrite?.get ?? {}
|
|
503
503
|
)
|
|
504
504
|
);
|
|
505
|
-
}
|
|
505
|
+
};
|
package/src/populate.test.ts
CHANGED
|
@@ -265,6 +265,31 @@ describe("getOpenApiSpecForModel edge cases", () => {
|
|
|
265
265
|
});
|
|
266
266
|
});
|
|
267
267
|
|
|
268
|
+
describe("getOpenApiSpecForModel populate with existing properties", () => {
|
|
269
|
+
it("merges populated properties into a path that already has properties", () => {
|
|
270
|
+
const result = getOpenApiSpecForModel(FoodModel, {
|
|
271
|
+
populatePaths: [{path: "likesIds.userId"}],
|
|
272
|
+
});
|
|
273
|
+
// likesIds is an array subschema with its own properties already;
|
|
274
|
+
// populating userId should merge the user properties into the existing structure.
|
|
275
|
+
expect(result.properties.likesIds).toBeDefined();
|
|
276
|
+
const likesIds = result.properties.likesIds as Record<string, unknown>;
|
|
277
|
+
const items = likesIds.items as Record<string, Record<string, unknown>>;
|
|
278
|
+
expect(items.properties.userId).toBeDefined();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("creates intermediate path structure when navigating to nested populate", () => {
|
|
282
|
+
// eatenBy is defined as [{ ref: "User", type: ObjectId }] - an array of refs.
|
|
283
|
+
// When we populate eatenBy, the openApiPath resolves through items.
|
|
284
|
+
const result = getOpenApiSpecForModel(FoodModel, {
|
|
285
|
+
populatePaths: [{path: "eatenBy"}],
|
|
286
|
+
});
|
|
287
|
+
expect(result.properties.eatenBy).toBeDefined();
|
|
288
|
+
const eatenBy = result.properties.eatenBy as Record<string, unknown>;
|
|
289
|
+
expect(eatenBy.items).toBeDefined();
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
268
293
|
describe("filterKeys (via getOpenApiSpecForModel populatePaths)", () => {
|
|
269
294
|
it("filters populated fields using dot-notation keys", () => {
|
|
270
295
|
const result = getOpenApiSpecForModel(FoodModel, {
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* Supports: equality, $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $and, $or, $not.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
// biome-ignore lint/suspicious/noExplicitAny: traversing arbitrary nested document fields by user-supplied dotted path
|
|
10
9
|
const getNestedValue = (doc: any, path: string): any => {
|
|
11
10
|
const parts = path.split(".");
|
|
12
11
|
let current = doc;
|
|
@@ -19,7 +18,6 @@ const getNestedValue = (doc: any, path: string): any => {
|
|
|
19
18
|
return current;
|
|
20
19
|
};
|
|
21
20
|
|
|
22
|
-
// biome-ignore lint/suspicious/noExplicitAny: value may be any document field type (string, number, ObjectId, etc.)
|
|
23
21
|
const normalize = (value: any): any => {
|
|
24
22
|
if (value === null || value === undefined) {
|
|
25
23
|
return value;
|
|
@@ -36,7 +34,6 @@ const normalize = (value: any): any => {
|
|
|
36
34
|
return value;
|
|
37
35
|
};
|
|
38
36
|
|
|
39
|
-
// biome-ignore lint/suspicious/noExplicitAny: rawValue is an arbitrary document field, condition is an arbitrary user query operand
|
|
40
37
|
const matchesCondition = (rawValue: any, condition: any): boolean => {
|
|
41
38
|
const value = normalize(rawValue);
|
|
42
39
|
|
|
@@ -91,7 +88,6 @@ const matchesCondition = (rawValue: any, condition: any): boolean => {
|
|
|
91
88
|
return false;
|
|
92
89
|
}
|
|
93
90
|
const inValues = operand.map(normalize);
|
|
94
|
-
// biome-ignore lint/suspicious/noExplicitAny: normalized value of arbitrary document field
|
|
95
91
|
if (!inValues.some((v: any) => v === value || String(v) === String(value))) {
|
|
96
92
|
return false;
|
|
97
93
|
}
|
|
@@ -102,7 +98,6 @@ const matchesCondition = (rawValue: any, condition: any): boolean => {
|
|
|
102
98
|
return false;
|
|
103
99
|
}
|
|
104
100
|
const ninValues = operand.map(normalize);
|
|
105
|
-
// biome-ignore lint/suspicious/noExplicitAny: normalized value of arbitrary document field
|
|
106
101
|
if (ninValues.some((v: any) => v === value || String(v) === String(value))) {
|
|
107
102
|
return false;
|
|
108
103
|
}
|
|
@@ -137,7 +132,6 @@ const matchesCondition = (rawValue: any, condition: any): boolean => {
|
|
|
137
132
|
* @param query - MongoDB-style query object
|
|
138
133
|
* @returns true if the document matches all query conditions
|
|
139
134
|
*/
|
|
140
|
-
// biome-ignore lint/suspicious/noExplicitAny: doc is arbitrary; query values are arbitrary user-supplied JSON
|
|
141
135
|
export const matchesQuery = (doc: any, query: Record<string, any>): boolean => {
|
|
142
136
|
for (const [key, condition] of Object.entries(query)) {
|
|
143
137
|
if (key === "$and") {
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
|
|
10
10
|
interface QuerySubscription {
|
|
11
11
|
collection: string;
|
|
12
|
-
// biome-ignore lint/suspicious/noExplicitAny: MongoDB query filter values are arbitrary user-supplied JSON
|
|
13
12
|
query: Record<string, any>;
|
|
14
13
|
queryId: string;
|
|
15
14
|
}
|
|
@@ -18,13 +17,8 @@ interface QuerySubscription {
|
|
|
18
17
|
* Compute a deterministic queryId from collection and query on the server side.
|
|
19
18
|
* This prevents clients from hijacking other subscriptions by providing a colliding queryId.
|
|
20
19
|
*/
|
|
21
|
-
export const computeQueryId = (
|
|
22
|
-
collection: string,
|
|
23
|
-
// biome-ignore lint/suspicious/noExplicitAny: MongoDB query filter values are arbitrary user-supplied JSON
|
|
24
|
-
query: Record<string, any>
|
|
25
|
-
): string => {
|
|
20
|
+
export const computeQueryId = (collection: string, query: Record<string, any>): string => {
|
|
26
21
|
const sortedKeys = Object.keys(query).sort();
|
|
27
|
-
// biome-ignore lint/suspicious/noExplicitAny: mirrors the input query value shape
|
|
28
22
|
const normalized: Record<string, any> = {};
|
|
29
23
|
for (const key of sortedKeys) {
|
|
30
24
|
normalized[key] = query[key];
|
|
@@ -45,7 +39,6 @@ const socketQueries = new Map<string, Set<string>>();
|
|
|
45
39
|
export const addQuerySubscription = (
|
|
46
40
|
socketId: string,
|
|
47
41
|
collection: string,
|
|
48
|
-
// biome-ignore lint/suspicious/noExplicitAny: MongoDB query filter values are arbitrary user-supplied JSON
|
|
49
42
|
query: Record<string, any>,
|
|
50
43
|
queryId: string
|
|
51
44
|
): void => {
|
|
@@ -109,9 +102,7 @@ export const removeAllSocketQueries = (socketId: string): void => {
|
|
|
109
102
|
*/
|
|
110
103
|
export const getQuerySubscriptionsForCollection = (
|
|
111
104
|
collection: string
|
|
112
|
-
// biome-ignore lint/suspicious/noExplicitAny: MongoDB query filter values are arbitrary user-supplied JSON
|
|
113
105
|
): {queryId: string; query: Record<string, any>}[] => {
|
|
114
|
-
// biome-ignore lint/suspicious/noExplicitAny: MongoDB query filter values are arbitrary user-supplied JSON
|
|
115
106
|
const result: {queryId: string; query: Record<string, any>}[] = [];
|
|
116
107
|
|
|
117
108
|
for (const [queryId, sub] of querySubscriptions) {
|