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,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON:API backend adapter.
|
|
3
|
+
*
|
|
4
|
+
* Single responsibility: implement the full Backend interface (read + write +
|
|
5
|
+
* delete) against Drupal core JSON:API, translating entity descriptors into
|
|
6
|
+
* JSON:API query strings and JSON:API resources into the shared
|
|
7
|
+
* CanonicalEntity shape. This is the read/write backend; GraphQL is read-only.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { drupalFetch, drupalUploadFile } from "../drupal-fetch.js";
|
|
11
|
+
import { Backend } from "./backend-interface.js";
|
|
12
|
+
import {
|
|
13
|
+
makeCanonicalEntity,
|
|
14
|
+
normalizeRelationship,
|
|
15
|
+
BASE_ATTRIBUTE_FIELDS,
|
|
16
|
+
} from "../canonical.js";
|
|
17
|
+
|
|
18
|
+
// Drupal exposes internal numeric ids under drupal_internal__* attributes;
|
|
19
|
+
// these are dropped from canonical `fields` (the canonical id is the UUID).
|
|
20
|
+
const INTERNAL_ATTR_RE = /^drupal_internal__/;
|
|
21
|
+
|
|
22
|
+
// Canonical filter op -> JSON:API condition operator.
|
|
23
|
+
const OP_MAP = new Map([
|
|
24
|
+
["neq", "<>"], ["gt", ">"], ["gte", ">="], ["lt", "<"], ["lte", "<="],
|
|
25
|
+
["contains", "CONTAINS"], ["in", "IN"], ["isNull", "IS NULL"],
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// entityType -> JSON:API config-entity resource that enumerates its bundles.
|
|
29
|
+
const BUNDLE_ENDPOINTS = new Map([
|
|
30
|
+
["node", "node_type/node_type"],
|
|
31
|
+
["taxonomy_term", "taxonomy_vocabulary/taxonomy_vocabulary"],
|
|
32
|
+
["media", "media_type/media_type"],
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
// The attribute holding each config entity's machine id, by entity type.
|
|
36
|
+
const BUNDLE_ID_ATTR = new Map([
|
|
37
|
+
["node", "drupal_internal__type"],
|
|
38
|
+
["taxonomy_term", "drupal_internal__vid"],
|
|
39
|
+
["media", "drupal_internal__id"],
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Infer a coarse type label for a sample attribute value, recognizing common
|
|
44
|
+
* Drupal field object shapes (text-with-summary, image, uri, ...) by their key
|
|
45
|
+
* set so schema output is human-meaningful rather than just "object".
|
|
46
|
+
* @param {*} value Sample value.
|
|
47
|
+
* @returns {string} A type label, e.g. "string", "array<number>", "image".
|
|
48
|
+
*/
|
|
49
|
+
function inferType(value) {
|
|
50
|
+
if (value === null) return "null";
|
|
51
|
+
if (typeof value === "boolean") return "boolean";
|
|
52
|
+
if (typeof value === "number") return "number";
|
|
53
|
+
if (typeof value === "string") return "string";
|
|
54
|
+
if (Array.isArray(value)) return `array<${inferType(value[0])}>`;
|
|
55
|
+
if (typeof value === "object") {
|
|
56
|
+
const keys = Object.keys(value).sort().join(",");
|
|
57
|
+
if (keys === "format,processed,summary,value") return "text_with_summary";
|
|
58
|
+
if (keys === "format,processed,value") return "text_formatted";
|
|
59
|
+
if (keys === "alt,height,target_id,target_type,title,url,width") return "image";
|
|
60
|
+
if (keys === "url,value") return "uri";
|
|
61
|
+
return `object{${keys}}`;
|
|
62
|
+
}
|
|
63
|
+
return typeof value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Append one filter condition to a URLSearchParams in JSON:API syntax.
|
|
68
|
+
* Equality uses the shorthand `filter[field]=value`; other operators use the
|
|
69
|
+
* verbose `filter[c_field][condition][...]` form. `in` expands to indexed
|
|
70
|
+
* value params; `isNull` omits the value entirely.
|
|
71
|
+
* @param {URLSearchParams} params Params to mutate.
|
|
72
|
+
* @param {{field: string, op?: string, value: *}} cond Filter condition.
|
|
73
|
+
* @returns {void}
|
|
74
|
+
*/
|
|
75
|
+
function applyFilter(params, { field, op = "eq", value }) {
|
|
76
|
+
if (op === "eq") {
|
|
77
|
+
params.append(`filter[${field}]`, String(value));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const key = `c_${field}`;
|
|
81
|
+
params.append(`filter[${key}][condition][path]`, field);
|
|
82
|
+
params.append(`filter[${key}][condition][operator]`, OP_MAP.get(op) || "=");
|
|
83
|
+
if (op === "in" && Array.isArray(value)) {
|
|
84
|
+
value.forEach((v, i) => params.append(`filter[${key}][condition][value][${i}]`, String(v)));
|
|
85
|
+
} else if (op !== "isNull") {
|
|
86
|
+
params.append(`filter[${key}][condition][value]`, String(value));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read/write Backend adapter backed by Drupal core JSON:API.
|
|
92
|
+
*/
|
|
93
|
+
export class JsonApiBackend extends Backend {
|
|
94
|
+
/** @param {object} site Site config (must include `_name`). */
|
|
95
|
+
constructor(site) {
|
|
96
|
+
super();
|
|
97
|
+
this.site = site;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Report capabilities: full read/write/delete, exact count, server-side
|
|
102
|
+
* filter, full sort, revisions.
|
|
103
|
+
* @returns {import("./backend-interface.js").Capabilities}
|
|
104
|
+
*/
|
|
105
|
+
capabilities() {
|
|
106
|
+
return {
|
|
107
|
+
read: true, write: true, delete: true,
|
|
108
|
+
count: true, filter: true, sort: "full", revisions: true,
|
|
109
|
+
fieldAvailability: null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build the JSON:API collection path for an entity/bundle.
|
|
115
|
+
* @param {string} entityType
|
|
116
|
+
* @param {string} bundle
|
|
117
|
+
* @returns {string} e.g. "/jsonapi/node/article".
|
|
118
|
+
*/
|
|
119
|
+
resourcePath(entityType, bundle) {
|
|
120
|
+
return `/jsonapi/${entityType}/${bundle}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Compile an entity descriptor into JSON:API query parameters
|
|
125
|
+
* (filter/sort/sparse-fieldset/include/page).
|
|
126
|
+
* @param {import("../canonical.js").QueryDescriptor} descriptor
|
|
127
|
+
* @returns {URLSearchParams}
|
|
128
|
+
*/
|
|
129
|
+
compileQuery(descriptor) {
|
|
130
|
+
const params = new URLSearchParams();
|
|
131
|
+
const { filters = [], sort = [], fields = [], include = [], page = {} } = descriptor;
|
|
132
|
+
for (const f of filters) applyFilter(params, f);
|
|
133
|
+
if (sort.length) {
|
|
134
|
+
params.set("sort", sort.map((s) => (s.dir === "desc" ? "-" : "") + s.field).join(","));
|
|
135
|
+
}
|
|
136
|
+
if (fields.length) {
|
|
137
|
+
params.set(`fields[${descriptor.entityType}--${descriptor.bundle}]`, fields.join(","));
|
|
138
|
+
}
|
|
139
|
+
if (include.length) params.set("include", include.join(","));
|
|
140
|
+
if (page.limit !== undefined && page.limit !== null) params.set("page[limit]", String(page.limit));
|
|
141
|
+
if (page.offset !== undefined && page.offset !== null) params.set("page[offset]", String(page.offset));
|
|
142
|
+
return params;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Convert a JSON:API resource object into a CanonicalEntity. Base attributes
|
|
147
|
+
* (title/status/...) are promoted; drupal_internal__* and base fields are
|
|
148
|
+
* stripped from `fields`; relationships are normalized to canonical refs.
|
|
149
|
+
* @param {object} resource A JSON:API resource object.
|
|
150
|
+
* @returns {import("../canonical.js").CanonicalEntity}
|
|
151
|
+
*/
|
|
152
|
+
toCanonical(resource) {
|
|
153
|
+
const [rawType, rawBundle] = (resource.type || "").split("--");
|
|
154
|
+
const entityType = rawType || null;
|
|
155
|
+
const bundle = rawBundle || null;
|
|
156
|
+
const attrs = resource.attributes || {};
|
|
157
|
+
const fields = Object.fromEntries(
|
|
158
|
+
Object.entries(attrs).filter(
|
|
159
|
+
([k]) => !BASE_ATTRIBUTE_FIELDS.includes(k) && !INTERNAL_ATTR_RE.test(k)
|
|
160
|
+
)
|
|
161
|
+
);
|
|
162
|
+
const relationships = Object.fromEntries(
|
|
163
|
+
Object.entries(resource.relationships || {}).map(([k, rel]) => [k, normalizeRelationship(rel?.data ?? null)])
|
|
164
|
+
);
|
|
165
|
+
return makeCanonicalEntity({
|
|
166
|
+
id: resource.id,
|
|
167
|
+
entityType, bundle,
|
|
168
|
+
title: attrs.title ?? null,
|
|
169
|
+
status: attrs.status ?? null,
|
|
170
|
+
langcode: attrs.langcode ?? null,
|
|
171
|
+
created: attrs.created ?? null,
|
|
172
|
+
changed: attrs.changed ?? null,
|
|
173
|
+
url: attrs.path?.alias ?? null,
|
|
174
|
+
fields, relationships,
|
|
175
|
+
backend: "jsonapi",
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* List entities for a descriptor. Server-side filter/sort/paging means the
|
|
181
|
+
* result is always exact (`approximate`/`truncated` are false).
|
|
182
|
+
* @param {import("../canonical.js").QueryDescriptor} descriptor
|
|
183
|
+
* @returns {Promise<import("./backend-interface.js").ListResult>}
|
|
184
|
+
*/
|
|
185
|
+
async listEntities(descriptor) {
|
|
186
|
+
const params = this.compileQuery(descriptor);
|
|
187
|
+
const qs = params.toString();
|
|
188
|
+
const base = this.resourcePath(descriptor.entityType, descriptor.bundle);
|
|
189
|
+
const path = qs ? `${base}?${qs}` : base;
|
|
190
|
+
const data = await drupalFetch(this.site, path);
|
|
191
|
+
const entities = (data.data || []).map((r) => this.toCanonical(r));
|
|
192
|
+
const total = data.meta?.count ?? entities.length;
|
|
193
|
+
return {
|
|
194
|
+
entities,
|
|
195
|
+
page: { total, hasNext: Boolean(data.links?.next), cursor: null },
|
|
196
|
+
approximate: false,
|
|
197
|
+
truncated: false,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Fetch a single entity by id.
|
|
203
|
+
* @param {{entityType: string, bundle: string, id: string}} ref
|
|
204
|
+
* @returns {Promise<?import("../canonical.js").CanonicalEntity>} Entity, or null.
|
|
205
|
+
*/
|
|
206
|
+
async getEntity({ entityType, bundle, id }) {
|
|
207
|
+
const data = await drupalFetch(this.site, `${this.resourcePath(entityType, bundle)}/${id}`);
|
|
208
|
+
return data?.data ? this.toCanonical(data.data) : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Create an entity via JSON:API POST.
|
|
213
|
+
* @param {{entityType: string, bundle: string, attributes?: object, relationships?: object}} input
|
|
214
|
+
* @returns {Promise<import("../canonical.js").CanonicalEntity>} The created entity.
|
|
215
|
+
*/
|
|
216
|
+
async createEntity({ entityType, bundle, attributes = {}, relationships }) {
|
|
217
|
+
const payload = { data: { type: `${entityType}--${bundle}`, attributes } };
|
|
218
|
+
if (relationships) payload.data.relationships = relationships;
|
|
219
|
+
const data = await drupalFetch(this.site, this.resourcePath(entityType, bundle), {
|
|
220
|
+
method: "POST",
|
|
221
|
+
body: JSON.stringify(payload),
|
|
222
|
+
});
|
|
223
|
+
return this.toCanonical(data.data);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Update an entity via JSON:API PATCH.
|
|
228
|
+
* @param {{entityType: string, bundle: string, id: string, attributes?: object, relationships?: object}} input
|
|
229
|
+
* @returns {Promise<import("../canonical.js").CanonicalEntity>} The updated entity.
|
|
230
|
+
*/
|
|
231
|
+
async updateEntity({ entityType, bundle, id, attributes = {}, relationships }) {
|
|
232
|
+
const payload = { data: { type: `${entityType}--${bundle}`, id, attributes } };
|
|
233
|
+
if (relationships) payload.data.relationships = relationships;
|
|
234
|
+
const data = await drupalFetch(this.site, `${this.resourcePath(entityType, bundle)}/${id}`, {
|
|
235
|
+
method: "PATCH",
|
|
236
|
+
body: JSON.stringify(payload),
|
|
237
|
+
});
|
|
238
|
+
return this.toCanonical(data.data);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Delete an entity via JSON:API DELETE.
|
|
243
|
+
* @param {{entityType: string, bundle: string, id: string}} ref
|
|
244
|
+
* @returns {Promise<void>}
|
|
245
|
+
*/
|
|
246
|
+
async deleteEntity({ entityType, bundle, id }) {
|
|
247
|
+
await drupalFetch(this.site, `${this.resourcePath(entityType, bundle)}/${id}`, { method: "DELETE" });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Discover resource types from the JSON:API entry-point links.
|
|
252
|
+
* @returns {Promise<{resourceTypes: string[]}>}
|
|
253
|
+
*/
|
|
254
|
+
async introspect() {
|
|
255
|
+
const data = await drupalFetch(this.site, "/jsonapi");
|
|
256
|
+
const resourceTypes = Object.keys(data.links || {}).filter((k) => k !== "self");
|
|
257
|
+
return { resourceTypes };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Escape hatch for direct JSON:API access (keeps passthrough tools working).
|
|
262
|
+
* @param {{path: string, options?: object}} input Request path and fetch options.
|
|
263
|
+
* @returns {Promise<*>} The raw JSON:API response.
|
|
264
|
+
*/
|
|
265
|
+
async rawQuery({ path, options }) {
|
|
266
|
+
return drupalFetch(this.site, path, options);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* List node content types with labels and descriptions.
|
|
271
|
+
* @returns {Promise<Array<{id: string, label: string, description: ?string}>>}
|
|
272
|
+
*/
|
|
273
|
+
async listContentTypes() {
|
|
274
|
+
// page[limit]=50 is an intentional cap; matches Drupal JSON:API's default page size.
|
|
275
|
+
const data = await drupalFetch(this.site, "/jsonapi/node_type/node_type?page[limit]=50");
|
|
276
|
+
return (data.data || []).map((ct) => ({
|
|
277
|
+
id: ct.attributes.drupal_internal__type,
|
|
278
|
+
label: ct.attributes.name,
|
|
279
|
+
description: ct.attributes.description ?? null,
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* List the bundles of an entity type via its config-entity resource.
|
|
285
|
+
* Attribute reads use Map(Object.entries()) to stay object-injection-safe.
|
|
286
|
+
* @param {string} entityType One of the keys in BUNDLE_ENDPOINTS.
|
|
287
|
+
* @returns {Promise<Array<{id: string, label: ?string, description: ?string}>>}
|
|
288
|
+
* @throws {Error} When no bundle endpoint is known for the entity type.
|
|
289
|
+
*/
|
|
290
|
+
async listBundles(entityType) {
|
|
291
|
+
const endpoint = BUNDLE_ENDPOINTS.get(entityType);
|
|
292
|
+
if (!endpoint) {
|
|
293
|
+
throw new Error(`No bundle endpoint known for entity type "${entityType}".`);
|
|
294
|
+
}
|
|
295
|
+
const idAttr = BUNDLE_ID_ATTR.get(entityType) ?? "drupal_internal__id";
|
|
296
|
+
const data = await drupalFetch(this.site, `/jsonapi/${endpoint}?page[limit]=100`);
|
|
297
|
+
return (data.data || []).map((b) => {
|
|
298
|
+
const a = new Map(Object.entries(b.attributes));
|
|
299
|
+
return {
|
|
300
|
+
id: a.get(idAttr) ?? a.get("drupal_internal__id"),
|
|
301
|
+
label: a.get("name") ?? a.get("label") ?? null,
|
|
302
|
+
description: a.get("description") ?? null,
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* List user roles.
|
|
309
|
+
* @returns {Promise<Array<{id: string, machineName: string, label: string, weight: number}>>}
|
|
310
|
+
*/
|
|
311
|
+
async listRoles() {
|
|
312
|
+
const data = await drupalFetch(this.site, "/jsonapi/user_role/user_role");
|
|
313
|
+
return (data.data || []).map((r) => ({
|
|
314
|
+
id: r.id,
|
|
315
|
+
machineName: r.attributes.drupal_internal__id,
|
|
316
|
+
label: r.attributes.label,
|
|
317
|
+
weight: r.attributes.weight,
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Return an exact entity count. Requests page[limit]=1 and reads the
|
|
323
|
+
* server-provided meta.count, so no full result set is transferred.
|
|
324
|
+
* @param {import("../canonical.js").QueryDescriptor} descriptor
|
|
325
|
+
* @returns {Promise<{count: number, approximate: false}>}
|
|
326
|
+
*/
|
|
327
|
+
async countEntities(descriptor) {
|
|
328
|
+
const params = this.compileQuery({ ...descriptor, page: { limit: 1 } });
|
|
329
|
+
const qs = params.toString();
|
|
330
|
+
const base = this.resourcePath(descriptor.entityType, descriptor.bundle);
|
|
331
|
+
const data = await drupalFetch(this.site, qs ? `${base}?${qs}` : base);
|
|
332
|
+
return { count: data.meta?.count ?? (data.data || []).length, approximate: false };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Upload a file and return its descriptor.
|
|
337
|
+
* @param {{entityType?: string, bundle: string, fieldName: string, filePath: string}} opts
|
|
338
|
+
* @returns {Promise<{id: string, drupalId: number, filename: string, uri: ?string, url: ?string, size: number, mimeType: string}>}
|
|
339
|
+
*/
|
|
340
|
+
async uploadFile({ entityType = "media", bundle, fieldName, filePath }) {
|
|
341
|
+
const data = await drupalUploadFile(this.site, entityType, bundle, fieldName, filePath);
|
|
342
|
+
const f = data.data;
|
|
343
|
+
return {
|
|
344
|
+
id: f.id,
|
|
345
|
+
drupalId: f.attributes.drupal_internal__fid,
|
|
346
|
+
filename: f.attributes.filename,
|
|
347
|
+
uri: f.attributes.uri?.value ?? null,
|
|
348
|
+
url: f.attributes.uri?.url ?? null,
|
|
349
|
+
size: f.attributes.filesize,
|
|
350
|
+
mimeType: f.attributes.filemime,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* List resource types as entity/bundle pairs from the entry-point links.
|
|
356
|
+
* Only `entityType--bundle` link keys are included.
|
|
357
|
+
* @returns {Promise<Array<{resourceType: string, entityType: string, bundle: string}>>}
|
|
358
|
+
*/
|
|
359
|
+
async listResourceTypes() {
|
|
360
|
+
const data = await drupalFetch(this.site, "/jsonapi");
|
|
361
|
+
return Object.keys(data.links || {})
|
|
362
|
+
.filter((k) => k !== "self" && k.includes("--"))
|
|
363
|
+
.map((k) => {
|
|
364
|
+
const [entityType, ...rest] = k.split("--");
|
|
365
|
+
return { resourceType: k, entityType, bundle: rest.join("--") };
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Describe a bundle's fields by sampling one entity and inferring attribute
|
|
371
|
+
* types from its values. JSON:API has no schema endpoint, so an empty bundle
|
|
372
|
+
* yields a `note` and empty maps.
|
|
373
|
+
* @param {string} entityType
|
|
374
|
+
* @param {string} bundle
|
|
375
|
+
* @returns {Promise<{entityType: string, bundle: string, resourceType?: string, note?: string, attributes: object, relationships: object}>}
|
|
376
|
+
*/
|
|
377
|
+
async getEntitySchema(entityType, bundle) {
|
|
378
|
+
const data = await drupalFetch(this.site, `${this.resourcePath(entityType, bundle)}?page[limit]=1`);
|
|
379
|
+
if (!data.data?.length) {
|
|
380
|
+
return { entityType, bundle, note: "No entities exist yet — schema unavailable.", attributes: {}, relationships: {} };
|
|
381
|
+
}
|
|
382
|
+
const sample = data.data[0];
|
|
383
|
+
const attributes = Object.fromEntries(
|
|
384
|
+
Object.entries(sample.attributes ?? {}).map(([k, v]) => [k, inferType(v)])
|
|
385
|
+
);
|
|
386
|
+
const relationships = Object.fromEntries(
|
|
387
|
+
Object.keys(sample.relationships ?? {}).map((k) => [k, "relationship"])
|
|
388
|
+
);
|
|
389
|
+
return { entityType, bundle, resourceType: sample.type, attributes, relationships };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Resolve a field name from candidates. JSON:API has no cheap
|
|
394
|
+
* field-availability check, so the first candidate is returned optimistically.
|
|
395
|
+
* @param {string} entityType
|
|
396
|
+
* @param {string} bundle
|
|
397
|
+
* @param {string[]} candidates Field names in preference order.
|
|
398
|
+
* @returns {?string} The first candidate, or null when the list is empty.
|
|
399
|
+
*/
|
|
400
|
+
resolveFieldName(entityType, bundle, candidates) {
|
|
401
|
+
return candidates[0] ?? null;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical, API-neutral entity model shared by all backends.
|
|
3
|
+
*
|
|
4
|
+
* Every tool returns this shape regardless of whether the data came from
|
|
5
|
+
* JSON:API or GraphQL, so downstream consumers (reports, prompts, MCP clients)
|
|
6
|
+
* never branch on the underlying protocol.
|
|
7
|
+
*
|
|
8
|
+
* @typedef {Object} CanonicalEntity
|
|
9
|
+
* @property {string} id UUID
|
|
10
|
+
* @property {string} entityType e.g. "node"
|
|
11
|
+
* @property {string} bundle e.g. "article"
|
|
12
|
+
* @property {?string} title
|
|
13
|
+
* @property {?boolean} status
|
|
14
|
+
* @property {?string} langcode
|
|
15
|
+
* @property {?string} created ISO-8601
|
|
16
|
+
* @property {?string} changed ISO-8601
|
|
17
|
+
* @property {?string} url path/alias
|
|
18
|
+
* @property {Object} fields non-base fields
|
|
19
|
+
* @property {Object} relationships normalized refs ({id, entityType, bundle})
|
|
20
|
+
* @property {string} _backend "jsonapi" | "graphql"
|
|
21
|
+
*
|
|
22
|
+
* @typedef {Object} QueryDescriptor
|
|
23
|
+
* @property {string} entityType
|
|
24
|
+
* @property {string} bundle
|
|
25
|
+
* @property {Array<{field:string, op:string, value:*}>} [filters]
|
|
26
|
+
* @property {Array<{field:string, dir:"asc"|"desc"}>} [sort]
|
|
27
|
+
* @property {string[]} [fields]
|
|
28
|
+
* @property {string[]} [include]
|
|
29
|
+
* @property {{limit?:number, offset?:number}} [page]
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Attribute names promoted out of `fields` into canonical base properties. */
|
|
33
|
+
export const BASE_ATTRIBUTE_FIELDS = ["title", "status", "langcode", "created", "changed", "path"];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build a canonical entity, filling defaults for any omitted optional props.
|
|
37
|
+
* @param {object} parts Source values; id/entityType/bundle required, rest optional.
|
|
38
|
+
* @param {string} parts.backend Backend tag stored as `_backend` ("jsonapi" | "graphql").
|
|
39
|
+
* @returns {CanonicalEntity}
|
|
40
|
+
*/
|
|
41
|
+
export function makeCanonicalEntity(parts) {
|
|
42
|
+
const {
|
|
43
|
+
id, entityType, bundle,
|
|
44
|
+
title = null, status = null, langcode = null,
|
|
45
|
+
created = null, changed = null, url = null,
|
|
46
|
+
fields = {}, relationships = {}, backend,
|
|
47
|
+
} = parts;
|
|
48
|
+
return {
|
|
49
|
+
id, entityType, bundle,
|
|
50
|
+
title, status, langcode, created, changed, url,
|
|
51
|
+
fields, relationships,
|
|
52
|
+
_backend: backend,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalize a JSON:API-style relationship reference (or array of them) into
|
|
58
|
+
* canonical `{ id, entityType, bundle }`.
|
|
59
|
+
* @param {?(object|object[])} ref A `{ id, type }` ref, an array of them, or null.
|
|
60
|
+
* @returns {?(object|object[])} Normalized ref(s), or null when ref is falsy.
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeRelationship(ref) {
|
|
63
|
+
if (!ref) return null;
|
|
64
|
+
if (Array.isArray(ref)) return ref.map(normalizeRelationship);
|
|
65
|
+
// JSON:API encodes type as "entityType--bundle"; split into the two parts.
|
|
66
|
+
const [entityType = null, bundle = null] = (ref.type || "").split("--");
|
|
67
|
+
return { id: ref.id, entityType, bundle };
|
|
68
|
+
}
|