@open-predicate/open-predicate 0.6.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 +532 -0
- package/LICENSE +21 -0
- package/README.md +483 -0
- package/SPEC.md +534 -0
- package/open-predicate-schema.json +395 -0
- package/package.json +67 -0
- package/tools/generate-filter-schema.mjs +1153 -0
|
@@ -0,0 +1,1153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* generate-filter-schema.mjs — derive a per-resource filter schema from a
|
|
4
|
+
* resource's JSON Schema.
|
|
5
|
+
*
|
|
6
|
+
* The published grammar (open-predicate-schema.json) shares one `Constraint`
|
|
7
|
+
* definition across every field, so it can say that `{"status": "Available"}`
|
|
8
|
+
* is well-formed but not that "Available" is outside `status`'s domain. That
|
|
9
|
+
* gap is why SPEC.md §2.2 exists: the domains have to be published somewhere,
|
|
10
|
+
* and the grammar is not able to carry them.
|
|
11
|
+
*
|
|
12
|
+
* A *generated* schema can. Given the resource's own JSON Schema, every
|
|
13
|
+
* queryable path is known, along with its type, format and value set — so each
|
|
14
|
+
* path gets its own constraint subschema, carrying only the operators that
|
|
15
|
+
* apply to it and only the operands it can meaningfully take. The three
|
|
16
|
+
* valid-but-wrong filters in README §"Exposing search to an agent" all become
|
|
17
|
+
* validation failures instead of empty result sets.
|
|
18
|
+
*
|
|
19
|
+
* Operator titles and descriptions are copied from the published grammar rather
|
|
20
|
+
* than restated here, so the prose an agent reads stays in one place.
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* open-predicate-generate <resource-schema.json> [options]
|
|
24
|
+
* open-predicate-generate --config <open-predicate.config.json>
|
|
25
|
+
*
|
|
26
|
+
* `open-predicate-generate` is the installed name; from a clone it is
|
|
27
|
+
* `node tools/generate-filter-schema.mjs`, with the same arguments.
|
|
28
|
+
*
|
|
29
|
+
* What the schema describes:
|
|
30
|
+
* --id <uri> $id for the generated schema (recommended)
|
|
31
|
+
* --title <text> title for the generated schema
|
|
32
|
+
* --pointer <json-ptr> subschema of the input file to treat as the resource
|
|
33
|
+
* --max-depth <n> how far to descend into nested objects (default 3)
|
|
34
|
+
* --include <list> comma-separated paths; omit for "everything found"
|
|
35
|
+
* --exclude <list> comma-separated paths or path prefixes ending in *
|
|
36
|
+
*
|
|
37
|
+
* What the endpoint can actually serve — the filter schema is a narrowing, so
|
|
38
|
+
* anything declined here is rejected by validation instead of at runtime:
|
|
39
|
+
* --profiles <list> comma-separated; default core,strings,ranges,collections
|
|
40
|
+
* --operators <list> exactly these operators, intersected with --profiles
|
|
41
|
+
* --drop-operators <list> everything --profiles implies, minus these
|
|
42
|
+
* --no-shorthand drop the bare-scalar form; every constraint takes
|
|
43
|
+
* the object form
|
|
44
|
+
* --max-filter-depth <n> the deepest filter the schema accepts. 1 is a flat
|
|
45
|
+
* filter with no logical operators at all, 2 permits
|
|
46
|
+
* one $and/$or/$nor/$not level. Unbounded by default
|
|
47
|
+
* --limits <json|@file> maxDepth, maxClauses and maxSetLength for the
|
|
48
|
+
* capability document, merged over the defaults
|
|
49
|
+
*
|
|
50
|
+
* Output:
|
|
51
|
+
* --descriptions <mode> all | brief | none. Default brief: the operators
|
|
52
|
+
* whose semantics surprise people keep their prose,
|
|
53
|
+
* the self-evident ones keep only a title.
|
|
54
|
+
* --out <file> write the schema here instead of stdout
|
|
55
|
+
* --capabilities <file> also write a SPEC.md §2.2 capability document
|
|
56
|
+
* --grammar <file> path to open-predicate-schema.json
|
|
57
|
+
* --config <file> read these options from JSON, using the camelCase
|
|
58
|
+
* names of the JS API plus "resource", "out" and
|
|
59
|
+
* "capabilities". Relative paths in it resolve
|
|
60
|
+
* against its own directory, and an explicit flag
|
|
61
|
+
* always beats it. This is the file to check in.
|
|
62
|
+
* --quiet suppress warnings on stderr
|
|
63
|
+
*
|
|
64
|
+
* A property may also opt out or override in the resource schema itself:
|
|
65
|
+
* "x-open-predicate": false — not queryable
|
|
66
|
+
* "x-open-predicate": { "queryable": false } — same
|
|
67
|
+
* "x-open-predicate": { "operators": ["$eq"] } — exactly these operators
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
71
|
+
import { fileURLToPath } from "node:url";
|
|
72
|
+
import { dirname, join, resolve } from "node:path";
|
|
73
|
+
import { parseArgs } from "node:util";
|
|
74
|
+
|
|
75
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
76
|
+
const DEFAULT_GRAMMAR = join(here, "..", "open-predicate-schema.json");
|
|
77
|
+
const DEFAULT_PROFILES = ["core", "strings", "ranges", "collections"];
|
|
78
|
+
/** SPEC §7's RECOMMENDED defaults, published through the capability document (§2.2). */
|
|
79
|
+
const DEFAULT_LIMITS = { maxDepth: 10, maxClauses: 100, maxSetLength: 1000 };
|
|
80
|
+
|
|
81
|
+
/** Formats whose values are opaque tokens: substring matching on them is noise. */
|
|
82
|
+
const OPAQUE_FORMATS = new Set(["uuid", "uri", "iri", "email", "ipv4", "ipv6", "duration"]);
|
|
83
|
+
/** Formats whose lexicographic order coincides with their natural order (SPEC §5.2). */
|
|
84
|
+
const ORDERED_FORMATS = new Set(["date", "date-time", "time"]);
|
|
85
|
+
/**
|
|
86
|
+
* Operators whose description earns its place next to every field: each one is
|
|
87
|
+
* a rule a reader would otherwise get wrong. The rest carry their title only,
|
|
88
|
+
* because repeating "Field equals the operand" once per path is pure tokens in
|
|
89
|
+
* an MCP tool definition. `--descriptions all` restores them.
|
|
90
|
+
*/
|
|
91
|
+
const SURPRISING = new Set([
|
|
92
|
+
"$and", "$or", "$nor", "$not",
|
|
93
|
+
"$in", "$nin", "$exists", "$isNull", "$type",
|
|
94
|
+
"$like", "$ilike", "$contains", "$regex", "$flags", "$search",
|
|
95
|
+
"$between", "$hasAll", "$size", "$some", "$every", "$unknownAs",
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
/** Value-domain keywords worth carrying onto an equality operand. */
|
|
99
|
+
const DOMAIN_KEYWORDS = [
|
|
100
|
+
"enum", "const", "format", "pattern", "minLength", "maxLength",
|
|
101
|
+
"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf",
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// $ref resolution and allOf flattening
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
function jsonPointer(root, pointer) {
|
|
109
|
+
if (pointer === "" || pointer === "#") return root;
|
|
110
|
+
const parts = pointer.replace(/^#/, "").split("/").slice(1);
|
|
111
|
+
let node = root;
|
|
112
|
+
for (const raw of parts) {
|
|
113
|
+
const key = decodeURIComponent(raw).replace(/~1/g, "/").replace(/~0/g, "~");
|
|
114
|
+
if (node === undefined || node === null) return undefined;
|
|
115
|
+
node = node[key];
|
|
116
|
+
}
|
|
117
|
+
return node;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Follows local $refs to a concrete node. `trail` accumulates the pointers
|
|
122
|
+
* crossed on this branch so a recursive schema (Pet.friends -> Pet) terminates
|
|
123
|
+
* rather than looping: a ref already on the trail resolves to null and the
|
|
124
|
+
* caller drops that branch.
|
|
125
|
+
*/
|
|
126
|
+
function makeDeref(root) {
|
|
127
|
+
return function deref(node, trail) {
|
|
128
|
+
let cur = node;
|
|
129
|
+
for (let hops = 0; cur && typeof cur === "object" && typeof cur.$ref === "string"; hops++) {
|
|
130
|
+
const ref = cur.$ref;
|
|
131
|
+
if (!ref.startsWith("#")) {
|
|
132
|
+
throw new Error(`only local $ref is supported; found "${ref}". Bundle the schema first.`);
|
|
133
|
+
}
|
|
134
|
+
if (hops > 32) throw new Error(`$ref chain too long at "${ref}"`);
|
|
135
|
+
if (trail?.has(ref)) return null;
|
|
136
|
+
trail?.add(ref);
|
|
137
|
+
const target = jsonPointer(root, ref);
|
|
138
|
+
if (target === undefined) throw new Error(`unresolvable $ref: "${ref}"`);
|
|
139
|
+
cur = target;
|
|
140
|
+
}
|
|
141
|
+
return cur;
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Shallow-merges allOf branches so a composed resource schema still yields fields. */
|
|
146
|
+
function flatten(node, deref, trail) {
|
|
147
|
+
const resolved = deref(node, trail);
|
|
148
|
+
if (!resolved || typeof resolved !== "object") return resolved;
|
|
149
|
+
if (!Array.isArray(resolved.allOf)) return resolved;
|
|
150
|
+
|
|
151
|
+
const merged = { ...resolved };
|
|
152
|
+
delete merged.allOf;
|
|
153
|
+
for (const branch of resolved.allOf) {
|
|
154
|
+
const b = flatten(branch, deref, new Set(trail));
|
|
155
|
+
if (!b || typeof b !== "object") continue;
|
|
156
|
+
merged.properties = { ...(b.properties ?? {}), ...(merged.properties ?? {}) };
|
|
157
|
+
if (b.required) merged.required = [...new Set([...(b.required ?? []), ...(merged.required ?? [])])];
|
|
158
|
+
for (const k of ["type", "items", "description", "title", ...DOMAIN_KEYWORDS]) {
|
|
159
|
+
if (merged[k] === undefined && b[k] !== undefined) merged[k] = b[k];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return merged;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Type inspection
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
function typesOf(schema) {
|
|
170
|
+
if (!schema || typeof schema !== "object") return [];
|
|
171
|
+
if (typeof schema.type === "string") return [schema.type];
|
|
172
|
+
if (Array.isArray(schema.type)) return [...schema.type];
|
|
173
|
+
// No explicit "type" — infer from whatever else is present.
|
|
174
|
+
const inferred = new Set();
|
|
175
|
+
for (const value of [].concat(schema.const ?? [], schema.enum ?? [])) {
|
|
176
|
+
inferred.add(value === null ? "null" : Array.isArray(value) ? "array" : typeof value === "object" ? "object" : typeof value);
|
|
177
|
+
}
|
|
178
|
+
if (schema.properties || schema.additionalProperties) inferred.add("object");
|
|
179
|
+
if (schema.items || schema.prefixItems) inferred.add("array");
|
|
180
|
+
return [...inferred];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The consts of a closed domain, whether written as `enum` or as a union of `const`s. */
|
|
184
|
+
function domainValues(schema) {
|
|
185
|
+
if (Array.isArray(schema?.enum)) return schema.enum;
|
|
186
|
+
if (schema?.const !== undefined) return [schema.const];
|
|
187
|
+
const union = schema?.oneOf ?? schema?.anyOf;
|
|
188
|
+
if (Array.isArray(union) && union.length && union.every((b) => b && b.const !== undefined)) {
|
|
189
|
+
return union.map((b) => b.const);
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const SCALARS = new Set(["string", "number", "integer", "boolean"]);
|
|
195
|
+
|
|
196
|
+
function classify(schema) {
|
|
197
|
+
const types = typesOf(schema);
|
|
198
|
+
const nullable = types.includes("null");
|
|
199
|
+
const nonNull = types.filter((t) => t !== "null");
|
|
200
|
+
if (nonNull.length === 0) return { kind: "unknown", nullable, types: nonNull };
|
|
201
|
+
if (nonNull.length === 1 && nonNull[0] === "array") return { kind: "array", nullable, types: nonNull };
|
|
202
|
+
if (nonNull.length === 1 && nonNull[0] === "object") return { kind: "object", nullable, types: nonNull };
|
|
203
|
+
if (nonNull.every((t) => SCALARS.has(t))) return { kind: "scalar", nullable, types: nonNull };
|
|
204
|
+
return { kind: "mixed", nullable, types: nonNull };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Field paths (SPEC §3.2, §3.3)
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
function escapeKey(key) {
|
|
212
|
+
const escaped = key.replace(/([.[\]\\])/g, "\\$1");
|
|
213
|
+
return escaped.startsWith("$") ? `$${escaped}` : escaped;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function globToRegExp(glob) {
|
|
217
|
+
const body = glob.split("*").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
218
|
+
return new RegExp(`^${body}$`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Field collection
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Walks one object schema and returns the queryable paths beneath it. Nested
|
|
227
|
+
* objects contribute dotted paths within the *same* filter; arrays stop the
|
|
228
|
+
* descent and are addressed through the $some/$every quantifiers instead, which
|
|
229
|
+
* say which quantifier is meant instead of leaving it to a path shape (SPEC §5.8).
|
|
230
|
+
*/
|
|
231
|
+
function collectFields(node, ctx, state) {
|
|
232
|
+
const out = [];
|
|
233
|
+
walk(node, "", true, state.depth, new Set(state.trail));
|
|
234
|
+
|
|
235
|
+
function walk(schemaNode, prefix, alwaysPresent, depth, trail) {
|
|
236
|
+
const schema = flatten(schemaNode, ctx.deref, trail);
|
|
237
|
+
if (!schema || typeof schema !== "object" || !schema.properties) return;
|
|
238
|
+
const required = new Set(schema.required ?? []);
|
|
239
|
+
|
|
240
|
+
for (const [name, rawProp] of Object.entries(schema.properties)) {
|
|
241
|
+
const branchTrail = new Set(trail);
|
|
242
|
+
const prop = flatten(rawProp, ctx.deref, branchTrail);
|
|
243
|
+
if (!prop || typeof prop !== "object") continue;
|
|
244
|
+
|
|
245
|
+
const ext = rawProp["x-open-predicate"] ?? prop["x-open-predicate"];
|
|
246
|
+
if (ext === false || ext?.queryable === false) continue;
|
|
247
|
+
|
|
248
|
+
const path = prefix + escapeKey(name);
|
|
249
|
+
if (ctx.opts.exclude.some((rx) => rx.test(path))) continue;
|
|
250
|
+
|
|
251
|
+
const present = alwaysPresent && required.has(name);
|
|
252
|
+
const info = classify(prop);
|
|
253
|
+
|
|
254
|
+
if (info.kind === "unknown") {
|
|
255
|
+
ctx.warn(`skipped "${path}": no discoverable type. Add "type", or "x-open-predicate": {"operators": [...]}.`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (info.kind === "object") {
|
|
260
|
+
// The object itself is queryable only for presence; its members carry
|
|
261
|
+
// the real predicates.
|
|
262
|
+
if (!present) out.push({ path, schema: prop, info, present, ext, kind: "object" });
|
|
263
|
+
if (depth + 1 <= ctx.opts.maxDepth) {
|
|
264
|
+
walk(prop, `${path}.`, present, depth + 1, branchTrail);
|
|
265
|
+
} else {
|
|
266
|
+
ctx.warn(`stopped at "${path}": --max-depth ${ctx.opts.maxDepth} reached.`);
|
|
267
|
+
}
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (info.kind === "array") {
|
|
272
|
+
const items = flatten(prop.items ?? {}, ctx.deref, branchTrail);
|
|
273
|
+
const itemInfo = items ? classify(items) : { kind: "unknown" };
|
|
274
|
+
out.push({ path, schema: prop, info, present, ext, kind: "array", items, itemInfo, trail: branchTrail, depth });
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
out.push({ path, schema: prop, info, present, ext, kind: info.kind });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Operator selection
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
function operatorsFor(field, ctx) {
|
|
290
|
+
if (Array.isArray(field.ext?.operators)) {
|
|
291
|
+
return field.ext.operators.filter((op) => ctx.enabled.has(op));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const ops = [];
|
|
295
|
+
const push = (...names) => { for (const n of names) if (ctx.enabled.has(n)) ops.push(n); };
|
|
296
|
+
const { info, schema, kind } = field;
|
|
297
|
+
const format = schema.format;
|
|
298
|
+
const closed = domainValues(schema) !== null;
|
|
299
|
+
|
|
300
|
+
if (kind === "object") {
|
|
301
|
+
push("$exists");
|
|
302
|
+
if (info.nullable) push("$isNull");
|
|
303
|
+
pushUnknownAs(push, field, info);
|
|
304
|
+
return ops;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (kind === "array") {
|
|
308
|
+
// An element type we could not resolve — an untyped `items`, or a cycle the
|
|
309
|
+
// walk cut — leaves nothing to type an operand against. Length and presence
|
|
310
|
+
// are all that can be offered honestly.
|
|
311
|
+
if (!field.itemInfo || field.itemInfo.kind === "unknown") {
|
|
312
|
+
ctx.warn(`"${field.path}": element type unresolved; only $size and presence are queryable.`);
|
|
313
|
+
push("$size");
|
|
314
|
+
if (!field.present) push("$exists");
|
|
315
|
+
if (info.nullable) push("$isNull");
|
|
316
|
+
push("$not");
|
|
317
|
+
pushUnknownAs(push, field, info);
|
|
318
|
+
return ops;
|
|
319
|
+
}
|
|
320
|
+
push("$eq", "$ne");
|
|
321
|
+
if (field.itemInfo?.kind === "object" || field.itemInfo?.kind === "scalar") push("$some", "$every");
|
|
322
|
+
if (field.itemInfo.kind === "scalar") push("$hasAll");
|
|
323
|
+
push("$size");
|
|
324
|
+
if (!field.present) push("$exists");
|
|
325
|
+
if (info.nullable) push("$isNull");
|
|
326
|
+
push("$not");
|
|
327
|
+
pushUnknownAs(push, field, info);
|
|
328
|
+
return ops;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const isString = info.types.includes("string");
|
|
332
|
+
const isNumeric = info.types.some((t) => t === "number" || t === "integer");
|
|
333
|
+
const isBoolean = info.types.length === 1 && info.types[0] === "boolean";
|
|
334
|
+
|
|
335
|
+
push("$eq", "$ne");
|
|
336
|
+
// $in over a two-valued domain says nothing $eq does not.
|
|
337
|
+
if (!isBoolean) push("$in", "$nin");
|
|
338
|
+
|
|
339
|
+
const ordered = isNumeric || (isString && ORDERED_FORMATS.has(format));
|
|
340
|
+
if (ordered) {
|
|
341
|
+
push("$gt", "$gte", "$lt", "$lte");
|
|
342
|
+
push("$between", "$nbetween");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Pattern matching is meaningful on free text only: not on a closed domain,
|
|
346
|
+
// where the accepted values are already enumerated, and not on an opaque
|
|
347
|
+
// token like a UUID.
|
|
348
|
+
if (isString && !closed && !OPAQUE_FORMATS.has(format) && !ORDERED_FORMATS.has(format)) {
|
|
349
|
+
push("$like", "$nlike", "$ilike", "$nilike", "$startsWith", "$endsWith", "$contains");
|
|
350
|
+
push("$regex", "$flags");
|
|
351
|
+
push("$search");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// $type only earns its place where the type is genuinely a union.
|
|
355
|
+
if (info.types.length > 1) push("$type");
|
|
356
|
+
if (!field.present) push("$exists");
|
|
357
|
+
if (info.nullable) push("$isNull");
|
|
358
|
+
push("$not");
|
|
359
|
+
pushUnknownAs(push, field, info);
|
|
360
|
+
return ops;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* $unknownAs only earns its place where UNKNOWN is reachable. A property that is
|
|
365
|
+
* required all the way up and cannot hold null never resolves to nothing, and
|
|
366
|
+
* the generated operand schemas make a type mismatch a validation error rather
|
|
367
|
+
* than an UNKNOWN — so on those fields the modifier would be a constant, exactly
|
|
368
|
+
* as $exists and $isNull are.
|
|
369
|
+
*/
|
|
370
|
+
function pushUnknownAs(push, field, info) {
|
|
371
|
+
if (!field.present || info.nullable) push("$unknownAs");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// Operand schemas
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The operand schema for the equality family: the field's own value domain.
|
|
380
|
+
* Carrying `enum`, `pattern` and the numeric bounds is what turns
|
|
381
|
+
* {"status": "Available"} from an empty result set into a 400.
|
|
382
|
+
*/
|
|
383
|
+
function strictValue(schema, info) {
|
|
384
|
+
const value = {};
|
|
385
|
+
const known = info?.types ?? [];
|
|
386
|
+
const types = info?.nullable ? [...known, "null"] : [...known];
|
|
387
|
+
const consts = domainValues(schema);
|
|
388
|
+
const union = schema.oneOf ?? schema.anyOf;
|
|
389
|
+
|
|
390
|
+
if (consts && Array.isArray(union) && union.every((b) => b?.const !== undefined) && union.some((b) => b.description)) {
|
|
391
|
+
// Per-value prose only survives as a union of consts; an enum has nowhere
|
|
392
|
+
// to put it. README §"Exposing search to an agent", step 2.
|
|
393
|
+
if (types.length) value.type = types.length === 1 ? types[0] : types;
|
|
394
|
+
value.anyOf = union.map((b) => ({ const: b.const, ...(b.description ? { description: b.description } : {}) }));
|
|
395
|
+
if (info.nullable) value.anyOf.push({ const: null });
|
|
396
|
+
return value;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (types.length) value.type = types.length === 1 ? types[0] : types;
|
|
400
|
+
for (const k of DOMAIN_KEYWORDS) {
|
|
401
|
+
if (schema[k] !== undefined) value[k] = schema[k];
|
|
402
|
+
}
|
|
403
|
+
if (consts && info?.nullable && Array.isArray(value.enum) && !value.enum.includes(null)) {
|
|
404
|
+
value.enum = [...value.enum, null];
|
|
405
|
+
}
|
|
406
|
+
return value;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The operand schema for the ordering family. Deliberately looser than
|
|
411
|
+
* `strictValue`: `{"$gt": 0}` against a field whose minimum is 1 is a
|
|
412
|
+
* perfectly sensible predicate, so the bounds must not be carried across.
|
|
413
|
+
*/
|
|
414
|
+
function looseValue(schema, info) {
|
|
415
|
+
const value = { type: info.types.length === 1 ? info.types[0] : info.types };
|
|
416
|
+
if (schema.format && ORDERED_FORMATS.has(schema.format)) value.format = schema.format;
|
|
417
|
+
return value;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function setOf(itemsRef) {
|
|
421
|
+
return { type: "array", minItems: 1, uniqueItems: true, items: itemsRef };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// Emission
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
function sanitize(text) {
|
|
429
|
+
return text
|
|
430
|
+
.replace(/\[\*\]/g, "_elem")
|
|
431
|
+
.replace(/[^A-Za-z0-9]+/g, "_")
|
|
432
|
+
.replace(/^_+|_+$/g, "") || "field";
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function defName(ctx, prefix, hint) {
|
|
436
|
+
const base = `${prefix}${sanitize(hint)}`;
|
|
437
|
+
let name = base;
|
|
438
|
+
for (let n = 2; ctx.defs[name] !== undefined || ctx.reserved.has(name); n++) name = `${base}${n}`;
|
|
439
|
+
ctx.reserved.add(name);
|
|
440
|
+
return name;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Copies the operator's own title and description out of the published grammar,
|
|
445
|
+
* so the prose an agent reads lives in exactly one place. `from` picks the
|
|
446
|
+
* right definition for the two operators that exist at both levels: $not means
|
|
447
|
+
* something different inside a Filter than inside a constraint object.
|
|
448
|
+
*/
|
|
449
|
+
function annotate(ctx, op, schema, from = "constraint") {
|
|
450
|
+
const table = from === "filter" ? ctx.grammar.$defs.Filter.properties : ctx.grammar.$defs.ConstraintObject.properties;
|
|
451
|
+
const source = table[op] ?? ctx.grammar.$defs.ConstraintObject.properties[op] ?? ctx.grammar.$defs.Filter.properties[op];
|
|
452
|
+
const { title, description } = source ?? {};
|
|
453
|
+
const keep = ctx.opts.descriptions === "all" || (ctx.opts.descriptions === "brief" && SURPRISING.has(op));
|
|
454
|
+
return {
|
|
455
|
+
...(title ? { title } : {}),
|
|
456
|
+
...(keep && description ? { description } : {}),
|
|
457
|
+
...schema,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function emitSizeDef(ctx) {
|
|
462
|
+
if (!ctx.defs.Size) {
|
|
463
|
+
ctx.reserved.add("Size");
|
|
464
|
+
ctx.defs.Size = structuredClone(ctx.grammar.$defs.SizeConstraint);
|
|
465
|
+
}
|
|
466
|
+
return { $ref: "#/$defs/Size" };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Carries the published constraint object's dependency keywords over to a
|
|
471
|
+
* generated one, for the triggers whose operator survived: $flags needs $regex
|
|
472
|
+
* beside it, and $unknownAs needs something to modify.
|
|
473
|
+
*
|
|
474
|
+
* These rules are part of what the grammar rejects, so a generated schema that
|
|
475
|
+
* drops one is *wider* than the grammar there — which is the one thing a
|
|
476
|
+
* generated schema may never be (README §"Narrowing only"). Reading them off
|
|
477
|
+
* the grammar rather than restating them here means a rule added to
|
|
478
|
+
* $defs/ConstraintObject reaches generated schemas with the version that
|
|
479
|
+
* introduced it.
|
|
480
|
+
*/
|
|
481
|
+
function dependencyRules(ctx, props) {
|
|
482
|
+
const source = ctx.grammar.$defs.ConstraintObject;
|
|
483
|
+
// The rule is carried; the $comment justifying it is not. That prose is
|
|
484
|
+
// written for someone reading the grammar, and here it would be one copy per
|
|
485
|
+
// field, charged by the token to whoever inlines this in a tool definition.
|
|
486
|
+
const constraining = ({ $comment, ...rest }) => structuredClone(rest);
|
|
487
|
+
const rules = {};
|
|
488
|
+
for (const keyword of ["dependentSchemas", "dependentRequired"]) {
|
|
489
|
+
const kept = {};
|
|
490
|
+
for (const [trigger, rule] of Object.entries(source[keyword] ?? {})) {
|
|
491
|
+
if (trigger in props) kept[trigger] = Array.isArray(rule) ? [...rule] : constraining(rule);
|
|
492
|
+
}
|
|
493
|
+
if (Object.keys(kept).length > 0) rules[keyword] = kept;
|
|
494
|
+
}
|
|
495
|
+
return rules;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Builds the constraint-object subschema for one field, and registers it. */
|
|
499
|
+
function emitConstraint(ctx, field, prefix) {
|
|
500
|
+
const ops = operatorsFor(field, ctx);
|
|
501
|
+
if (ops.length === 0) return null;
|
|
502
|
+
// A bounded $not points at a copy of this constraint object with $not removed,
|
|
503
|
+
// so it needs at least one other operator to negate.
|
|
504
|
+
if (ctx.opts.maxFilterDepth !== undefined && ops.length === 1 && ops[0] === "$not") return null;
|
|
505
|
+
|
|
506
|
+
const name = defName(ctx, `${prefix}C_`, field.nameHint ?? field.path);
|
|
507
|
+
const props = {};
|
|
508
|
+
|
|
509
|
+
const hint = field.nameHint ?? field.path;
|
|
510
|
+
const valueRef = () => {
|
|
511
|
+
if (!field.valueDef) {
|
|
512
|
+
field.valueDef = defName(ctx, `${prefix}V_`, hint);
|
|
513
|
+
ctx.defs[field.valueDef] = strictValue(field.schema, field.info);
|
|
514
|
+
}
|
|
515
|
+
return { $ref: `#/$defs/${field.valueDef}` };
|
|
516
|
+
};
|
|
517
|
+
const orderedRef = () => {
|
|
518
|
+
if (!field.orderedDef) {
|
|
519
|
+
field.orderedDef = defName(ctx, `${prefix}O_`, hint);
|
|
520
|
+
ctx.defs[field.orderedDef] = looseValue(field.schema, field.info);
|
|
521
|
+
}
|
|
522
|
+
return { $ref: `#/$defs/${field.orderedDef}` };
|
|
523
|
+
};
|
|
524
|
+
// Unbounded, $not negates this very constraint object, so {"$not": {"$not": …}}
|
|
525
|
+
// nests forever. Where a depth is set that has to stop somewhere, and one
|
|
526
|
+
// level is the natural place: under Kleene logic ¬¬X ≡ X even for UNKNOWN, so
|
|
527
|
+
// a negated negation says nothing the plain constraint does not.
|
|
528
|
+
let negName = null;
|
|
529
|
+
const notRef = () => {
|
|
530
|
+
if (ctx.opts.maxFilterDepth === undefined) return `#/$defs/${name}`;
|
|
531
|
+
negName ??= defName(ctx, `${prefix}N_`, hint);
|
|
532
|
+
return `#/$defs/${negName}`;
|
|
533
|
+
};
|
|
534
|
+
const itemRef = () => {
|
|
535
|
+
if (!field.itemDef) {
|
|
536
|
+
field.itemDef = defName(ctx, `${prefix}I_`, hint);
|
|
537
|
+
ctx.defs[field.itemDef] = strictValue(field.items ?? {}, field.itemInfo);
|
|
538
|
+
}
|
|
539
|
+
return { $ref: `#/$defs/${field.itemDef}` };
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
for (const op of ops) {
|
|
543
|
+
switch (op) {
|
|
544
|
+
case "$eq": case "$ne":
|
|
545
|
+
props[op] = annotate(ctx, op, field.kind === "array"
|
|
546
|
+
? { type: "array", items: itemRef() }
|
|
547
|
+
: valueRef());
|
|
548
|
+
break;
|
|
549
|
+
case "$in": case "$nin":
|
|
550
|
+
props[op] = annotate(ctx, op, setOf(valueRef()));
|
|
551
|
+
break;
|
|
552
|
+
case "$gt": case "$gte": case "$lt": case "$lte":
|
|
553
|
+
props[op] = annotate(ctx, op, orderedRef());
|
|
554
|
+
break;
|
|
555
|
+
case "$between": case "$nbetween":
|
|
556
|
+
props[op] = annotate(ctx, op, { type: "array", minItems: 2, maxItems: 2, items: orderedRef() });
|
|
557
|
+
break;
|
|
558
|
+
case "$hasAll":
|
|
559
|
+
props[op] = annotate(ctx, op, setOf(itemRef()));
|
|
560
|
+
break;
|
|
561
|
+
case "$size":
|
|
562
|
+
props[op] = annotate(ctx, op, emitSizeDef(ctx));
|
|
563
|
+
break;
|
|
564
|
+
case "$some": case "$every": {
|
|
565
|
+
// Both quantifiers take the same element condition, so the subschema is
|
|
566
|
+
// emitted once and referenced twice.
|
|
567
|
+
field.elementTarget ??= emitQuantifierTarget(ctx, field, prefix);
|
|
568
|
+
if (field.elementTarget) props[op] = annotate(ctx, op, field.elementTarget);
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
case "$not":
|
|
572
|
+
props[op] = annotate(ctx, op, { $ref: notRef() });
|
|
573
|
+
break;
|
|
574
|
+
case "$flags":
|
|
575
|
+
props[op] = annotate(ctx, op, { type: "string", pattern: "^[ims]{0,3}$" });
|
|
576
|
+
break;
|
|
577
|
+
default: {
|
|
578
|
+
// Everything left takes the operand shape the grammar already gives it:
|
|
579
|
+
// $like and friends, $regex, $search, $exists, $isNull, $type.
|
|
580
|
+
const base = ctx.grammar.$defs.ConstraintObject.properties[op];
|
|
581
|
+
const { title, description, $ref, ...shape } = base ?? {};
|
|
582
|
+
props[op] = annotate(ctx, op, shape);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (negName) {
|
|
588
|
+
// The same constraint object minus $not, so one negation is expressible and
|
|
589
|
+
// two are not. Reachable only through $not, whose own description explains
|
|
590
|
+
// negation, so the field's prose is not repeated into it.
|
|
591
|
+
const { $not: dropped, ...rest } = props;
|
|
592
|
+
ctx.defs[negName] = {
|
|
593
|
+
title: `${field.path}, negated`,
|
|
594
|
+
type: "object",
|
|
595
|
+
minProperties: 1,
|
|
596
|
+
...dependencyRules(ctx, rest),
|
|
597
|
+
properties: rest,
|
|
598
|
+
additionalProperties: false,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const constraint = {
|
|
603
|
+
title: field.path,
|
|
604
|
+
...(field.schema.description ? { description: field.schema.description } : {}),
|
|
605
|
+
type: "object",
|
|
606
|
+
minProperties: 1,
|
|
607
|
+
...dependencyRules(ctx, props),
|
|
608
|
+
properties: props,
|
|
609
|
+
additionalProperties: false,
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
ctx.defs[name] = constraint;
|
|
613
|
+
field.constraintDef = name;
|
|
614
|
+
field.operators = ops;
|
|
615
|
+
|
|
616
|
+
// Scalars keep the shorthand: {"status": "open"} is {"status": {"$eq": "open"}}.
|
|
617
|
+
// --no-shorthand gives them the object-only form arrays and objects already
|
|
618
|
+
// have, which costs a provider nothing to accept and buys a validator that
|
|
619
|
+
// reports where a constraint went wrong instead of an anyOf that failed.
|
|
620
|
+
if (field.kind === "scalar" && ctx.opts.shorthand) {
|
|
621
|
+
return { anyOf: [valueRef(), { $ref: `#/$defs/${name}` }] };
|
|
622
|
+
}
|
|
623
|
+
return { $ref: `#/$defs/${name}` };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function emitQuantifierTarget(ctx, field, prefix) {
|
|
627
|
+
if (field.itemInfo?.kind === "object") {
|
|
628
|
+
if (field.depth + 1 > ctx.opts.maxDepth) {
|
|
629
|
+
ctx.warn(`"${field.path}": --max-depth reached, $some and $every omitted.`);
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
const name = defName(ctx, `${prefix}F_`, `${field.path}_elem`);
|
|
633
|
+
const filter = emitFilter(ctx, field.items, name, {
|
|
634
|
+
depth: field.depth + 1,
|
|
635
|
+
trail: field.trail ?? new Set(),
|
|
636
|
+
selfRef: `#/$defs/${name}`,
|
|
637
|
+
});
|
|
638
|
+
if (!filter) return null;
|
|
639
|
+
return { $ref: `#/$defs/${name}` };
|
|
640
|
+
}
|
|
641
|
+
if (field.itemInfo?.kind === "scalar") {
|
|
642
|
+
// An array of scalars has no member paths, so the element condition is a
|
|
643
|
+
// constraint object over the element value itself (SPEC §5.8).
|
|
644
|
+
const element = {
|
|
645
|
+
path: `${field.path} element`,
|
|
646
|
+
nameHint: `${field.path}_elem`,
|
|
647
|
+
schema: field.items ?? {},
|
|
648
|
+
info: field.itemInfo,
|
|
649
|
+
present: true,
|
|
650
|
+
kind: "scalar",
|
|
651
|
+
};
|
|
652
|
+
const ref = emitConstraint(ctx, element, prefix);
|
|
653
|
+
// The quantifiers take the object form only; the scalar shorthand is not
|
|
654
|
+
// part of their operand grammar.
|
|
655
|
+
return element.constraintDef ? { $ref: `#/$defs/${element.constraintDef}` } : ref;
|
|
656
|
+
}
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Emits a Filter over one object schema: logical operators plus one property
|
|
662
|
+
* per queryable path. `additionalProperties: false` over an explicit property
|
|
663
|
+
* list is what makes an unknown field a validation error rather than a runtime
|
|
664
|
+
* `unknown-field` error.
|
|
665
|
+
*/
|
|
666
|
+
function emitFilter(ctx, node, name, state) {
|
|
667
|
+
if (name !== "__root__") ctx.reserved.add(name);
|
|
668
|
+
const fields = collectFields(node, ctx, state);
|
|
669
|
+
if (fields.length === 0) {
|
|
670
|
+
ctx.warn(`no queryable fields found${name === "__root__" ? "" : ` for ${name}`}.`);
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const prefix = name === "__root__" ? "" : `${sanitize(name)}_`;
|
|
675
|
+
const fieldProps = {};
|
|
676
|
+
const emitted = [];
|
|
677
|
+
for (const field of fields) {
|
|
678
|
+
const ref = emitConstraint(ctx, field, prefix);
|
|
679
|
+
if (!ref) continue;
|
|
680
|
+
fieldProps[field.path] = ref;
|
|
681
|
+
emitted.push(field);
|
|
682
|
+
}
|
|
683
|
+
if (emitted.length === 0) {
|
|
684
|
+
// Logical operators over no field predicates is an infinite regress with no
|
|
685
|
+
// base case: every instance of it is unsatisfiable.
|
|
686
|
+
ctx.warn(`no constraints could be emitted${name === "__root__" ? "" : ` for ${name}`}.`);
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Unbounded nesting is the single self-referential object it has always been.
|
|
691
|
+
// A bounded one has to be a chain, because JSON Schema cannot count how deep
|
|
692
|
+
// an instance already is: level i offers the logical operators over level
|
|
693
|
+
// i+1, and the last level does not offer them at all. Every level shares the
|
|
694
|
+
// field properties, so the cost is n copies of a map of $refs, not n copies
|
|
695
|
+
// of the operand schemas.
|
|
696
|
+
const depth = ctx.opts.maxFilterDepth ?? 1;
|
|
697
|
+
const hint = name === "__root__" ? "Filter" : name;
|
|
698
|
+
const deeper = [];
|
|
699
|
+
for (let i = 2; i <= depth; i++) deeper.push(defName(ctx, "", `${hint}_depth_${i}`));
|
|
700
|
+
|
|
701
|
+
const level = (i) => {
|
|
702
|
+
const properties = {};
|
|
703
|
+
const next =
|
|
704
|
+
ctx.opts.maxFilterDepth === undefined ? (state.selfRef ?? "#")
|
|
705
|
+
: i < depth ? `#/$defs/${deeper[i - 1]}`
|
|
706
|
+
: null;
|
|
707
|
+
if (next) {
|
|
708
|
+
for (const op of ["$and", "$or", "$nor"]) {
|
|
709
|
+
if (!ctx.enabled.has(op)) continue;
|
|
710
|
+
properties[op] = annotate(ctx, op, { type: "array", minItems: 1, items: { $ref: next } }, "filter");
|
|
711
|
+
}
|
|
712
|
+
if (ctx.enabled.has("$not")) properties.$not = annotate(ctx, "$not", { $ref: next }, "filter");
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
type: "object",
|
|
716
|
+
minProperties: 1,
|
|
717
|
+
properties: { ...properties, ...fieldProps },
|
|
718
|
+
additionalProperties: false,
|
|
719
|
+
};
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
for (let i = 2; i <= depth; i++) ctx.defs[deeper[i - 2]] = level(i);
|
|
723
|
+
const filter = level(1);
|
|
724
|
+
|
|
725
|
+
if (name === "__root__") {
|
|
726
|
+
ctx.rootFilter = filter;
|
|
727
|
+
ctx.rootFields = emitted;
|
|
728
|
+
// The deeper levels repeat the root's field properties, so --include has to
|
|
729
|
+
// reach them too — otherwise a path pruned from the root walks back in
|
|
730
|
+
// under an $and.
|
|
731
|
+
ctx.rootLevels = deeper.map((level) => ctx.defs[level]);
|
|
732
|
+
} else {
|
|
733
|
+
ctx.defs[name] = filter;
|
|
734
|
+
}
|
|
735
|
+
return filter;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ---------------------------------------------------------------------------
|
|
739
|
+
// Public API
|
|
740
|
+
// ---------------------------------------------------------------------------
|
|
741
|
+
|
|
742
|
+
const SHORTHAND_RULE =
|
|
743
|
+
"A bare scalar is equality: {\"status\": \"open\"} is {\"status\": {\"$eq\": \"open\"}}.";
|
|
744
|
+
|
|
745
|
+
const SILENT_RULES = [
|
|
746
|
+
"Sibling members are combined with implicit AND, at every level.",
|
|
747
|
+
SHORTHAND_RULE,
|
|
748
|
+
"Comparisons use three-valued logic: $ne and $not do NOT match records where the field is null or absent. To include those records, add \"$unknownAs\": true to the same constraint.",
|
|
749
|
+
"$in compares the whole field value; it is not array membership. To say something about the elements of an array, quantify: {\"tags\": {\"$some\": {\"$in\": [\"a\"]}}}.",
|
|
750
|
+
];
|
|
751
|
+
|
|
752
|
+
export function generateFilterSchema(resource, options = {}) {
|
|
753
|
+
const ctx = prepare(resource, options);
|
|
754
|
+
emitFilter(ctx, ctx.resource, "__root__", { depth: 0, trail: new Set() });
|
|
755
|
+
if (!ctx.rootFilter) throw new Error("no queryable fields — nothing to generate");
|
|
756
|
+
if (ctx.opts.include) pruneToIncluded(ctx);
|
|
757
|
+
|
|
758
|
+
const title = options.title ?? `${ctx.resource.title ?? "Resource"} — filter`;
|
|
759
|
+
const schema = {
|
|
760
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
761
|
+
...(options.id ? { $id: options.id } : {}),
|
|
762
|
+
title,
|
|
763
|
+
description: [
|
|
764
|
+
`A filter over ${ctx.resource.title ?? "this resource"}, in OpenPredicate.`,
|
|
765
|
+
...SILENT_RULES.filter((rule) => ctx.opts.shorthand || rule !== SHORTHAND_RULE),
|
|
766
|
+
].join(" "),
|
|
767
|
+
$comment:
|
|
768
|
+
`Generated by tools/generate-filter-schema.mjs from ${ctx.resource.$id ?? options.source ?? "a resource schema"} ` +
|
|
769
|
+
`against ${ctx.grammar.$id}. Profiles: ${ctx.opts.profiles.join(", ")}` +
|
|
770
|
+
`${ctx.opts.excluded.length ? ` (minus ${ctx.opts.excluded.join(", ")})` : ""}. Do not edit by hand.`,
|
|
771
|
+
...ctx.rootFilter,
|
|
772
|
+
$defs: ctx.defs,
|
|
773
|
+
};
|
|
774
|
+
return { schema, capabilities: buildCapabilities(ctx, options), warnings: ctx.warnings };
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
export function generateCapabilities(resource, options = {}) {
|
|
778
|
+
return generateFilterSchema(resource, options).capabilities;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* --include is applied after the walk rather than during it, so a nested path
|
|
783
|
+
* like "shelter.city" can be named without also naming its parent.
|
|
784
|
+
*/
|
|
785
|
+
function pruneToIncluded(ctx) {
|
|
786
|
+
const keep = new Set(ctx.opts.include);
|
|
787
|
+
for (const filter of [ctx.rootFilter, ...ctx.rootLevels]) {
|
|
788
|
+
for (const path of Object.keys(filter.properties)) {
|
|
789
|
+
if (!path.startsWith("$") && !keep.has(path)) delete filter.properties[path];
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
for (const path of ctx.opts.include) {
|
|
793
|
+
if (!(path in ctx.rootFilter.properties)) ctx.warn(`--include names "${path}", which was not found.`);
|
|
794
|
+
}
|
|
795
|
+
ctx.rootFields = ctx.rootFields.filter((f) => keep.has(f.path));
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function buildCapabilities(ctx, options) {
|
|
799
|
+
const fields = {};
|
|
800
|
+
for (const field of ctx.rootFields ?? []) {
|
|
801
|
+
const entry = { operators: field.operators };
|
|
802
|
+
const types = field.info.types;
|
|
803
|
+
if (types.length === 1) entry.type = types[0];
|
|
804
|
+
else if (types.length > 1) entry.type = types;
|
|
805
|
+
if (field.schema.format) entry.format = field.schema.format;
|
|
806
|
+
const values = domainValues(field.schema);
|
|
807
|
+
if (values) entry.values = values;
|
|
808
|
+
if (field.kind === "array" && field.items) {
|
|
809
|
+
const itemValues = domainValues(field.items);
|
|
810
|
+
if (itemValues) entry.itemValues = itemValues;
|
|
811
|
+
}
|
|
812
|
+
if (field.schema.description) entry.description = field.schema.description;
|
|
813
|
+
if (field.info.nullable) entry.nullable = true;
|
|
814
|
+
fields[field.path] = entry;
|
|
815
|
+
}
|
|
816
|
+
return {
|
|
817
|
+
queryLanguage: ctx.grammar.$id,
|
|
818
|
+
...(options.id ? { filterSchema: options.id } : {}),
|
|
819
|
+
profiles: ctx.opts.complete,
|
|
820
|
+
fields,
|
|
821
|
+
limits: ctx.opts.limits,
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* The operator set the generated schema will offer: profiles first, because
|
|
827
|
+
* they are the coarse unit a server advertises, then the operator-level refinement
|
|
828
|
+
* for the cases a profile boundary does not fit — a backend with LIKE but no
|
|
829
|
+
* POSITION supports $like and not $contains, and a key-value store cannot
|
|
830
|
+
* implement $exists at all.
|
|
831
|
+
*
|
|
832
|
+
* Narrowing the set is always safe: the emitted schema accepts fewer filters
|
|
833
|
+
* than the grammar, never more. What it costs is the profile *claim*, which is
|
|
834
|
+
* why the demotion is warned about here and reflected in the capability
|
|
835
|
+
* document rather than silently kept (SPEC §2.1).
|
|
836
|
+
*/
|
|
837
|
+
function selectOperators(grammar, profiles, options, warn) {
|
|
838
|
+
const byProfile = grammar["x-profiles"];
|
|
839
|
+
const defined = new Set(Object.values(byProfile).flat());
|
|
840
|
+
const profileOf = (op) => Object.keys(byProfile).find((p) => byProfile[p].includes(op));
|
|
841
|
+
|
|
842
|
+
const check = (names, flag) => {
|
|
843
|
+
for (const op of names) {
|
|
844
|
+
if (!defined.has(op)) {
|
|
845
|
+
throw new Error(`unknown operator "${op}" in ${flag}; the grammar defines ${[...defined].join(", ")}`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
check(options.operators ?? [], "--operators");
|
|
850
|
+
check(options.dropOperators ?? [], "--drop-operators");
|
|
851
|
+
|
|
852
|
+
let enabled = new Set(profiles.flatMap((p) => byProfile[p]));
|
|
853
|
+
|
|
854
|
+
if (options.operators) {
|
|
855
|
+
for (const op of options.operators) {
|
|
856
|
+
if (!enabled.has(op)) {
|
|
857
|
+
warn(`--operators names "${op}", which the "${profileOf(op)}" profile supplies; add it to --profiles for the operator to take effect.`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
enabled = new Set(options.operators.filter((op) => enabled.has(op)));
|
|
861
|
+
}
|
|
862
|
+
for (const op of options.dropOperators ?? []) enabled.delete(op);
|
|
863
|
+
|
|
864
|
+
// A kept operator whose dependency was dropped would be emitted with a
|
|
865
|
+
// `dependentRequired` rule naming a member `additionalProperties: false`
|
|
866
|
+
// forbids — present in the schema and impossible to use. The rule is read off
|
|
867
|
+
// the grammar (today: $flags needs $regex) so a dependency added later is
|
|
868
|
+
// handled by construction rather than by a second list here.
|
|
869
|
+
for (const [trigger, requires] of Object.entries(grammar.$defs.ConstraintObject.dependentRequired ?? {})) {
|
|
870
|
+
if (!enabled.has(trigger)) continue;
|
|
871
|
+
const missing = requires.filter((op) => !enabled.has(op));
|
|
872
|
+
if (missing.length === 0) continue;
|
|
873
|
+
enabled.delete(trigger);
|
|
874
|
+
warn(`"${trigger}" requires ${missing.join(", ")} beside it, so it is excluded too.`);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (enabled.size === 0) throw new Error("the selection leaves no operators enabled");
|
|
878
|
+
|
|
879
|
+
// Which profiles survive as *claims*. SPEC §2.1: a profile other than core is
|
|
880
|
+
// implemented in full or not at all, so a profile missing an operator is no
|
|
881
|
+
// longer on offer — the per-field `operators` lists carry what is.
|
|
882
|
+
const requested = new Set(profiles.flatMap((p) => byProfile[p]));
|
|
883
|
+
const excluded = [...requested].filter((op) => !enabled.has(op));
|
|
884
|
+
const complete = profiles.filter((p) => byProfile[p].every((op) => enabled.has(op)));
|
|
885
|
+
for (const p of profiles) {
|
|
886
|
+
if (complete.includes(p)) continue;
|
|
887
|
+
const missing = byProfile[p].filter((op) => !enabled.has(op));
|
|
888
|
+
warn(
|
|
889
|
+
p === "core"
|
|
890
|
+
? `the "core" profile is incomplete (${missing.join(", ")} excluded). SPEC §2.1 requires core in full, so this endpoint is not a conforming implementation; the capability document will not claim core.`
|
|
891
|
+
: `profile "${p}" is not offered in full (${missing.join(", ")} excluded), so it is omitted from the capability document's profiles (SPEC §2.1).`,
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
return { enabled, complete, excluded };
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* SPEC §7's limits are a claim published through the capability document, not
|
|
900
|
+
* something the schema enforces — except `maxDepth`, which `--max-filter-depth`
|
|
901
|
+
* does enforce. Where both are given they should agree, so a disagreement is
|
|
902
|
+
* worth saying out loud.
|
|
903
|
+
*/
|
|
904
|
+
function resolveLimits(options, warn) {
|
|
905
|
+
const given = options.limits ?? {};
|
|
906
|
+
for (const [key, value] of Object.entries(given)) {
|
|
907
|
+
if (!(key in DEFAULT_LIMITS)) {
|
|
908
|
+
throw new Error(`unknown limit "${key}"; --limits takes ${Object.keys(DEFAULT_LIMITS).join(", ")}`);
|
|
909
|
+
}
|
|
910
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
911
|
+
throw new Error(`limit "${key}" must be a positive integer, got ${JSON.stringify(value)}`);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
const limits = { ...DEFAULT_LIMITS, ...given };
|
|
915
|
+
if (options.maxFilterDepth !== undefined) {
|
|
916
|
+
if (given.maxDepth === undefined) limits.maxDepth = options.maxFilterDepth;
|
|
917
|
+
else if (given.maxDepth !== options.maxFilterDepth) {
|
|
918
|
+
warn(`--limits maxDepth is ${given.maxDepth} but the schema refuses logical nesting past ${options.maxFilterDepth}; the published limit is the looser claim.`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
return limits;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function prepare(resource, options) {
|
|
925
|
+
const grammar = options.grammar ?? JSON.parse(readFileSync(options.grammarPath ?? DEFAULT_GRAMMAR, "utf8"));
|
|
926
|
+
const profiles = options.profiles ?? DEFAULT_PROFILES;
|
|
927
|
+
|
|
928
|
+
const modes = ["all", "brief", "none"];
|
|
929
|
+
if (options.descriptions && !modes.includes(options.descriptions)) {
|
|
930
|
+
throw new Error(`--descriptions must be one of ${modes.join(", ")}`);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const known = Object.keys(grammar["x-profiles"]);
|
|
934
|
+
for (const p of profiles) {
|
|
935
|
+
if (!known.includes(p)) throw new Error(`unknown profile "${p}"; the grammar defines ${known.join(", ")}`);
|
|
936
|
+
}
|
|
937
|
+
if (!profiles.includes("core")) throw new Error(`the "core" profile is mandatory (SPEC §2.1)`);
|
|
938
|
+
|
|
939
|
+
if (options.maxFilterDepth !== undefined
|
|
940
|
+
&& (!Number.isInteger(options.maxFilterDepth) || options.maxFilterDepth < 1)) {
|
|
941
|
+
throw new Error(`--max-filter-depth must be an integer of 1 or more, got ${JSON.stringify(options.maxFilterDepth)}`);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const warnings = [];
|
|
945
|
+
const warn = (m) => warnings.push(m);
|
|
946
|
+
const { enabled, complete, excluded } = selectOperators(grammar, profiles, options, warn);
|
|
947
|
+
const limits = resolveLimits(options, warn);
|
|
948
|
+
|
|
949
|
+
const root = resource;
|
|
950
|
+
const deref = makeDeref(root);
|
|
951
|
+
const entry = options.pointer ? jsonPointer(root, options.pointer) : root;
|
|
952
|
+
if (entry === undefined) throw new Error(`--pointer "${options.pointer}" does not resolve`);
|
|
953
|
+
const resolved = flatten(entry, deref, new Set());
|
|
954
|
+
|
|
955
|
+
return {
|
|
956
|
+
grammar,
|
|
957
|
+
deref,
|
|
958
|
+
enabled,
|
|
959
|
+
defs: {},
|
|
960
|
+
reserved: new Set(["Size"]),
|
|
961
|
+
warnings,
|
|
962
|
+
warn,
|
|
963
|
+
resource: resolved,
|
|
964
|
+
opts: {
|
|
965
|
+
profiles,
|
|
966
|
+
complete,
|
|
967
|
+
excluded,
|
|
968
|
+
limits,
|
|
969
|
+
maxDepth: options.maxDepth ?? 3,
|
|
970
|
+
maxFilterDepth: options.maxFilterDepth,
|
|
971
|
+
shorthand: options.shorthand !== false,
|
|
972
|
+
descriptions: options.descriptions ?? "brief",
|
|
973
|
+
include: options.include ?? null,
|
|
974
|
+
exclude: (options.exclude ?? []).map(globToRegExp),
|
|
975
|
+
},
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// ---------------------------------------------------------------------------
|
|
980
|
+
// CLI
|
|
981
|
+
// ---------------------------------------------------------------------------
|
|
982
|
+
|
|
983
|
+
const FLAGS = {
|
|
984
|
+
id: { type: "string" },
|
|
985
|
+
title: { type: "string" },
|
|
986
|
+
profiles: { type: "string" },
|
|
987
|
+
operators: { type: "string" },
|
|
988
|
+
"drop-operators": { type: "string" },
|
|
989
|
+
"no-shorthand": { type: "boolean" },
|
|
990
|
+
"max-filter-depth": { type: "string" },
|
|
991
|
+
limits: { type: "string" },
|
|
992
|
+
pointer: { type: "string" },
|
|
993
|
+
"max-depth": { type: "string" },
|
|
994
|
+
include: { type: "string" },
|
|
995
|
+
exclude: { type: "string" },
|
|
996
|
+
capabilities: { type: "string" },
|
|
997
|
+
descriptions: { type: "string" },
|
|
998
|
+
grammar: { type: "string" },
|
|
999
|
+
out: { type: "string" },
|
|
1000
|
+
config: { type: "string" },
|
|
1001
|
+
quiet: { type: "boolean" },
|
|
1002
|
+
help: { type: "boolean", short: "h" },
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
/** Config keys that name a file, and so resolve against the config's own directory. */
|
|
1006
|
+
const CONFIG_PATHS = new Set(["resource", "out", "capabilities", "grammar"]);
|
|
1007
|
+
|
|
1008
|
+
/** The config file's vocabulary: the JS API's option names, plus where things go. */
|
|
1009
|
+
const CONFIG_KEYS = [
|
|
1010
|
+
"resource", "id", "title", "profiles", "operators", "dropOperators", "shorthand",
|
|
1011
|
+
"maxFilterDepth", "limits", "pointer", "maxDepth", "include", "exclude",
|
|
1012
|
+
"descriptions", "out", "capabilities", "grammar", "quiet",
|
|
1013
|
+
];
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Merges a config file with the command line into one options object.
|
|
1017
|
+
*
|
|
1018
|
+
* The config file is the artifact a provider checks in beside their resource
|
|
1019
|
+
* schema and regenerates from, so the capability selection lives in version
|
|
1020
|
+
* control rather than in whoever's shell history. A flag given explicitly wins
|
|
1021
|
+
* over it, which is what makes a one-off `--include` on top of a checked-in
|
|
1022
|
+
* config work.
|
|
1023
|
+
*
|
|
1024
|
+
* Exported so the merge can be tested without spawning a process.
|
|
1025
|
+
*/
|
|
1026
|
+
export function resolveOptions(values, positionals, cwd = process.cwd()) {
|
|
1027
|
+
if (positionals.length > 1) {
|
|
1028
|
+
throw new Error(`expected one resource schema, got ${positionals.length}: ${positionals.join(", ")}`);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
let config = {};
|
|
1032
|
+
let base = cwd;
|
|
1033
|
+
if (values.config !== undefined) {
|
|
1034
|
+
const file = resolve(cwd, values.config);
|
|
1035
|
+
base = dirname(file);
|
|
1036
|
+
config = JSON.parse(readFileSync(file, "utf8"));
|
|
1037
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
1038
|
+
throw new Error(`${values.config} must contain a JSON object`);
|
|
1039
|
+
}
|
|
1040
|
+
// Refused rather than ignored, for the same reason parseArgs refuses an
|
|
1041
|
+
// unknown flag: a misspelled key is a selection that silently did not apply.
|
|
1042
|
+
const unknown = Object.keys(config).filter((k) => !CONFIG_KEYS.includes(k));
|
|
1043
|
+
if (unknown.length > 0) {
|
|
1044
|
+
throw new Error(
|
|
1045
|
+
`unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")} in ` +
|
|
1046
|
+
`${values.config}; it takes ${CONFIG_KEYS.join(", ")}`,
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Paths in a config file are relative to the config file. Paths on the command
|
|
1052
|
+
// line are relative to the cwd, and are left as written so that a generated
|
|
1053
|
+
// $comment names the schema the way the reader would.
|
|
1054
|
+
const configValue = (key) => (CONFIG_PATHS.has(key) ? resolve(base, config[key]) : config[key]);
|
|
1055
|
+
const pick = (flag, key) =>
|
|
1056
|
+
values[flag] !== undefined ? values[flag]
|
|
1057
|
+
: config[key] !== undefined ? configValue(key)
|
|
1058
|
+
: undefined;
|
|
1059
|
+
|
|
1060
|
+
const list = (v) => {
|
|
1061
|
+
if (typeof v !== "string") return v;
|
|
1062
|
+
const items = v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1063
|
+
return items.length > 0 ? items : undefined;
|
|
1064
|
+
};
|
|
1065
|
+
const integer = (v, flag, min) => {
|
|
1066
|
+
if (v === undefined) return undefined;
|
|
1067
|
+
const n = Number(v);
|
|
1068
|
+
if (!Number.isInteger(n) || n < min) {
|
|
1069
|
+
throw new Error(`${flag} must be an integer of ${min} or more, got ${JSON.stringify(v)}`);
|
|
1070
|
+
}
|
|
1071
|
+
return n;
|
|
1072
|
+
};
|
|
1073
|
+
const limits = () => {
|
|
1074
|
+
const raw = values.limits;
|
|
1075
|
+
if (raw === undefined) return config.limits;
|
|
1076
|
+
const text = raw.startsWith("@") ? readFileSync(resolve(cwd, raw.slice(1)), "utf8") : raw;
|
|
1077
|
+
try {
|
|
1078
|
+
return JSON.parse(text);
|
|
1079
|
+
} catch (error) {
|
|
1080
|
+
throw new Error(`--limits is not valid JSON: ${error.message}`);
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
const source = positionals[0] ?? (config.resource !== undefined ? resolve(base, config.resource) : undefined);
|
|
1085
|
+
|
|
1086
|
+
return {
|
|
1087
|
+
source,
|
|
1088
|
+
out: pick("out", "out"),
|
|
1089
|
+
capabilities: pick("capabilities", "capabilities"),
|
|
1090
|
+
quiet: values.quiet ?? config.quiet ?? false,
|
|
1091
|
+
options: {
|
|
1092
|
+
id: pick("id", "id"),
|
|
1093
|
+
title: pick("title", "title"),
|
|
1094
|
+
source,
|
|
1095
|
+
profiles: list(pick("profiles", "profiles")),
|
|
1096
|
+
operators: list(pick("operators", "operators")),
|
|
1097
|
+
dropOperators: list(pick("drop-operators", "dropOperators")),
|
|
1098
|
+
shorthand: values["no-shorthand"] ? false : config.shorthand,
|
|
1099
|
+
maxFilterDepth: integer(pick("max-filter-depth", "maxFilterDepth"), "--max-filter-depth", 1),
|
|
1100
|
+
limits: limits(),
|
|
1101
|
+
pointer: pick("pointer", "pointer"),
|
|
1102
|
+
maxDepth: integer(pick("max-depth", "maxDepth"), "--max-depth", 0),
|
|
1103
|
+
descriptions: pick("descriptions", "descriptions"),
|
|
1104
|
+
include: list(pick("include", "include")),
|
|
1105
|
+
exclude: list(pick("exclude", "exclude")),
|
|
1106
|
+
grammarPath: pick("grammar", "grammar"),
|
|
1107
|
+
},
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function usage() {
|
|
1112
|
+
// The header comment is the help text; keeping one copy avoids the usual drift.
|
|
1113
|
+
return readFileSync(fileURLToPath(import.meta.url), "utf8")
|
|
1114
|
+
.split("*/")[0]
|
|
1115
|
+
.replace(/^#!.*\n/, "")
|
|
1116
|
+
.replace(/^\/\*\*?\n?/, "")
|
|
1117
|
+
.replace(/^ \*\/?/gm, "")
|
|
1118
|
+
.replace(/^ /gm, "")
|
|
1119
|
+
.trimEnd();
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function main(argv) {
|
|
1123
|
+
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: FLAGS });
|
|
1124
|
+
|
|
1125
|
+
if (values.help) {
|
|
1126
|
+
process.stdout.write(`${usage()}\n`);
|
|
1127
|
+
process.exit(0);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const resolved = resolveOptions(values, positionals);
|
|
1131
|
+
if (!resolved.source) {
|
|
1132
|
+
process.stdout.write(`${usage()}\n`);
|
|
1133
|
+
process.exit(1);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const resource = JSON.parse(readFileSync(resolved.source, "utf8"));
|
|
1137
|
+
const { schema, capabilities, warnings } = generateFilterSchema(resource, resolved.options);
|
|
1138
|
+
|
|
1139
|
+
const json = `${JSON.stringify(schema, null, 2)}\n`;
|
|
1140
|
+
if (resolved.out) writeFileSync(resolved.out, json);
|
|
1141
|
+
else process.stdout.write(json);
|
|
1142
|
+
|
|
1143
|
+
if (resolved.capabilities) {
|
|
1144
|
+
writeFileSync(resolved.capabilities, `${JSON.stringify(capabilities, null, 2)}\n`);
|
|
1145
|
+
}
|
|
1146
|
+
if (!resolved.quiet) {
|
|
1147
|
+
for (const w of warnings) process.stderr.write(`warning: ${w}\n`);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
1152
|
+
main(process.argv.slice(2));
|
|
1153
|
+
}
|