@capacms/mcp 0.2.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/README.md +881 -0
- package/bin/capa-mcp.mjs +96 -0
- package/lib/annotations.mjs +27 -0
- package/lib/answers.mjs +69 -0
- package/lib/arguments.mjs +140 -0
- package/lib/bound.mjs +546 -0
- package/lib/client.mjs +512 -0
- package/lib/error-guide.mjs +750 -0
- package/lib/explore.mjs +471 -0
- package/lib/graphql/build.mjs +725 -0
- package/lib/graphql/document.mjs +388 -0
- package/lib/graphql/filter-values.mjs +92 -0
- package/lib/graphql/more.mjs +97 -0
- package/lib/graphql/names.mjs +131 -0
- package/lib/graphql/schema.mjs +237 -0
- package/lib/graphql/sdl.mjs +144 -0
- package/lib/graphql/served.mjs +82 -0
- package/lib/graphql-tools.mjs +1177 -0
- package/lib/guide.mjs +55 -0
- package/lib/instructions.mjs +32 -0
- package/lib/prompts.mjs +68 -0
- package/lib/registry.mjs +235 -0
- package/lib/resources.mjs +134 -0
- package/lib/rest-tools.mjs +176 -0
- package/lib/server.mjs +194 -0
- package/lib/session.mjs +90 -0
- package/lib/suggest.mjs +32 -0
- package/lib/tools.mjs +1158 -0
- package/package.json +24 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* names.mjs — how REST's `select`, `sort` and `where` write a field
|
|
3
|
+
* namespace, and how they read one back (spec 17, amendment 121).
|
|
4
|
+
*
|
|
5
|
+
* A namespace is whatever the admin saved, so any character can be in one:
|
|
6
|
+
* `am/pm_indicator`, `price.usd`, `a,b`. All three grammars write it the same
|
|
7
|
+
* way:
|
|
8
|
+
* - BARE when none of its characters means something in any of them and it
|
|
9
|
+
* starts with neither `$` (a system key) nor `-` (a descending sort):
|
|
10
|
+
* `title`, `open-time`, `am/pm_indicator`.
|
|
11
|
+
* - QUOTED otherwise, a quote inside doubled: `"price.usd"`, `"a,b"`,
|
|
12
|
+
* `"say ""hi"""`. A quoted name is always a field, never a system key, a
|
|
13
|
+
* relation hop or a modifier.
|
|
14
|
+
*
|
|
15
|
+
* A namespace that starts with `$` cannot be named at all, since `$tags` is
|
|
16
|
+
* the system key; `select=*` still returns it.
|
|
17
|
+
*
|
|
18
|
+
* The API's writer is `writeName` in `@capa/shared`, and `@capacms/sdk` has
|
|
19
|
+
* the same rule in src/next/field-names.ts. This package has no
|
|
20
|
+
* dependencies, so it keeps its own copy, and all three are tested against
|
|
21
|
+
* packages/sdk/test/fixtures/field-names.json.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const NAME_QUOTE = '"';
|
|
25
|
+
|
|
26
|
+
/** The sigil REST writes a system key with where a field shadows it (`$tags`). */
|
|
27
|
+
const SYSTEM_KEY_SIGIL = "$";
|
|
28
|
+
|
|
29
|
+
/** Characters that mean something in `select`, `sort` or a filter path. */
|
|
30
|
+
const MEANINGFUL = new Set([",", "(", ")", ":", ".", NAME_QUOTE, "*", "[", "]"]);
|
|
31
|
+
|
|
32
|
+
/** Whether a character can stand in a bare name. */
|
|
33
|
+
const isNameChar = (ch) => !MEANINGFUL.has(ch) && !/\s/.test(ch);
|
|
34
|
+
|
|
35
|
+
/** Whether REST can name a field namespace at all: every one but an empty one or one starting with `$`. */
|
|
36
|
+
export const isNameable = (namespace) => namespace !== "" && !namespace.startsWith(SYSTEM_KEY_SIGIL);
|
|
37
|
+
|
|
38
|
+
/** A namespace as every REST grammar writes it: bare where it can be, quoted otherwise. */
|
|
39
|
+
export function writeName(namespace) {
|
|
40
|
+
const bare = isNameable(namespace) && !namespace.startsWith("-") && [...namespace].every(isNameChar);
|
|
41
|
+
if (bare) return namespace;
|
|
42
|
+
return `${NAME_QUOTE}${namespace.split(NAME_QUOTE).join(NAME_QUOTE + NAME_QUOTE)}${NAME_QUOTE}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One segment of a sort or filter path: a system key (`$id`) keeps its sigil, a namespace is written by `writeName`. */
|
|
46
|
+
export const writePathSegment = (segment) => (segment.startsWith(SYSTEM_KEY_SIGIL) ? segment : writeName(segment));
|
|
47
|
+
|
|
48
|
+
/** A path (`["at.place", "zip.code"]`) as sort and where write it: `"at.place"."zip.code"`. */
|
|
49
|
+
export const writePath = (path) => path.map(writePathSegment).join(".");
|
|
50
|
+
|
|
51
|
+
/** The quoted name opening at `text[pos]`, and the index just past its closing quote; null when it never closes. */
|
|
52
|
+
export function readQuoted(text, pos) {
|
|
53
|
+
let name = "";
|
|
54
|
+
let i = pos + 1;
|
|
55
|
+
while (i < text.length) {
|
|
56
|
+
if (text[i] === NAME_QUOTE) {
|
|
57
|
+
if (text[i + 1] !== NAME_QUOTE) return { name, end: i + 1 };
|
|
58
|
+
name += NAME_QUOTE;
|
|
59
|
+
i += 2;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
name += text[i];
|
|
63
|
+
i += 1;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A name as written, read back: `"price.usd"` is `price.usd`, a bare name is itself. Null for a quote that does not close the name. */
|
|
69
|
+
export function readName(text) {
|
|
70
|
+
if (!text.startsWith(NAME_QUOTE)) return text;
|
|
71
|
+
const quoted = readQuoted(text, 0);
|
|
72
|
+
return quoted && quoted.end === text.length ? quoted.name : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `text` split on `separator` wherever it stands outside a quoted name and,
|
|
77
|
+
* unless `nested` is false, outside parentheses: a select level is split on
|
|
78
|
+
* its commas outside both, a list of names on its commas outside quotes.
|
|
79
|
+
* Null when a quote never closes.
|
|
80
|
+
*/
|
|
81
|
+
export function splitOutside(text, separator, { nested = true } = {}) {
|
|
82
|
+
const parts = [];
|
|
83
|
+
let depth = 0;
|
|
84
|
+
let start = 0;
|
|
85
|
+
let i = 0;
|
|
86
|
+
while (i < text.length) {
|
|
87
|
+
const ch = text[i];
|
|
88
|
+
if (ch === NAME_QUOTE) {
|
|
89
|
+
const quoted = readQuoted(text, i);
|
|
90
|
+
if (!quoted) return null;
|
|
91
|
+
i = quoted.end;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (nested && ch === "(") depth += 1;
|
|
95
|
+
else if (nested && ch === ")") depth -= 1;
|
|
96
|
+
else if (ch === separator && depth === 0) {
|
|
97
|
+
parts.push(text.slice(start, i));
|
|
98
|
+
start = i + 1;
|
|
99
|
+
}
|
|
100
|
+
i += 1;
|
|
101
|
+
}
|
|
102
|
+
parts.push(text.slice(start));
|
|
103
|
+
return parts;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A sort or filter path's segments, split on the dots outside quotes, each
|
|
108
|
+
* read back as `{ name, quoted }` (`"at.place"."zip.code"` is `at.place` then
|
|
109
|
+
* `zip.code`); a quoted segment is always a field. A bare segment is
|
|
110
|
+
* everything up to the next dot, as the API reads it. Null when a quote never
|
|
111
|
+
* closes or runs into something other than a dot.
|
|
112
|
+
*/
|
|
113
|
+
export function readPath(text) {
|
|
114
|
+
const segments = [];
|
|
115
|
+
let i = 0;
|
|
116
|
+
for (;;) {
|
|
117
|
+
if (text[i] === NAME_QUOTE) {
|
|
118
|
+
const quoted = readQuoted(text, i);
|
|
119
|
+
if (!quoted || (quoted.end < text.length && text[quoted.end] !== ".")) return null;
|
|
120
|
+
segments.push({ name: quoted.name, quoted: true });
|
|
121
|
+
i = quoted.end;
|
|
122
|
+
} else {
|
|
123
|
+
const dot = text.indexOf(".", i);
|
|
124
|
+
const end = dot === -1 ? text.length : dot;
|
|
125
|
+
segments.push({ name: text.slice(i, end), quoted: false });
|
|
126
|
+
i = end;
|
|
127
|
+
}
|
|
128
|
+
if (i >= text.length) return segments;
|
|
129
|
+
i += 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schema.mjs — the key's GraphQL schema: fetched once, cached, compacted.
|
|
3
|
+
*
|
|
4
|
+
* One POST of the introspection query (with the `version` root field beside
|
|
5
|
+
* it) answers every schema question the tools ask. It is cached per process
|
|
6
|
+
* for 60 seconds per API version: long enough that an agent's burst of calls
|
|
7
|
+
* costs one request, short enough that a model edited in the admin shows up
|
|
8
|
+
* within a minute.
|
|
9
|
+
*
|
|
10
|
+
* THE SAME COMPACTION AS `@capacms/sdk`'s `summarizeIntrospection`, written
|
|
11
|
+
* again here because this package has no dependencies. Both are tested
|
|
12
|
+
* against the same fixture and vector file (`packages/sdk/test/fixtures/`), so
|
|
13
|
+
* a change to one that the other does not share fails a test. Names are READ
|
|
14
|
+
* from introspection and never derived: the API owns the naming rules.
|
|
15
|
+
*/
|
|
16
|
+
import { apiNextPost } from "../client.mjs";
|
|
17
|
+
import { readName, splitOutside } from "./names.mjs";
|
|
18
|
+
import { whenNotServed } from "./served.mjs";
|
|
19
|
+
|
|
20
|
+
export const INTROSPECTION_QUERY = `query CapaIntrospection {
|
|
21
|
+
version
|
|
22
|
+
__schema {
|
|
23
|
+
queryType { name }
|
|
24
|
+
types { ...FullType }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
fragment FullType on __Type {
|
|
29
|
+
kind
|
|
30
|
+
name
|
|
31
|
+
description
|
|
32
|
+
fields(includeDeprecated: true) {
|
|
33
|
+
name
|
|
34
|
+
description
|
|
35
|
+
args(includeDeprecated: true) { ...InputValue }
|
|
36
|
+
type { ...TypeRef }
|
|
37
|
+
isDeprecated
|
|
38
|
+
deprecationReason
|
|
39
|
+
}
|
|
40
|
+
inputFields(includeDeprecated: true) { ...InputValue }
|
|
41
|
+
interfaces { ...TypeRef }
|
|
42
|
+
enumValues(includeDeprecated: true) { name description isDeprecated deprecationReason }
|
|
43
|
+
possibleTypes { ...TypeRef }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fragment InputValue on __InputValue {
|
|
47
|
+
name
|
|
48
|
+
description
|
|
49
|
+
type { ...TypeRef }
|
|
50
|
+
defaultValue
|
|
51
|
+
isDeprecated
|
|
52
|
+
deprecationReason
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fragment TypeRef on __Type {
|
|
56
|
+
kind
|
|
57
|
+
name
|
|
58
|
+
ofType { kind name ofType { kind name ofType { kind name ofType { kind name } } } }
|
|
59
|
+
}`;
|
|
60
|
+
|
|
61
|
+
export const SCHEMA_TTL_MS = 60_000;
|
|
62
|
+
|
|
63
|
+
const cache = new Map();
|
|
64
|
+
|
|
65
|
+
export function __resetGraphQLSchemaCacheForTests() {
|
|
66
|
+
cache.clear();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The named type under any NON_NULL and LIST wrappers. */
|
|
70
|
+
export function namedType(ref) {
|
|
71
|
+
let current = ref;
|
|
72
|
+
while (current && current.name == null) current = current.ofType;
|
|
73
|
+
return current?.name ?? "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The type as SDL writes it: `[Authors!]!`. */
|
|
77
|
+
export function printTypeRef(ref) {
|
|
78
|
+
if (ref.kind === "NON_NULL" && ref.ofType) return `${printTypeRef(ref.ofType)}!`;
|
|
79
|
+
if (ref.kind === "LIST" && ref.ofType) return `[${printTypeRef(ref.ofType)}]`;
|
|
80
|
+
return ref.name ?? "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isListType(ref) {
|
|
84
|
+
for (let current = ref; current; current = current.ofType) if (current.kind === "LIST") return true;
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const MODEL_DESCRIPTION = /\(model ([^()\s]+)\)$/;
|
|
89
|
+
/**
|
|
90
|
+
* N9's field tag, on the description's last line. The lines above it are the
|
|
91
|
+
* field's label and an enum's options, for people (spec 17, amendment 49).
|
|
92
|
+
* The namespace is written as REST writes it, quoted when it holds a
|
|
93
|
+
* character REST's grammars use (`"price.usd"`, amendment 121), so it is read
|
|
94
|
+
* back before use.
|
|
95
|
+
*/
|
|
96
|
+
const FIELD_DESCRIPTION = /(?:^|\n)Capa field ("(?:[^"]|"")*"|[^,\s]+), type (\S+)(?: of (\S+))?(?:, model ([^,\s]+))?$/;
|
|
97
|
+
/** N9: the second line of a model type's description, when N5 left fields out. */
|
|
98
|
+
const NOT_EXPOSED = /^Not exposed: (.+)$/m;
|
|
99
|
+
/** N9: the Query description's list of the models N1 leaves out of GraphQL. */
|
|
100
|
+
const MODELS_NOT_EXPOSED = /Models not exposed in GraphQL: ([^\n]+?)\.?$/m;
|
|
101
|
+
|
|
102
|
+
/** The namespaces of an N9 list line, each bare or quoted as REST writes it. */
|
|
103
|
+
function namespaceList(line) {
|
|
104
|
+
if (!line) return [];
|
|
105
|
+
const items = splitOutside(line[1], ",", { nested: false }) ?? line[1].split(",");
|
|
106
|
+
return items.map((item) => item.trim()).filter(Boolean).map((item) => readName(item) ?? item);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The namespaces a model type's description lists as not exposed. */
|
|
110
|
+
const notExposedFields = (description) => namespaceList(NOT_EXPOSED.exec(description ?? ""));
|
|
111
|
+
|
|
112
|
+
/** N9's field tag read: its namespace unquoted, its types and target; null when the field has no tag. */
|
|
113
|
+
function parseFieldDescription(description) {
|
|
114
|
+
const match = FIELD_DESCRIPTION.exec(description ?? "");
|
|
115
|
+
const namespace = match ? readName(match[1]) : null;
|
|
116
|
+
if (!match || namespace === null) return null;
|
|
117
|
+
return { namespace, capaType: match[2], arrayType: match[3] ?? null, target: match[4] ?? null };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function kindOf(field, types, modelTypes) {
|
|
121
|
+
const named = namedType(field.type);
|
|
122
|
+
const list = isListType(field.type);
|
|
123
|
+
if (named === "ID") return list ? "idList" : "id";
|
|
124
|
+
if (named === "Media") return "media";
|
|
125
|
+
if (named === "JSON") return "json";
|
|
126
|
+
if (modelTypes.has(named)) return "relation";
|
|
127
|
+
if (named.endsWith("RelationConnection") && types.get(named)?.kind === "OBJECT") return "relationList";
|
|
128
|
+
return "scalar";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Why a field is deprecated, or null when it is not (GraphQL's default reason when none was given). */
|
|
132
|
+
export function deprecationOf(field) {
|
|
133
|
+
return field.isDeprecated ? field.deprecationReason ?? "No longer supported" : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The keys of a `<Type>Filter` that combine conditions rather than name a field. */
|
|
137
|
+
const FILTER_LOGIC = new Set(["and", "or", "not"]);
|
|
138
|
+
|
|
139
|
+
/** Compact a `CapaIntrospection` answer: see GraphQLSchemaSummary in @capacms/sdk. */
|
|
140
|
+
export function summarizeIntrospection(introspection) {
|
|
141
|
+
const types = new Map(introspection.__schema.types.map((type) => [type.name, type]));
|
|
142
|
+
const roots = types.get(introspection.__schema.queryType.name)?.fields ?? [];
|
|
143
|
+
const namespaceByType = new Map();
|
|
144
|
+
for (const type of introspection.__schema.types) {
|
|
145
|
+
if (type.kind !== "OBJECT") continue;
|
|
146
|
+
const namespace = MODEL_DESCRIPTION.exec((type.description ?? "").split("\n")[0])?.[1];
|
|
147
|
+
if (namespace) namespaceByType.set(type.name, namespace);
|
|
148
|
+
}
|
|
149
|
+
const modelTypes = new Set(namespaceByType.keys());
|
|
150
|
+
|
|
151
|
+
const models = [];
|
|
152
|
+
for (const [typeName, namespace] of namespaceByType) {
|
|
153
|
+
const list = roots.find((r) => namedType(r.type) === `${typeName}Connection` && r.args.some((a) => a.name === "first"));
|
|
154
|
+
const single = roots.find((r) => namedType(r.type) === typeName && r.args.some((a) => a.name === "id"));
|
|
155
|
+
if (!list || !single) continue;
|
|
156
|
+
const argType = (name) => namedType(list.args.find((a) => a.name === name)?.type ?? {});
|
|
157
|
+
const filterType = argType("filter");
|
|
158
|
+
const sortType = argType("sort");
|
|
159
|
+
const sortValues = (types.get(sortType)?.enumValues ?? []).map((v) => v.name);
|
|
160
|
+
const filterFields = new Map((types.get(filterType)?.inputFields ?? []).map((input) => [input.name, input]));
|
|
161
|
+
const operandsOf = (name) => {
|
|
162
|
+
const input = filterFields.get(name);
|
|
163
|
+
const operands = input ? (types.get(namedType(input.type))?.inputFields ?? []) : [];
|
|
164
|
+
return {
|
|
165
|
+
filterOps: operands.map((operand) => operand.name),
|
|
166
|
+
filterInputs: Object.fromEntries(operands.map((operand) => [operand.name, namedType(operand.type)])),
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
const type = types.get(typeName);
|
|
170
|
+
const fields = [];
|
|
171
|
+
for (const field of type.fields ?? []) {
|
|
172
|
+
const parsed = parseFieldDescription(field.description);
|
|
173
|
+
if (!parsed) continue;
|
|
174
|
+
fields.push({
|
|
175
|
+
name: field.name,
|
|
176
|
+
namespace: parsed.namespace,
|
|
177
|
+
capaType: parsed.capaType,
|
|
178
|
+
arrayType: parsed.arrayType,
|
|
179
|
+
graphqlType: printTypeRef(field.type),
|
|
180
|
+
kind: kindOf(field, types, modelTypes),
|
|
181
|
+
target: parsed.target,
|
|
182
|
+
sortable: sortValues.includes(`${field.name}_ASC`),
|
|
183
|
+
...operandsOf(field.name),
|
|
184
|
+
deprecationReason: deprecationOf(field),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
const fieldNames = new Set(fields.map((field) => field.name));
|
|
188
|
+
models.push({
|
|
189
|
+
namespace,
|
|
190
|
+
typeName,
|
|
191
|
+
description: type.description ?? "",
|
|
192
|
+
listField: list.name,
|
|
193
|
+
singleField: single.name,
|
|
194
|
+
filterType,
|
|
195
|
+
// The system fields the filter takes, as it declares them (spec 17, amendment 56 adds _tags).
|
|
196
|
+
systemFilters: [...filterFields.keys()]
|
|
197
|
+
.filter((name) => !FILTER_LOGIC.has(name) && !fieldNames.has(name))
|
|
198
|
+
.map((name) => ({ name, ...operandsOf(name) })),
|
|
199
|
+
sortType,
|
|
200
|
+
sortValues,
|
|
201
|
+
fields,
|
|
202
|
+
notExposed: notExposedFields(type.description),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
models.sort((a, b) => (a.namespace < b.namespace ? -1 : a.namespace > b.namespace ? 1 : 0));
|
|
206
|
+
// The models the key reads that GraphQL leaves out (N1: their type names collide), which REST still serves.
|
|
207
|
+
const omitted = MODELS_NOT_EXPOSED.exec(types.get(introspection.__schema.queryType.name)?.description ?? "");
|
|
208
|
+
const restOnly = namespaceList(omitted);
|
|
209
|
+
return { version: introspection.version ?? "", models, restOnly };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* `{ introspection, summary }` for this key, from the cache when it is fresh.
|
|
214
|
+
* A failed introspection throws: every schema tool needs it, and the error
|
|
215
|
+
* already carries the API's code and hint.
|
|
216
|
+
*/
|
|
217
|
+
export async function loadSchema(config, { now = Date.now() } = {}) {
|
|
218
|
+
const key = `${config.baseUrl}|${config.apiKey}|${config.apiVersion}`;
|
|
219
|
+
const hit = cache.get(key);
|
|
220
|
+
if (hit && now - hit.at < SCHEMA_TTL_MS) return hit.value;
|
|
221
|
+
let body;
|
|
222
|
+
try {
|
|
223
|
+
body = await apiNextPost(config, "/api/graphql", { query: INTROSPECTION_QUERY });
|
|
224
|
+
} catch (error) {
|
|
225
|
+
throw whenNotServed(config, error);
|
|
226
|
+
}
|
|
227
|
+
if (!body.data?.__schema) {
|
|
228
|
+
const first = body.errors?.[0];
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Introspection returned no schema${first ? `: ${first.extensions?.code ?? "error"}: ${first.message}` : ""}. ` +
|
|
231
|
+
"Check that this deployment serves /api/graphql (CAPA_API_GRAPHQL is not off).",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const value = { introspection: body.data, summary: summarizeIntrospection(body.data) };
|
|
235
|
+
cache.set(key, { at: now, value });
|
|
236
|
+
return value;
|
|
237
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sdl.mjs — the key's schema as SDL text, whole or narrowed to some models.
|
|
3
|
+
*
|
|
4
|
+
* Printed from the introspection answer rather than fetched, because this
|
|
5
|
+
* package has no GraphQL library and `/api/schema.graphql` is not built. The
|
|
6
|
+
* output is the SDL a person would write, as graphql-js's printSchema writes
|
|
7
|
+
* it: every description kept (a type's, a field's, an argument's, an input
|
|
8
|
+
* field's and an enum value's; they carry each model's namespace, each
|
|
9
|
+
* field's Capa type and each argument's range), `@deprecated(reason:)` where
|
|
10
|
+
* a later Capa-Version phases a field, argument, input field or enum value
|
|
11
|
+
* out, built-in scalars and introspection types left out.
|
|
12
|
+
*
|
|
13
|
+
* NARROWED means: the Query root fields of the models asked for, their own
|
|
14
|
+
* types (the model, its connection, edge, filter, sort and relation filters),
|
|
15
|
+
* and every shared type those reach (PageInfo, Media, the scalar filters). A
|
|
16
|
+
* model that is only REFERENCED (Articles.author points at Authors) is named
|
|
17
|
+
* in `alsoReferenced` rather than printed, so narrowing stays narrow.
|
|
18
|
+
*
|
|
19
|
+
* COMPACT is the form a tool answer carries, where every character is the
|
|
20
|
+
* agent's context: input fields and enum values print without their
|
|
21
|
+
* descriptions (an operator's name says what it does, and a sort value's
|
|
22
|
+
* description only spells the name out), and a narrowed schema leaves out
|
|
23
|
+
* `me` and its KeyInfo, which describe the key rather than the models. Types,
|
|
24
|
+
* fields and arguments keep theirs: they carry each model's namespace, each
|
|
25
|
+
* field's label and Capa type, and each argument's range. The resource
|
|
26
|
+
* capa://graphql/schema.graphql prints the full form.
|
|
27
|
+
*/
|
|
28
|
+
import { namedType, printTypeRef } from "./schema.mjs";
|
|
29
|
+
|
|
30
|
+
const BUILT_IN = new Set(["String", "Int", "Float", "Boolean", "ID"]);
|
|
31
|
+
const ALWAYS_ROOTS = new Set(["entry", "version", "me"]);
|
|
32
|
+
const MODEL_SUFFIXES = ["", "Connection", "Edge", "RelationConnection", "Filter", "Sort", "RelationFilter", "RelationListFilter"];
|
|
33
|
+
|
|
34
|
+
function description(text, indent) {
|
|
35
|
+
if (!text) return [];
|
|
36
|
+
if (!text.includes("\n") && !text.includes('"')) return [`${indent}"${text}"`];
|
|
37
|
+
return [`${indent}"""`, ...text.split("\n").map((line) => `${indent}${line.replace(/"""/g, '\\"""')}`), `${indent}"""`];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** ` @deprecated(reason: "...")` for a deprecated field, argument, input field or enum value, else nothing. */
|
|
41
|
+
function deprecated(item) {
|
|
42
|
+
if (!item.isDeprecated) return "";
|
|
43
|
+
const reason = item.deprecationReason ?? "No longer supported";
|
|
44
|
+
return ` @deprecated(reason: ${JSON.stringify(reason)})`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** An input value as SDL writes it: `first: Int = 25`, and `@deprecated` when it is. */
|
|
48
|
+
const inputValue = (value) =>
|
|
49
|
+
`${value.name}: ${printTypeRef(value.type)}${value.defaultValue != null ? ` = ${value.defaultValue}` : ""}${deprecated(value)}`;
|
|
50
|
+
|
|
51
|
+
/** Arguments on one line, or one per line under their descriptions when any has one (as printSchema does). */
|
|
52
|
+
function printArgs(args, indent) {
|
|
53
|
+
if (!args?.length) return "";
|
|
54
|
+
if (!args.some((a) => a.description)) return `(${args.map(inputValue).join(", ")})`;
|
|
55
|
+
const lines = args.flatMap((a) => [...description(a.description, `${indent} `), `${indent} ${inputValue(a)}`]);
|
|
56
|
+
return `(\n${lines.join("\n")}\n${indent})`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function printType(type, rootFilter, compact = false) {
|
|
60
|
+
const lines = [...description(type.description, "")];
|
|
61
|
+
const itemDescription = (text, indent) => (compact ? [] : description(text, indent));
|
|
62
|
+
switch (type.kind) {
|
|
63
|
+
case "SCALAR":
|
|
64
|
+
lines.push(`scalar ${type.name}`);
|
|
65
|
+
break;
|
|
66
|
+
case "ENUM":
|
|
67
|
+
lines.push(`enum ${type.name} {`);
|
|
68
|
+
for (const v of type.enumValues) lines.push(...itemDescription(v.description, " "), ` ${v.name}${deprecated(v)}`);
|
|
69
|
+
lines.push("}");
|
|
70
|
+
break;
|
|
71
|
+
case "INPUT_OBJECT":
|
|
72
|
+
lines.push(`input ${type.name} {`);
|
|
73
|
+
for (const f of type.inputFields) lines.push(...itemDescription(f.description, " "), ` ${inputValue(f)}`);
|
|
74
|
+
lines.push("}");
|
|
75
|
+
break;
|
|
76
|
+
default: {
|
|
77
|
+
const keyword = type.kind === "INTERFACE" ? "interface" : "type";
|
|
78
|
+
const implemented = (type.interfaces ?? []).map((i) => i.name);
|
|
79
|
+
lines.push(`${keyword} ${type.name}${implemented.length ? ` implements ${implemented.join(" & ")}` : ""} {`);
|
|
80
|
+
for (const f of type.fields ?? []) {
|
|
81
|
+
if (rootFilter && !rootFilter(f)) continue;
|
|
82
|
+
lines.push(...description(f.description, " "), ` ${f.name}${printArgs(f.args, " ")}: ${printTypeRef(f.type)}${deprecated(f)}`);
|
|
83
|
+
}
|
|
84
|
+
lines.push("}");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Every type name one type refers to: field, argument, input and interface types. */
|
|
91
|
+
function references(type) {
|
|
92
|
+
const refs = [];
|
|
93
|
+
for (const f of type.fields ?? []) {
|
|
94
|
+
refs.push(namedType(f.type));
|
|
95
|
+
for (const a of f.args ?? []) refs.push(namedType(a.type));
|
|
96
|
+
}
|
|
97
|
+
for (const f of type.inputFields ?? []) refs.push(namedType(f.type));
|
|
98
|
+
for (const i of type.interfaces ?? []) refs.push(i.name);
|
|
99
|
+
return refs;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `{ sdl, types, alsoReferenced }`. `models` (namespaces or type names) narrows
|
|
104
|
+
* the output, to no model at all when empty; unknown names are the caller's to
|
|
105
|
+
* check against the summary. `compact` prints the form a tool answer carries.
|
|
106
|
+
*/
|
|
107
|
+
export function printSDL(introspection, summary, models, { compact = false } = {}) {
|
|
108
|
+
const types = new Map(introspection.__schema.types.filter((t) => !t.name.startsWith("__")).map((t) => [t.name, t]));
|
|
109
|
+
const queryName = introspection.__schema.queryType.name;
|
|
110
|
+
|
|
111
|
+
if (models === undefined) {
|
|
112
|
+
const ordered = [queryName, ...[...types.keys()].filter((n) => n !== queryName && !BUILT_IN.has(n)).sort()];
|
|
113
|
+
return { sdl: ordered.map((n) => printType(types.get(n), undefined, compact)).join("\n\n"), types: ordered.length, alsoReferenced: [] };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const chosen = summary.models.filter((m) => models.includes(m.namespace) || models.includes(m.typeName));
|
|
117
|
+
const modelTypeNames = new Set(summary.models.map((m) => m.typeName));
|
|
118
|
+
const chosenTypeNames = new Set(chosen.map((m) => m.typeName));
|
|
119
|
+
const alwaysRoots = [...ALWAYS_ROOTS].filter((name) => !(compact && name === "me"));
|
|
120
|
+
const rootNames = new Set([...alwaysRoots, ...chosen.flatMap((m) => [m.listField, m.singleField])]);
|
|
121
|
+
|
|
122
|
+
const include = new Set([queryName]);
|
|
123
|
+
const alsoReferenced = new Set();
|
|
124
|
+
const queue = chosen.flatMap((m) => MODEL_SUFFIXES.map((suffix) => `${m.typeName}${suffix}`)).filter((n) => types.has(n));
|
|
125
|
+
const rootTypes = compact ? ["Entry"] : ["KeyInfo", "Entry"];
|
|
126
|
+
queue.push(...references(types.get(queryName)).filter((n) => rootTypes.includes(n)));
|
|
127
|
+
while (queue.length) {
|
|
128
|
+
const name = queue.shift();
|
|
129
|
+
if (include.has(name) || BUILT_IN.has(name) || !types.has(name)) continue;
|
|
130
|
+
const owner = [...modelTypeNames].find((t) => name === t || MODEL_SUFFIXES.some((s) => s && name === `${t}${s}`));
|
|
131
|
+
if (owner && !chosenTypeNames.has(owner)) {
|
|
132
|
+
alsoReferenced.add(owner);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
include.add(name);
|
|
136
|
+
queue.push(...references(types.get(name)));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const ordered = [queryName, ...[...include].filter((n) => n !== queryName).sort()];
|
|
140
|
+
const sdl = ordered
|
|
141
|
+
.map((n) => (n === queryName ? printType(types.get(n), (f) => rootNames.has(f.name), compact) : printType(types.get(n), undefined, compact)))
|
|
142
|
+
.join("\n\n");
|
|
143
|
+
return { sdl, types: ordered.length, alsoReferenced: [...alsoReferenced].sort() };
|
|
144
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* served.mjs — telling "this deployment serves no GraphQL" from "this
|
|
3
|
+
* document was refused".
|
|
4
|
+
*
|
|
5
|
+
* `CAPA_API_GRAPHQL=off` unregisters `/api/graphql`, and the path then answers
|
|
6
|
+
* what any path the API does not serve answers: a POST is `405
|
|
7
|
+
* mutations_not_enabled`, a GET `404 route_not_found`, both in the REST
|
|
8
|
+
* envelope, `{ error: { ... } }`. The GraphQL handler never answers in that
|
|
9
|
+
* envelope: its refusals, a real mutation's included, are `{ errors: [...] }`.
|
|
10
|
+
* So the envelope, not the code, says which of the two happened, and a query
|
|
11
|
+
* is never told it tried to write.
|
|
12
|
+
*/
|
|
13
|
+
import { CapaApiError } from "../client.mjs";
|
|
14
|
+
|
|
15
|
+
/** Whether `error`, from a request to `/api/graphql`, means the path is not served here. */
|
|
16
|
+
export function graphqlNotServed(error) {
|
|
17
|
+
if (!(error instanceof CapaApiError) || (error.status !== 404 && error.status !== 405)) return false;
|
|
18
|
+
let body;
|
|
19
|
+
try {
|
|
20
|
+
body = JSON.parse(error.body);
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
if (!body || typeof body !== "object" || Array.isArray(body.errors)) return false;
|
|
25
|
+
return ["mutations_not_enabled", "route_not_found"].includes(body.error?.code);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The error every GraphQL tool answers on such a deployment. It names the
|
|
30
|
+
* switch and what can still read content: over REST with the same key, and
|
|
31
|
+
* for a legacy `pk_`/`sk_` key the legacy content tools it is also offered.
|
|
32
|
+
*
|
|
33
|
+
* Where `GET /api/me` found no `/api/` surface at all at startup
|
|
34
|
+
* (`config.apiMissing`), GraphQL being off is not the likely cause: the
|
|
35
|
+
* address is. That error names CAPA_API_URL instead.
|
|
36
|
+
*/
|
|
37
|
+
export class GraphQLNotServed extends Error {
|
|
38
|
+
constructor(config, error) {
|
|
39
|
+
super(config.apiMissing ? noApiAt(config, error) : graphqlOff(config, error));
|
|
40
|
+
this.name = "GraphQLNotServed";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function graphqlOff(config, error) {
|
|
45
|
+
return (
|
|
46
|
+
`This deployment does not serve GraphQL: POST /api/graphql answered ${error.status} ${error.code} as a path it does not serve, ` +
|
|
47
|
+
"which is what CAPA_API_GRAPHQL=off does. No GraphQL tool can answer until it is switched back on.\n" +
|
|
48
|
+
"Next: tell the person running Capa that GraphQL is off here. " +
|
|
49
|
+
(config.family === "legacy" ? "To read entries meanwhile, capa_list_content and capa_get_content use the legacy API. " : "") +
|
|
50
|
+
"Code can read the same content over REST with this key: GET /api/entries/<model>."
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function noApiAt(config, error) {
|
|
55
|
+
return (
|
|
56
|
+
`${printable(config.baseUrl)} serves no /api/ route: GET /api/me answered 404 when this server started, and POST /api/graphql answered ${error.status}${error.code ? ` ${error.code}` : ""}. ` +
|
|
57
|
+
"Either CAPA_API_URL is not the Capa API's address, or this deployment does not serve /api/.\n" +
|
|
58
|
+
"Next: tell the person running this server to check that CAPA_API_URL is the API's address with no route after it, " +
|
|
59
|
+
"e.g. https://api.capacms.com, and restart it; if it is, /api/ is off on this deployment."
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** CAPA_API_URL as it may be printed: never a user name or password written into it. */
|
|
64
|
+
function printable(baseUrl) {
|
|
65
|
+
try {
|
|
66
|
+
const url = new URL(baseUrl);
|
|
67
|
+
return `${url.origin}${url.pathname === "/" ? "" : url.pathname}`;
|
|
68
|
+
} catch {
|
|
69
|
+
return "CAPA_API_URL";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether `error`, from a request to `/api/graphql`, means no GraphQL tool can answer at this address. */
|
|
74
|
+
function notAnswerable(config, error) {
|
|
75
|
+
if (graphqlNotServed(error)) return true;
|
|
76
|
+
return Boolean(config.apiMissing) && error instanceof CapaApiError && (error.status === 404 || error.status === 405);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `error` as `GraphQLNotServed` when no GraphQL tool can answer at this address, else unchanged. */
|
|
80
|
+
export function whenNotServed(config, error) {
|
|
81
|
+
return notAnswerable(config, error) ? new GraphQLNotServed(config, error) : error;
|
|
82
|
+
}
|