drupal-mcp-connector 0.6.1
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 +92 -0
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/config/config.example.json +122 -0
- package/package.json +70 -0
- package/src/index.js +499 -0
- package/src/lib/backends/backend-interface.js +164 -0
- package/src/lib/backends/errors.js +31 -0
- package/src/lib/backends/graphql-filter.js +99 -0
- package/src/lib/backends/graphql-names.js +63 -0
- package/src/lib/backends/graphql-normalize.js +73 -0
- package/src/lib/backends/graphql-query.js +129 -0
- package/src/lib/backends/graphql-schema.js +226 -0
- package/src/lib/backends/graphql.js +391 -0
- package/src/lib/backends/index.js +128 -0
- package/src/lib/backends/jsonapi.js +403 -0
- package/src/lib/canonical.js +68 -0
- package/src/lib/config.js +257 -0
- package/src/lib/drupal-fetch.js +144 -0
- package/src/lib/errors.js +38 -0
- package/src/lib/http-auth.js +27 -0
- package/src/lib/oauth.js +177 -0
- package/src/lib/reports-support.js +75 -0
- package/src/lib/security.js +475 -0
- package/src/lib/validate.js +225 -0
- package/src/tools/drush.js +463 -0
- package/src/tools/entities.js +262 -0
- package/src/tools/graphql.js +175 -0
- package/src/tools/media.js +297 -0
- package/src/tools/nodes.js +247 -0
- package/src/tools/reports.js +609 -0
- package/src/tools/site.js +87 -0
- package/src/tools/taxonomy.js +202 -0
- package/src/tools/users.js +250 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed error classes for the backend abstraction layer.
|
|
3
|
+
*
|
|
4
|
+
* Single responsibility: give callers distinct, catchable error types for the
|
|
5
|
+
* two backend-specific failure modes (unsupported operation vs. no usable
|
|
6
|
+
* backend), separate from generic transport/HTTP errors.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Thrown when a resolved backend cannot perform a requested operation
|
|
11
|
+
* (e.g. attempting a write through the read-only GraphQL backend).
|
|
12
|
+
*/
|
|
13
|
+
export class BackendCapabilityError extends Error {
|
|
14
|
+
/** @param {string} message Human-readable explanation. */
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "BackendCapabilityError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Thrown when no usable backend can be resolved for a site (misconfigured
|
|
23
|
+
* `api` setting, or no protocol reachable during the probe).
|
|
24
|
+
*/
|
|
25
|
+
export class BackendResolutionError extends Error {
|
|
26
|
+
/** @param {string} message Human-readable explanation. */
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "BackendResolutionError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side filter and sort over canonical entities.
|
|
3
|
+
*
|
|
4
|
+
* Single responsibility: apply filter predicates and sort orders in JS for the
|
|
5
|
+
* GraphQL backend, which cannot filter (and can only sort by a fixed enum of
|
|
6
|
+
* keys) server-side. This runs over the bounded set of records the backend
|
|
7
|
+
* already paged in (up to its client-record cap), which is why the results it
|
|
8
|
+
* feeds are flagged `approximate`/`truncated` upstream.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const BASE_GETTERS = new Map([
|
|
12
|
+
["id", (e) => e.id],
|
|
13
|
+
["title", (e) => e.title],
|
|
14
|
+
["status", (e) => e.status],
|
|
15
|
+
["langcode",(e) => e.langcode],
|
|
16
|
+
["created", (e) => e.created],
|
|
17
|
+
["changed", (e) => e.changed],
|
|
18
|
+
["url", (e) => e.url],
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build a value accessor for one entity. The entity's custom fields are copied
|
|
23
|
+
* into a Map ONCE here, so repeated lookups across many filters/sort keys do not
|
|
24
|
+
* re-build it per access (this runs over up to ~1000 records in the GraphQL path).
|
|
25
|
+
* The Map(Object.entries()) lookup also keeps field access object-injection-safe.
|
|
26
|
+
* @param {import("../canonical.js").CanonicalEntity} entity
|
|
27
|
+
* @returns {(field: string) => *} Resolver returning the value for a field name.
|
|
28
|
+
*/
|
|
29
|
+
function accessorFor(entity) {
|
|
30
|
+
const custom = entity.fields ? new Map(Object.entries(entity.fields)) : new Map();
|
|
31
|
+
return (field) => {
|
|
32
|
+
const base = BASE_GETTERS.get(field);
|
|
33
|
+
return base ? base(entity) : custom.get(field);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Evaluate a single filter condition against an entity's value accessor.
|
|
39
|
+
* @param {(field: string) => *} get Value accessor from {@link accessorFor}.
|
|
40
|
+
* @param {{field: string, op?: string, value: *}} cond Filter condition.
|
|
41
|
+
* @returns {boolean} True when the condition holds (unknown ops return false).
|
|
42
|
+
*/
|
|
43
|
+
function matches(get, { field, op = "eq", value }) {
|
|
44
|
+
const v = get(field);
|
|
45
|
+
switch (op) {
|
|
46
|
+
case "eq": return v === value;
|
|
47
|
+
case "neq": return v !== value;
|
|
48
|
+
case "gt": return v > value;
|
|
49
|
+
case "gte": return v >= value;
|
|
50
|
+
case "lt": return v < value;
|
|
51
|
+
case "lte": return v <= value;
|
|
52
|
+
case "contains": return String(v ?? "").toLowerCase().includes(String(value).toLowerCase());
|
|
53
|
+
case "in": return Array.isArray(value) && value.includes(v);
|
|
54
|
+
case "isNull": return v === null || v === undefined;
|
|
55
|
+
default: return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Filter entities by an AND-combined list of conditions.
|
|
61
|
+
* @param {import("../canonical.js").CanonicalEntity[]} entities
|
|
62
|
+
* @param {Array<{field: string, op?: string, value: *}>} [filters]
|
|
63
|
+
* @returns {import("../canonical.js").CanonicalEntity[]} New filtered array
|
|
64
|
+
* (or the input array unchanged when there are no filters).
|
|
65
|
+
*/
|
|
66
|
+
export function applyClientFilters(entities, filters = []) {
|
|
67
|
+
if (!filters.length) return entities;
|
|
68
|
+
return entities.filter((e) => {
|
|
69
|
+
const get = accessorFor(e);
|
|
70
|
+
return filters.every((f) => matches(get, f));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sort entities by an ordered list of sort keys (later keys break ties).
|
|
76
|
+
* @param {import("../canonical.js").CanonicalEntity[]} entities
|
|
77
|
+
* @param {Array<{field: string, dir?: "asc"|"desc"}>} [sort]
|
|
78
|
+
* @returns {import("../canonical.js").CanonicalEntity[]} New sorted array
|
|
79
|
+
* (or the input array unchanged when there is no sort).
|
|
80
|
+
*/
|
|
81
|
+
export function applyClientSort(entities, sort = []) {
|
|
82
|
+
if (!sort.length) return entities;
|
|
83
|
+
// Build one accessor per entity up front (keyed by the entity object) so the
|
|
84
|
+
// comparator never re-builds the per-entity fields Map.
|
|
85
|
+
const accessors = new Map(entities.map((e) => [e, accessorFor(e)]));
|
|
86
|
+
const sorted = [...entities];
|
|
87
|
+
sorted.sort((a, b) => {
|
|
88
|
+
const ga = accessors.get(a);
|
|
89
|
+
const gb = accessors.get(b);
|
|
90
|
+
for (const { field, dir = "asc" } of sort) {
|
|
91
|
+
const av = ga(field);
|
|
92
|
+
const bv = gb(field);
|
|
93
|
+
if (av < bv) return dir === "desc" ? 1 : -1;
|
|
94
|
+
if (av > bv) return dir === "desc" ? -1 : 1;
|
|
95
|
+
}
|
|
96
|
+
return 0;
|
|
97
|
+
});
|
|
98
|
+
return sorted;
|
|
99
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers mapping graphql_compose type names to Drupal entityType/bundle.
|
|
3
|
+
*
|
|
4
|
+
* graphql_compose names a type as <Prefix><PascalBundle>, where Prefix encodes
|
|
5
|
+
* the entity type (Node, Media, Term=taxonomy_term, Paragraph, BlockContent,
|
|
6
|
+
* Menu, User). Single-type entities (User, Menu) have no bundle suffix.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Type-name prefix -> Drupal entity type. Order matters: longer/more-specific
|
|
10
|
+
// prefixes are listed first so a type like "BlockContent..." matches
|
|
11
|
+
// "BlockContent" before a hypothetical shorter "Block" prefix.
|
|
12
|
+
const PREFIXES = [
|
|
13
|
+
["BlockContent", "block_content"],
|
|
14
|
+
["Paragraph", "paragraph"],
|
|
15
|
+
["Media", "media"],
|
|
16
|
+
["Term", "taxonomy_term"],
|
|
17
|
+
["Menu", "menu"],
|
|
18
|
+
["Node", "node"],
|
|
19
|
+
["User", "user"],
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert a PascalCase fragment to snake_case (the Drupal bundle convention).
|
|
24
|
+
* @param {string} s e.g. "BasicPage".
|
|
25
|
+
* @returns {string} e.g. "basic_page".
|
|
26
|
+
*/
|
|
27
|
+
export function pascalToSnake(s) {
|
|
28
|
+
// Two passes: lower/digit->Upper boundaries, then acronym->Word boundaries,
|
|
29
|
+
// so both "fooBar" and "URLAlias" split correctly before lowercasing.
|
|
30
|
+
return s
|
|
31
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
32
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
|
33
|
+
.toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Convert a snake_case machine name to PascalCase.
|
|
38
|
+
* @param {string} s e.g. "basic_page".
|
|
39
|
+
* @returns {string} e.g. "BasicPage".
|
|
40
|
+
*/
|
|
41
|
+
export function snakeToPascal(s) {
|
|
42
|
+
return s.split("_").map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Map a graphql_compose type name to its Drupal entityType/bundle.
|
|
47
|
+
* @param {string} typeName e.g. "NodeArticle".
|
|
48
|
+
* @returns {{entityType: string, bundle: string}|null} Pair, or null when the
|
|
49
|
+
* name matches no known prefix.
|
|
50
|
+
*/
|
|
51
|
+
export function graphqlTypeToEntity(typeName) {
|
|
52
|
+
for (const [prefix, entityType] of PREFIXES) {
|
|
53
|
+
if (typeName === prefix) {
|
|
54
|
+
// Single-type entity (User, Menu): bundle == entityType.
|
|
55
|
+
return { entityType, bundle: entityType };
|
|
56
|
+
}
|
|
57
|
+
if (typeName.startsWith(prefix)) {
|
|
58
|
+
const rest = typeName.slice(prefix.length);
|
|
59
|
+
return { entityType, bundle: pascalToSnake(rest) };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphQL result normalization.
|
|
3
|
+
*
|
|
4
|
+
* Single responsibility: convert a graphql_compose node object into the
|
|
5
|
+
* shared CanonicalEntity shape, promoting base fields, collapsing entity
|
|
6
|
+
* references into canonical relationship refs, and leaving everything else as
|
|
7
|
+
* raw field values.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { makeCanonicalEntity } from "../canonical.js";
|
|
11
|
+
import { graphqlTypeToEntity } from "./graphql-names.js";
|
|
12
|
+
|
|
13
|
+
// Node keys that map to canonical base properties, not to `fields`.
|
|
14
|
+
const BASE_KEYS = new Set(["__typename", "id", "title", "status", "langcode", "created", "changed", "path"]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Detect a single entity reference object (has `__typename` and `id`).
|
|
18
|
+
* @param {*} v Candidate value.
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
function isEntityRef(v) {
|
|
22
|
+
return v && typeof v === "object" && typeof v.__typename === "string" && "id" in v;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Collapse an entity-reference object into a canonical relationship ref.
|
|
27
|
+
* @param {{__typename: string, id: string}} v
|
|
28
|
+
* @returns {{id: string, entityType: ?string, bundle: ?string}}
|
|
29
|
+
*/
|
|
30
|
+
function refToCanonical(v) {
|
|
31
|
+
const entity = graphqlTypeToEntity(v.__typename) ?? { entityType: null, bundle: null };
|
|
32
|
+
return { id: v.id, entityType: entity.entityType, bundle: entity.bundle };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalize a graphql_compose node into a CanonicalEntity.
|
|
37
|
+
* @param {object} node Raw GraphQL node object (includes `__typename`, `id`).
|
|
38
|
+
* @returns {import("../canonical.js").CanonicalEntity}
|
|
39
|
+
*/
|
|
40
|
+
export function graphqlNodeToCanonical(node) {
|
|
41
|
+
const entity = graphqlTypeToEntity(node.__typename || "") ?? { entityType: null, bundle: null };
|
|
42
|
+
|
|
43
|
+
const fieldsMap = new Map();
|
|
44
|
+
const relationshipsMap = new Map();
|
|
45
|
+
for (const [k, v] of Object.entries(node)) {
|
|
46
|
+
if (BASE_KEYS.has(k)) continue;
|
|
47
|
+
if (isEntityRef(v)) {
|
|
48
|
+
relationshipsMap.set(k, refToCanonical(v));
|
|
49
|
+
} else if (Array.isArray(v) && v.length && v.every(isEntityRef)) {
|
|
50
|
+
relationshipsMap.set(k, v.map(refToCanonical));
|
|
51
|
+
} else {
|
|
52
|
+
// Note: an empty array ([]) lands in `fields`, not `relationships` — we
|
|
53
|
+
// cannot tell an empty multi-ref from an empty scalar list without schema
|
|
54
|
+
// context, so empties stay as raw field values.
|
|
55
|
+
fieldsMap.set(k, v);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return makeCanonicalEntity({
|
|
60
|
+
id: node.id,
|
|
61
|
+
entityType: entity.entityType,
|
|
62
|
+
bundle: entity.bundle,
|
|
63
|
+
title: node.title ?? null,
|
|
64
|
+
status: typeof node.status === "boolean" ? node.status : (node.status ?? null),
|
|
65
|
+
langcode: node.langcode?.id ?? null,
|
|
66
|
+
created: node.created?.time ?? null,
|
|
67
|
+
changed: node.changed?.time ?? null,
|
|
68
|
+
url: node.path ?? null,
|
|
69
|
+
fields: Object.fromEntries(fieldsMap),
|
|
70
|
+
relationships: Object.fromEntries(relationshipsMap),
|
|
71
|
+
backend: "graphql",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphQL selection-set and query-document builders for graphql_compose.
|
|
3
|
+
*
|
|
4
|
+
* Single responsibility: turn a resolved SchemaEntry into a valid GraphQL
|
|
5
|
+
* query string. The builders are type-aware so the generated selection only
|
|
6
|
+
* requests fields the server can actually resolve (scalars are selected bare,
|
|
7
|
+
* scalar-wrapper objects get a fixed sub-selection, entity references collapse
|
|
8
|
+
* to `{ __typename id }`, and unknown wrappers are skipped to keep the query
|
|
9
|
+
* valid).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { graphqlTypeToEntity } from "./graphql-names.js";
|
|
13
|
+
|
|
14
|
+
// graphql_compose wraps some scalars in object types; each needs an explicit
|
|
15
|
+
// sub-selection because GraphQL forbids selecting an object without one.
|
|
16
|
+
const OBJECT_SUBSELECTIONS = new Map([
|
|
17
|
+
["DateTime", "{ time }"],
|
|
18
|
+
["Language", "{ id }"],
|
|
19
|
+
["TextSummary", "{ value summary format }"],
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
// Field kinds that are selected as a bare field name (no sub-selection).
|
|
23
|
+
const SCALAR_KINDS = new Set(["SCALAR", "ENUM"]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Map an entity union type name to its companion `<Entity>Interface`.
|
|
27
|
+
* An entity union/interface (TermUnion, MediaUnion, ...) exposes a matching
|
|
28
|
+
* <Entity>Interface with an `id`. Non-entity unions (e.g. MetaTagUnion) do
|
|
29
|
+
* NOT, so an inline fragment on their "interface" would be invalid — return
|
|
30
|
+
* null so the caller skips them.
|
|
31
|
+
* @param {?string} unionTypeName e.g. "MediaUnion".
|
|
32
|
+
* @returns {?string} e.g. "MediaInterface", or null when not an entity union.
|
|
33
|
+
*/
|
|
34
|
+
function entityInterfaceFor(unionTypeName) {
|
|
35
|
+
if (!unionTypeName || !graphqlTypeToEntity(unionTypeName)) return null;
|
|
36
|
+
return unionTypeName.replace(/Union$/, "Interface");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the selection text for a single field, or null to skip it.
|
|
41
|
+
* @param {string} name Field name.
|
|
42
|
+
* @param {object} desc describeType() result for the field.
|
|
43
|
+
* @returns {?string} Selection fragment, or null when the field is unselectable.
|
|
44
|
+
*/
|
|
45
|
+
function selectField(name, desc) {
|
|
46
|
+
if (SCALAR_KINDS.has(desc.kind)) return name;
|
|
47
|
+
|
|
48
|
+
if (desc.kind === "OBJECT") {
|
|
49
|
+
const sub = OBJECT_SUBSELECTIONS.get(desc.typeName);
|
|
50
|
+
if (sub) return `${name} ${sub}`;
|
|
51
|
+
// Another entity (User, MediaImage, ...) -> relationship reference.
|
|
52
|
+
if (graphqlTypeToEntity(desc.typeName)) return `${name} { __typename id }`;
|
|
53
|
+
return null; // unknown object wrapper -> skip to keep the query valid
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Single union/interface field (e.g. heroImage: MediaUnion) -> entity ref.
|
|
57
|
+
if (desc.kind === "UNION" || desc.kind === "INTERFACE") {
|
|
58
|
+
const iface = entityInterfaceFor(desc.typeName);
|
|
59
|
+
return iface ? `${name} { __typename ... on ${iface} { __typename id } }` : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (desc.kind === "LIST") {
|
|
63
|
+
if (desc.ofTypeKind === "UNION" || desc.ofTypeKind === "INTERFACE") {
|
|
64
|
+
const iface = entityInterfaceFor(desc.ofTypeName);
|
|
65
|
+
return iface ? `${name} { __typename ... on ${iface} { __typename id } }` : null;
|
|
66
|
+
}
|
|
67
|
+
if (desc.ofTypeKind === "OBJECT" && graphqlTypeToEntity(desc.ofTypeName)) {
|
|
68
|
+
return `${name} { __typename id }`;
|
|
69
|
+
}
|
|
70
|
+
if (SCALAR_KINDS.has(desc.ofTypeKind)) return name;
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build the selection set (without surrounding braces) for an entity entry.
|
|
79
|
+
* Always includes `__typename` and `id` so results can be normalized later.
|
|
80
|
+
* @param {import("./graphql-schema.js").SchemaEntry} entry
|
|
81
|
+
* @returns {string} Space-joined selection fragments.
|
|
82
|
+
*/
|
|
83
|
+
export function buildSelection(entry) {
|
|
84
|
+
const parts = ["__typename"];
|
|
85
|
+
for (const [name, desc] of entry.fields) {
|
|
86
|
+
if (name === "__typename") continue;
|
|
87
|
+
const sel = selectField(name, desc);
|
|
88
|
+
if (sel && sel !== "__typename") parts.push(sel);
|
|
89
|
+
}
|
|
90
|
+
// Ensure id present even if not in fields map.
|
|
91
|
+
if (!parts.some((p) => p === "id" || p.startsWith("id "))) parts.splice(1, 0, "id");
|
|
92
|
+
return parts.join(" ");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Render collection arguments into GraphQL argument syntax.
|
|
97
|
+
* @param {{first?: number, after?: string, sortKey?: string, reverse?: boolean}} args
|
|
98
|
+
* @returns {string} e.g. "(first: 50, sortKey: CREATED_AT)" or "" when empty.
|
|
99
|
+
*/
|
|
100
|
+
function formatArgs(args) {
|
|
101
|
+
const out = [];
|
|
102
|
+
if (args.first !== undefined && args.first !== null) out.push(`first: ${args.first}`);
|
|
103
|
+
if (args.after) out.push(`after: ${JSON.stringify(args.after)}`);
|
|
104
|
+
if (args.sortKey) out.push(`sortKey: ${args.sortKey}`);
|
|
105
|
+
if (args.reverse !== undefined) out.push(`reverse: ${args.reverse}`);
|
|
106
|
+
return out.length ? `(${out.join(", ")})` : "";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build a collection (connection) query document.
|
|
111
|
+
* @param {import("./graphql-schema.js").SchemaEntry} entry
|
|
112
|
+
* @param {{first?: number, after?: string, sortKey?: string, reverse?: boolean}} [args]
|
|
113
|
+
* @returns {string} A complete GraphQL query string.
|
|
114
|
+
*/
|
|
115
|
+
export function buildCollectionQuery(entry, args = {}) {
|
|
116
|
+
const selection = buildSelection(entry);
|
|
117
|
+
return `{ ${entry.collection}${formatArgs(args)} { pageInfo { hasNextPage endCursor } nodes { ${selection} } } }`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Build a single-entity query document.
|
|
122
|
+
* @param {import("./graphql-schema.js").SchemaEntry} entry
|
|
123
|
+
* @param {string} id Entity id (UUID) to fetch.
|
|
124
|
+
* @returns {string} A complete GraphQL query string.
|
|
125
|
+
*/
|
|
126
|
+
export function buildSingleQuery(entry, id) {
|
|
127
|
+
const selection = buildSelection(entry);
|
|
128
|
+
return `{ ${entry.single}(id: ${JSON.stringify(id)}) { ${selection} } }`;
|
|
129
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphQL schema introspection and the queryable SchemaMap.
|
|
3
|
+
*
|
|
4
|
+
* Single responsibility: introspect a site's GraphQL schema once and build a
|
|
5
|
+
* lookup from Drupal entityType/bundle to the graphql_compose query fields
|
|
6
|
+
* (single + collection), field names, and field type kinds. The resolved map
|
|
7
|
+
* is cached per site so introspection runs at most once per process per site.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { drupalGraphqlFetch } from "../drupal-fetch.js";
|
|
11
|
+
import { graphqlTypeToEntity } from "./graphql-names.js";
|
|
12
|
+
|
|
13
|
+
// Introspection is intentionally nested four `ofType` levels deep: a field type
|
|
14
|
+
// can be wrapped as NON_NULL(LIST(NON_NULL(NamedType))), so four levels are
|
|
15
|
+
// needed to reach the underlying named type through every wrapper combination.
|
|
16
|
+
const INTROSPECTION_QUERY = `
|
|
17
|
+
{
|
|
18
|
+
__schema {
|
|
19
|
+
queryType { name }
|
|
20
|
+
types {
|
|
21
|
+
name
|
|
22
|
+
kind
|
|
23
|
+
enumValues { name }
|
|
24
|
+
fields {
|
|
25
|
+
name
|
|
26
|
+
args { name }
|
|
27
|
+
type { name kind ofType { name kind ofType { name kind ofType { name kind } } } }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}`;
|
|
32
|
+
|
|
33
|
+
const cache = new Map();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Test helper: clear the per-site schema cache.
|
|
37
|
+
* @returns {void}
|
|
38
|
+
*/
|
|
39
|
+
export function _clearSchemaCache() {
|
|
40
|
+
cache.clear();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Unwrap NON_NULL/LIST wrappers down to the meaningful kind and type names.
|
|
45
|
+
* @param {object|null} t An introspection type node.
|
|
46
|
+
* @returns {{kind: ?string, typeName: ?string, ofTypeKind: ?string, ofTypeName: ?string}}
|
|
47
|
+
* For LIST types, `ofType*` describes the element type; otherwise `typeName`
|
|
48
|
+
* is the named type.
|
|
49
|
+
*/
|
|
50
|
+
function describeType(t) {
|
|
51
|
+
// Returns { kind, typeName, ofTypeKind, ofTypeName }
|
|
52
|
+
if (!t) return { kind: null, typeName: null, ofTypeKind: null, ofTypeName: null };
|
|
53
|
+
if (t.kind === "NON_NULL") return describeType(t.ofType);
|
|
54
|
+
if (t.kind === "LIST") {
|
|
55
|
+
const inner = t.ofType?.kind === "NON_NULL" ? t.ofType.ofType : t.ofType;
|
|
56
|
+
return { kind: "LIST", typeName: null, ofTypeKind: inner?.kind ?? null, ofTypeName: inner?.name ?? null };
|
|
57
|
+
}
|
|
58
|
+
return { kind: t.kind, typeName: t.name, ofTypeKind: null, ofTypeName: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {Object} SchemaEntry
|
|
63
|
+
* @property {string} typeName graphql_compose object type, e.g. "NodeArticle".
|
|
64
|
+
* @property {string} entityType Drupal entity type, e.g. "node".
|
|
65
|
+
* @property {string} bundle Drupal bundle, e.g. "article".
|
|
66
|
+
* @property {?string} single Query field returning one entity, or null.
|
|
67
|
+
* @property {?string} collection Query field returning a connection, or null.
|
|
68
|
+
* @property {Map<string, object>} fields Field name -> describeType() result.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolved schema lookup: entityType/bundle -> SchemaEntry, plus the set of
|
|
73
|
+
* server-supported sort-key enum values.
|
|
74
|
+
*/
|
|
75
|
+
class SchemaMap {
|
|
76
|
+
constructor() {
|
|
77
|
+
this._byEntity = new Map(); // "node:article" -> SchemaEntry
|
|
78
|
+
this._typeToEntity = new Map(); // "NodeArticle" -> {entityType, bundle}
|
|
79
|
+
/** @type {Set<string>} Enum values of ConnectionSortKeys (server-side sort). */
|
|
80
|
+
this.sortKeys = new Set();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Compose the internal map key for an entity/bundle pair.
|
|
85
|
+
* @param {string} entityType
|
|
86
|
+
* @param {string} bundle
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
static _key(entityType, bundle) { return `${entityType}:${bundle}`; }
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Look up the schema entry for an entity/bundle.
|
|
93
|
+
* @param {string} entityType
|
|
94
|
+
* @param {string} bundle
|
|
95
|
+
* @returns {?SchemaEntry}
|
|
96
|
+
*/
|
|
97
|
+
forEntity(entityType, bundle) {
|
|
98
|
+
return this._byEntity.get(SchemaMap._key(entityType, bundle)) ?? null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Reverse lookup: GraphQL type name -> entity/bundle.
|
|
103
|
+
* @param {string} typeName
|
|
104
|
+
* @returns {{entityType: string, bundle: string}|null}
|
|
105
|
+
*/
|
|
106
|
+
entityForType(typeName) {
|
|
107
|
+
return this._typeToEntity.get(typeName) ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* List bundles of the `node` entity type.
|
|
112
|
+
* @returns {string[]}
|
|
113
|
+
*/
|
|
114
|
+
nodeBundles() {
|
|
115
|
+
const out = [];
|
|
116
|
+
for (const [key, v] of this._byEntity) {
|
|
117
|
+
if (key.startsWith("node:")) out.push(v.bundle);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* List the bundles of a given entity type.
|
|
124
|
+
* @param {string} entityType
|
|
125
|
+
* @returns {string[]}
|
|
126
|
+
*/
|
|
127
|
+
bundlesOf(entityType) {
|
|
128
|
+
const out = [];
|
|
129
|
+
const prefix = `${entityType}:`;
|
|
130
|
+
for (const [key, v] of this._byEntity) {
|
|
131
|
+
if (key.startsWith(prefix)) out.push(v.bundle);
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* List every known entity/bundle pair.
|
|
138
|
+
* @returns {Array<{entityType: string, bundle: string}>}
|
|
139
|
+
*/
|
|
140
|
+
allEntities() {
|
|
141
|
+
const out = [];
|
|
142
|
+
for (const v of this._byEntity.values()) {
|
|
143
|
+
out.push({ entityType: v.entityType, bundle: v.bundle });
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build a SchemaMap from a raw introspection response.
|
|
151
|
+
* @param {object} introspection The `{ data: { __schema } }` response.
|
|
152
|
+
* @returns {SchemaMap}
|
|
153
|
+
*/
|
|
154
|
+
function buildSchemaMap(introspection) {
|
|
155
|
+
const schema = introspection.data.__schema;
|
|
156
|
+
const typesByName = new Map(schema.types.map((t) => [t.name, t]));
|
|
157
|
+
const queryType = typesByName.get(schema.queryType.name);
|
|
158
|
+
const map = new SchemaMap();
|
|
159
|
+
|
|
160
|
+
// Sort keys
|
|
161
|
+
const sortEnum = typesByName.get("ConnectionSortKeys");
|
|
162
|
+
for (const v of sortEnum?.enumValues ?? []) map.sortKeys.add(v.name);
|
|
163
|
+
|
|
164
|
+
// Index single + collection fields by the concrete entity object type they expose.
|
|
165
|
+
const singles = new Map(); // typeName -> queryField
|
|
166
|
+
const collections = new Map(); // typeName -> queryField
|
|
167
|
+
for (const f of queryType.fields ?? []) {
|
|
168
|
+
const d = describeType(f.type);
|
|
169
|
+
// graphql_compose names collection types <Entity>Connection; the nodes-type
|
|
170
|
+
// guard below is sufficient, so we don't also require a pageInfo field here.
|
|
171
|
+
if (d.kind === "OBJECT" && d.typeName?.endsWith("Connection")) {
|
|
172
|
+
const conn = typesByName.get(d.typeName);
|
|
173
|
+
const nodesField = conn?.fields?.find((x) => x.name === "nodes");
|
|
174
|
+
const nodeType = describeType(nodesField?.type).ofTypeName;
|
|
175
|
+
if (nodeType) collections.set(nodeType, f.name);
|
|
176
|
+
} else if (d.kind === "OBJECT" && (f.args ?? []).some((a) => a.name === "id")) {
|
|
177
|
+
singles.set(d.typeName, f.name);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// For each entity object type with a single OR collection field, build the entry.
|
|
182
|
+
const typeNames = new Set([...singles.keys(), ...collections.keys()]);
|
|
183
|
+
for (const typeName of typeNames) {
|
|
184
|
+
const entity = graphqlTypeToEntity(typeName);
|
|
185
|
+
if (!entity) continue;
|
|
186
|
+
const typeDef = typesByName.get(typeName);
|
|
187
|
+
const fields = new Map();
|
|
188
|
+
for (const ff of typeDef?.fields ?? []) {
|
|
189
|
+
fields.set(ff.name, describeType(ff.type));
|
|
190
|
+
}
|
|
191
|
+
const entry = {
|
|
192
|
+
typeName,
|
|
193
|
+
entityType: entity.entityType,
|
|
194
|
+
bundle: entity.bundle,
|
|
195
|
+
single: singles.get(typeName) ?? null,
|
|
196
|
+
collection: collections.get(typeName) ?? null,
|
|
197
|
+
fields,
|
|
198
|
+
};
|
|
199
|
+
map._byEntity.set(SchemaMap._key(entity.entityType, entity.bundle), entry);
|
|
200
|
+
map._typeToEntity.set(typeName, { entityType: entity.entityType, bundle: entity.bundle });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return map;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Load (or return the cached) SchemaMap for a site.
|
|
208
|
+
* @param {object} site Site config; must include `_name` (the cache key).
|
|
209
|
+
* @returns {Promise<SchemaMap>}
|
|
210
|
+
* @throws {Error} When introspection fails (the cache entry is cleared first).
|
|
211
|
+
*/
|
|
212
|
+
export async function loadSchemaMap(site) {
|
|
213
|
+
// Cache the in-flight Promise (not the resolved value) so concurrent first
|
|
214
|
+
// calls share a single introspection. Clear on failure so a transient error
|
|
215
|
+
// does not poison the cache.
|
|
216
|
+
if (!cache.has(site._name)) {
|
|
217
|
+
const pending = drupalGraphqlFetch(site, { query: INTROSPECTION_QUERY })
|
|
218
|
+
.then(buildSchemaMap)
|
|
219
|
+
.catch((err) => {
|
|
220
|
+
cache.delete(site._name);
|
|
221
|
+
throw err;
|
|
222
|
+
});
|
|
223
|
+
cache.set(site._name, pending);
|
|
224
|
+
}
|
|
225
|
+
return cache.get(site._name);
|
|
226
|
+
}
|