@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 +51 -0
- package/README.md +86 -0
- package/dist/plugins/jsonapi-schema/src/index.js +50 -0
- package/dist/plugins/jsonapi-schema/src/jsonapi-schema.js +114 -0
- package/package.json +29 -0
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
|
+
}
|