@dropsh/plugin-jsonapi-schema 0.5.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/LICENSE ADDED
@@ -0,0 +1,51 @@
1
+ drupal-cli is dual-licensed. You may use this software under the terms of
2
+ either the MIT License or the GNU General Public License version 2 or any
3
+ later version, at your option.
4
+
5
+ SPDX-License-Identifier: (MIT OR GPL-2.0-or-later)
6
+
7
+ ================================================================================
8
+ MIT License
9
+ ================================================================================
10
+
11
+ Copyright (c) 2026 Christian Wiedemann
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+
31
+ ================================================================================
32
+ GNU General Public License, version 2 or later
33
+ ================================================================================
34
+
35
+ The full text of the GNU GPL v2 is available at:
36
+ https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt
37
+
38
+ The full text of any later GPL version is available at:
39
+ https://www.gnu.org/licenses/
40
+
41
+ Copyright (C) 2026 Christian Wiedemann
42
+
43
+ This program is free software; you can redistribute it and/or modify it under
44
+ the terms of the GNU General Public License as published by the Free Software
45
+ Foundation; either version 2 of the License, or (at your option) any later
46
+ version.
47
+
48
+ This program is distributed in the hope that it will be useful, but WITHOUT
49
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
50
+ FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
51
+ details.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # @dropsh/plugin-jsonapi-schema
2
+
3
+ Authoritative JSON:API write-schema source for [dropsh](https://github.com/pen-drop/dropsh),
4
+ the entity-agnostic CLI for Drupal 11 JSON:API.
5
+
6
+ dropsh works without this plugin. By default it derives a shallow schema from
7
+ JSON:API sample records, which is enough to discover field names but does not
8
+ know Drupal's required fields, formats, or constraints.
9
+
10
+ With this plugin installed, `dropsh schema` reads the **authoritative** schema
11
+ from Drupal's [`jsonapi_schema`](https://www.drupal.org/project/jsonapi_schema)
12
+ module. The generated schema carries `required` fields, `maxLength`, and
13
+ `format` constraints, so client-side validation in `create` / `update` catches
14
+ invalid payloads before they are sent to Drupal.
15
+
16
+ ## Why not `schemata`?
17
+
18
+ The older `@dropsh/plugin-schemata` targets the `schemata_json_schema` module,
19
+ whose `/schemata/{entity}/{bundle}` endpoint returns **HTTP 500 on Drupal 11 /
20
+ PHP 8.4**. `jsonapi_schema` is actively maintained, supports Drupal 10.1+/11,
21
+ and serves its schema routes under the JSON:API prefix (`/jsonapi/*`), so it
22
+ authenticates with the same credentials as every other dropsh request.
23
+
24
+ Unlike `schemata`, this plugin never swallows a broken endpoint silently: a
25
+ missing module (404) or a broken endpoint (5xx / unparseable body) both fall
26
+ back to the heuristic schema **with a visible warning** explaining why.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pnpm add @dropsh/plugin-jsonapi-schema
32
+ ```
33
+
34
+ `dropsh` is a peer dependency.
35
+
36
+ ## Usage
37
+
38
+ ```js
39
+ import { jsonapiSchemaPlugin } from "@dropsh/plugin-jsonapi-schema";
40
+
41
+ export default {
42
+ site: { base_url: "https://my-drupal.example.com", jsonapi_prefix: "/jsonapi" },
43
+ plugins: [jsonapiSchemaPlugin()],
44
+ };
45
+ ```
46
+
47
+ ```bash
48
+ dropsh schema node/article --for=create
49
+ ```
50
+
51
+ The output carries `x-dropsh-source: "jsonapi-schema"` when the authoritative
52
+ schema was used (vs `"heuristic"` / `"heuristic-empty"` for the sample-derived
53
+ fallback).
54
+
55
+ ## Required Drupal module
56
+
57
+ - [`jsonapi_schema`](https://www.drupal.org/project/jsonapi_schema) (`^1.0`)
58
+
59
+ Install and enable it on the Drupal site:
60
+
61
+ ```bash
62
+ composer require drupal/jsonapi_schema
63
+ drush en jsonapi_schema
64
+ ```
65
+
66
+ **Authentication note:** the schema routes (`…/resource/schema`) do not declare
67
+ `_auth`, so Drupal core `basic_auth` (which is `global: false`) cannot
68
+ authenticate them — an authenticated basic request is rejected with 403 (the
69
+ same limitation the `schemata` module has). Use an auth provider that is
70
+ registered globally, such as OAuth2 Bearer via `simple_oauth` (see
71
+ `@dropsh/plugin-oauth2`), or grant the anonymous role access to the routes if
72
+ your schemas are non-sensitive.
73
+
74
+ ## How it works
75
+
76
+ The plugin fetches the **resource-object** schema at
77
+ `{jsonapi_prefix}/{entity}/{bundle}/resource/schema`, inlines its local
78
+ `#/definitions` graph, drops the external `https://jsonapi.org/schema#` `$ref`
79
+ (which Ajv cannot fetch), and wraps the resource object as the `data` member of
80
+ a JSON:API document envelope — the exact shape a create/update payload must
81
+ satisfy. dropsh's operation transform then tailors `required` per operation
82
+ (`id` is stripped for create; `type`/`id` are required for update).
83
+
84
+ ## License
85
+
86
+ MIT OR GPL-2.0-or-later
@@ -0,0 +1,50 @@
1
+ import { HttpError } from "dropsh/plugin";
2
+ import { buildWriteSchema, fetchJsonapiSchema, JSONAPI_SCHEMA_MISS } from "./jsonapi-schema.js";
3
+ /**
4
+ * Authoritative JSON:API write-schema source backed by the Drupal
5
+ * `jsonapi_schema` module (works on Drupal 10.1+/11 + PHP 8.4, unlike the
6
+ * dead `schemata_json_schema` endpoint which 500s there).
7
+ *
8
+ * Outcomes are distinguished instead of collapsed into one silent fallback:
9
+ * - 200 → the derived write schema replaces the heuristic base.
10
+ * - 404 → the module/route is absent; fall back to the heuristic base **and**
11
+ * warn why (so the user knows the constraint-bearing schema is missing).
12
+ * - any other status / unparseable body → the endpoint is broken; fall back to
13
+ * the heuristic base **and** warn visibly. Never swallowed silently.
14
+ */
15
+ export function jsonapiSchemaPlugin() {
16
+ return {
17
+ id: "jsonapi-schema",
18
+ requiredModules: ["jsonapi_schema"],
19
+ async extendSchema(entityType, bundle, baseSchema, ctx) {
20
+ const target = `${entityType}/${bundle}`;
21
+ try {
22
+ const raw = await fetchJsonapiSchema({
23
+ http: ctx.http,
24
+ auth: ctx.auth,
25
+ baseUrl: ctx.baseUrl,
26
+ jsonapiPrefix: ctx.jsonapiPrefix ?? "/jsonapi",
27
+ entity: entityType,
28
+ bundle,
29
+ });
30
+ if (raw === JSONAPI_SCHEMA_MISS) {
31
+ ctx.warn?.(`warning: jsonapi_schema module not enabled (no schema route for ${target}); ` +
32
+ `falling back to the heuristic schema (no required fields, no constraints)`);
33
+ return baseSchema;
34
+ }
35
+ return buildWriteSchema(raw);
36
+ }
37
+ catch (err) {
38
+ if (err instanceof HttpError) {
39
+ ctx.warn?.(`warning: jsonapi_schema endpoint for ${target} is broken (HTTP ${err.status}); ` +
40
+ `falling back to the heuristic schema`);
41
+ }
42
+ else {
43
+ ctx.warn?.(`warning: jsonapi_schema endpoint for ${target} returned an unusable schema ` +
44
+ `(${err.message}); falling back to the heuristic schema`);
45
+ }
46
+ return baseSchema;
47
+ }
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,114 @@
1
+ import { HttpError } from "dropsh/plugin";
2
+ /** Sentinel returned when the jsonapi_schema route is absent (HTTP 404). */
3
+ export const JSONAPI_SCHEMA_MISS = Symbol("JSONAPI_SCHEMA_MISS");
4
+ /**
5
+ * Fetch the resource-object schema for a bundle from the jsonapi_schema module.
6
+ *
7
+ * Route (dynamic, registered by jsonapi_schema): the resource-object schema
8
+ * lives at `{jsonapiPrefix}/{entity}/{bundle}/resource/schema`. It describes a
9
+ * single JSON:API resource object (type + attributes + relationships), which is
10
+ * exactly what a create/update payload's `data` member must satisfy.
11
+ *
12
+ * Returns the parsed schema on 200, {@link JSONAPI_SCHEMA_MISS} on 404 (module
13
+ * or route absent), and throws on any other HTTP status (a broken endpoint must
14
+ * be surfaced, never silently swallowed) or an unparseable body.
15
+ */
16
+ export async function fetchJsonapiSchema(deps) {
17
+ const base = deps.baseUrl.replace(/\/+$/, "");
18
+ const prefix = `/${deps.jsonapiPrefix.replace(/^\/+|\/+$/g, "")}`;
19
+ const url = `${base}${prefix}/${deps.entity}/${deps.bundle}/resource/schema`;
20
+ const req = await deps.auth.apply({
21
+ method: "GET",
22
+ url,
23
+ headers: { Accept: "application/json" },
24
+ });
25
+ let res;
26
+ try {
27
+ res = await deps.http.send(req);
28
+ }
29
+ catch (err) {
30
+ if (err instanceof HttpError && err.status === 404)
31
+ return JSONAPI_SCHEMA_MISS;
32
+ throw err;
33
+ }
34
+ return JSON.parse(res.body);
35
+ }
36
+ const ABSOLUTE_REF = /^https?:\/\//;
37
+ /**
38
+ * Strip hyper-schema noise that Ajv either ignores or cannot compile:
39
+ * `links` (JSON hyper-schema; carries absolute `$ref`s to related routes),
40
+ * document identity keywords (`$schema`, `$id`), and any leftover absolute
41
+ * `$ref` (neutralised to an always-true `{}` so compilation never fails).
42
+ */
43
+ function sanitize(node) {
44
+ if (Array.isArray(node))
45
+ return node.map(sanitize);
46
+ if (node === null || typeof node !== "object")
47
+ return node;
48
+ if (typeof node.$ref === "string" && ABSOLUTE_REF.test(node.$ref))
49
+ return {};
50
+ const out = {};
51
+ for (const [key, value] of Object.entries(node)) {
52
+ if (key === "links" || key === "$schema" || key === "$id")
53
+ continue;
54
+ out[key] = sanitize(value);
55
+ }
56
+ return out;
57
+ }
58
+ /** Resolve a single-level local `#/definitions/X` reference against `defs`. */
59
+ function resolveLocal(node, defs) {
60
+ if (node && typeof node === "object" && typeof node.$ref === "string") {
61
+ const m = /^#\/definitions\/(.+)$/.exec(node.$ref);
62
+ const name = m?.[1];
63
+ if (name && defs && defs[name] !== undefined)
64
+ return defs[name];
65
+ }
66
+ return node;
67
+ }
68
+ /**
69
+ * Transform a jsonapi_schema resource-object schema into a JSON Schema that
70
+ * validates a dropsh create/update payload — a JSON:API document envelope
71
+ * `{ data: { type, id, attributes, relationships } }`.
72
+ *
73
+ * The module emits `{ allOf: [ { properties: { type, attributes, relationships }
74
+ * (local $refs) }, { $ref: "https://jsonapi.org/schema#/definitions/resource" } ],
75
+ * definitions: {...} }`. We inline the local definitions, drop the external
76
+ * jsonapi.org `$ref` (Ajv cannot fetch it), and wrap the resource object as the
77
+ * `data` member. dropsh's `toOperationVariant` later tailors `required` per
78
+ * operation, so we only assert the resource `type` here.
79
+ */
80
+ export function buildWriteSchema(resourceSchema) {
81
+ const root = resourceSchema;
82
+ if (root === null || typeof root !== "object") {
83
+ throw new Error("jsonapi_schema response is not an object");
84
+ }
85
+ const defs = root.definitions ?? {};
86
+ const allOf = Array.isArray(root.allOf) ? root.allOf : [];
87
+ const resourceMember = allOf.find((m) => m && typeof m === "object" && m.properties && typeof m.properties === "object");
88
+ const props = resourceMember?.properties;
89
+ if (!props || (props.attributes === undefined && props.type === undefined)) {
90
+ throw new Error("jsonapi_schema response has no resource-object properties");
91
+ }
92
+ const dataProperties = {
93
+ type: sanitize(resolveLocal(props.type ?? { type: "string" }, defs)),
94
+ id: { type: "string" },
95
+ };
96
+ if (props.attributes !== undefined) {
97
+ dataProperties.attributes = sanitize(resolveLocal(props.attributes, defs));
98
+ }
99
+ if (props.relationships !== undefined) {
100
+ dataProperties.relationships = sanitize(resolveLocal(props.relationships, defs));
101
+ }
102
+ return {
103
+ $schema: "http://json-schema.org/draft-07/schema#",
104
+ type: "object",
105
+ properties: {
106
+ data: {
107
+ type: "object",
108
+ properties: dataProperties,
109
+ required: ["type"],
110
+ },
111
+ },
112
+ required: ["data"],
113
+ };
114
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@dropsh/plugin-jsonapi-schema",
3
+ "version": "0.5.1",
4
+ "type": "module",
5
+ "main": "./dist/plugins/jsonapi-schema/src/index.js",
6
+ "exports": {
7
+ ".": "./dist/plugins/jsonapi-schema/src/index.js"
8
+ },
9
+ "files": [
10
+ "dist/plugins/jsonapi-schema/src",
11
+ "README.md"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "peerDependencies": {
17
+ "dropsh": "*"
18
+ },
19
+ "devDependencies": {
20
+ "typescript": "^5.6.0",
21
+ "vitest": "^4.1.8",
22
+ "dropsh": "0.5.1"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc",
26
+ "test": "vitest run --passWithNoTests",
27
+ "typecheck": "tsc --noEmit"
28
+ }
29
+ }