dropsh 0.5.8 → 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/README.md +156 -3
- package/dist/src/commands/create.d.ts +6 -1
- package/dist/src/commands/create.js +1 -2
- package/dist/src/commands/update.d.ts +13 -1
- package/dist/src/commands/update.js +13 -5
- package/dist/src/core/auth/types.d.ts +17 -4
- package/dist/src/core/jsonapi/client.d.ts +3 -1
- package/dist/src/core/jsonapi/client.js +31 -13
- package/dist/src/core/params/from-parameters.d.ts +12 -0
- package/dist/src/core/params/from-parameters.js +209 -0
- package/dist/src/core/params/help.d.ts +46 -0
- package/dist/src/core/params/help.js +148 -0
- package/dist/src/core/params/parse-args.d.ts +18 -0
- package/dist/src/core/params/parse-args.js +75 -0
- package/dist/src/core/params/schema-fields.d.ts +20 -0
- package/dist/src/core/params/schema-fields.js +65 -0
- package/dist/src/core/plugin.d.ts +8 -1
- package/dist/src/core/request-hooks.d.ts +4 -0
- package/dist/src/core/request-hooks.js +26 -0
- package/dist/src/errors.d.ts +3 -0
- package/dist/src/errors.js +12 -0
- package/dist/src/index.js +138 -44
- package/dist/src/plugin-api.d.ts +8 -3
- package/dist/src/plugin-api.js +3 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -295,17 +295,170 @@ exit codes.
|
|
|
295
295
|
```bash
|
|
296
296
|
dropsh read <entity_type>/<bundle>/<uuid> [--include=<field>,…]
|
|
297
297
|
dropsh search <entity_type> [--bundle=<b>] [--filter=key:value]… [--limit=N] [--include=<field>,…]
|
|
298
|
-
dropsh create <entity_type> --bundle=<b> --data=<json|@file> [--dry-run] [--no-validate]
|
|
299
|
-
dropsh update <entity_type>/<bundle>/<uuid> --data=<json|@file> [--dry-run] [--no-validate]
|
|
298
|
+
dropsh create <entity_type> --bundle=<b> (--data=<json|@file> | <field parameters>) [--dry-run] [--no-validate]
|
|
299
|
+
dropsh update <entity_type>/<bundle>/<uuid> (--data=<json|@file> | <field parameters>) [--dry-run] [--no-validate]
|
|
300
300
|
dropsh delete <entity_type>/<bundle>/<uuid> [--dry-run]
|
|
301
301
|
dropsh upload-file --target=<entity_type>/<bundle>/<uuid>/<field> --file=<path> [--dry-run]
|
|
302
302
|
dropsh schema [--refresh]
|
|
303
|
-
dropsh schema <entity_type>/<bundle> [--for=create|update] [--refresh]
|
|
303
|
+
dropsh schema <entity_type>/<bundle> [--for=create|update] [--refresh] [--fields]
|
|
304
304
|
```
|
|
305
305
|
|
|
306
306
|
`create` and `update` validate payloads against the current schema by default.
|
|
307
307
|
Pass `--no-validate` when you intentionally want to skip local validation.
|
|
308
308
|
|
|
309
|
+
### Field parameters on `create` / `update`
|
|
310
|
+
|
|
311
|
+
Instead of handing `create`/`update` a finished JSON:API document via `--data`,
|
|
312
|
+
you can name the fields directly. The CLI reads the bundle's operation schema and
|
|
313
|
+
builds the document itself, so it knows which name is an attribute and which is a
|
|
314
|
+
relationship:
|
|
315
|
+
|
|
316
|
+
| Form | Meaning |
|
|
317
|
+
|---|---|
|
|
318
|
+
| `--<field> <value>` | set a field |
|
|
319
|
+
| `--<field>=<value>` | same; required when the value itself starts with `--` |
|
|
320
|
+
| `--<field>.<sub> <value>` | set a sub-property, e.g. `--body.value "Text"` |
|
|
321
|
+
| `--<relationship> <uuid>` | reference by UUID; repeat for a multi-valued field |
|
|
322
|
+
| `--json <field>=<json>` | raw JSON value, for arrays of objects |
|
|
323
|
+
|
|
324
|
+
Any number of fields goes into a single invocation; the `--<field>=<value>` pair
|
|
325
|
+
form and the spaced form are interchangeable and produce identical output.
|
|
326
|
+
|
|
327
|
+
**`--fields` lists the bundle's actual fields.** `--help` documents the forms;
|
|
328
|
+
`--fields` resolves the bundle's schema and prints every settable parameter,
|
|
329
|
+
then exits without sending anything:
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
dropsh schema node/article_test --fields # the introspection route
|
|
333
|
+
dropsh create node --bundle=article_test --fields # same listing, on the command
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
```
|
|
337
|
+
node--article_test — field parameters
|
|
338
|
+
|
|
339
|
+
Attributes:
|
|
340
|
+
--drupal_internal__nid <value> integer ID
|
|
341
|
+
--drupal_internal__vid <value> integer Revision ID
|
|
342
|
+
--langcode.value <value> string Language code
|
|
343
|
+
--langcode.language <value> Language object
|
|
344
|
+
--revision_timestamp <value> number Revision create time
|
|
345
|
+
--revision_log <value> string Revision log message
|
|
346
|
+
--status [true|false] boolean Published
|
|
347
|
+
--title <value> string required Title
|
|
348
|
+
--created <value> number Authored on
|
|
349
|
+
--changed <value> number Changed
|
|
350
|
+
--promote [true|false] boolean Promoted to front page
|
|
351
|
+
--sticky [true|false] boolean Sticky at top of lists
|
|
352
|
+
--default_langcode [true|false] boolean Default translation
|
|
353
|
+
--revision_default [true|false] boolean Default revision
|
|
354
|
+
--revision_translation_affected [true|false] boolean Revision translation affected
|
|
355
|
+
--path.alias <value> string Path alias
|
|
356
|
+
--path.pid <value> integer Path id
|
|
357
|
+
--path.langcode <value> string Language Code
|
|
358
|
+
--body.value <value> string Text
|
|
359
|
+
--body.format <value> string Text format
|
|
360
|
+
--body.summary <value> string Summary
|
|
361
|
+
--field_test_text <value> string Test Text
|
|
362
|
+
|
|
363
|
+
Relationships (take a UUID):
|
|
364
|
+
--node_type <uuid> node_type--node_type Content type
|
|
365
|
+
--revision_uid <uuid> user--user Revision user
|
|
366
|
+
--uid <uuid> user--user Authored by
|
|
367
|
+
--field_image <uuid> file--file Image
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
The listing is the schema's, not a curated subset: Drupal's base fields
|
|
371
|
+
(`--drupal_internal__nid`, `--langcode.*`, `--path.*`, `--node_type`,
|
|
372
|
+
`--revision_uid`, …) are settable parameters and appear alongside the
|
|
373
|
+
bundle's own fields.
|
|
374
|
+
|
|
375
|
+
Each row carries the schema type, whether the field is required, and the field's
|
|
376
|
+
human label. Object attributes appear as their dotted leaves (`--body.value`,
|
|
377
|
+
never `--body`), array attributes as their `--json` form, and a relationship that
|
|
378
|
+
allows several target types is spelled `<type>:<uuid>`. The same listing is reachable three ways, all derived from the schema alone with
|
|
379
|
+
no extra request: `dropsh schema <entity>/<bundle> --fields` (honouring `--for`
|
|
380
|
+
and `--refresh`), and `--fields` on `create` and `update`.
|
|
381
|
+
|
|
382
|
+
Add an explicit `--format json` to get the same listing as JSON — `parameter`,
|
|
383
|
+
`path`, `type`, `label`, `required`, `bare_flag` per attribute, and `targets`,
|
|
384
|
+
`multiple`, `requires_type_prefix` per relationship — for a caller that wants to
|
|
385
|
+
consume it rather than read it.
|
|
386
|
+
|
|
387
|
+
Rules worth knowing:
|
|
388
|
+
|
|
389
|
+
- **Field names come from the schema** (`dropsh schema <entity>/<bundle>`), so an
|
|
390
|
+
unknown name is **rejected** with exit code `4` instead of being ignored. The
|
|
391
|
+
error names the rejected parameter and lists the known attributes and
|
|
392
|
+
relationships.
|
|
393
|
+
- **Relationships take a bare UUID**; the resource type comes from the schema.
|
|
394
|
+
When a relationship allows several target types, pass `<type>:<uuid>`.
|
|
395
|
+
Repeating the flag accumulates values on a multi-valued relationship; a second
|
|
396
|
+
value on a single-valued one is an error.
|
|
397
|
+
- **Values are typed by the schema** — `boolean`, `integer`/`number`, else
|
|
398
|
+
string. A bare `--flag` means `true` only on a boolean field. Values are never
|
|
399
|
+
comma-split, so `--title "a, b"` survives intact.
|
|
400
|
+
- **`--data` and field parameters are mutually exclusive**; giving neither is an
|
|
401
|
+
error.
|
|
402
|
+
- **`--dry-run` prints the generated document** and sends nothing. Output keys are
|
|
403
|
+
emitted in schema declaration order, so two dry-runs of the same fields are
|
|
404
|
+
byte-identical and diffable.
|
|
405
|
+
- **`--no-validate` skips Ajv, not the schema.** In parameter mode the schema is
|
|
406
|
+
always loaded — attribute/relationship placement is impossible without it.
|
|
407
|
+
- A field whose name collides with a reserved option (`--bundle`, `--data`,
|
|
408
|
+
`--dry-run`, `--no-validate`, `--fields`, `--format`, `--auth-profile`,
|
|
409
|
+
`--config`, `--view-mode`, `--json`) is out of reach as a bare `--<field>`, but
|
|
410
|
+
`--json <field>=<json>` still sets it (write the value as JSON), and so does
|
|
411
|
+
`--data`. No Drupal base field name collides with that list in practice.
|
|
412
|
+
- Fields contributed by a plugin's `extendOperationSchema` hook are usable as
|
|
413
|
+
parameters like any native field.
|
|
414
|
+
|
|
415
|
+
Worked example:
|
|
416
|
+
|
|
417
|
+
```bash
|
|
418
|
+
dropsh create node --bundle=article_test \
|
|
419
|
+
--title "Hello" --body.value "Text" \
|
|
420
|
+
--uid c8e0b5d2-9e66-4caf-ac43-337f3b3af62d --dry-run | jq
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
```json
|
|
424
|
+
{
|
|
425
|
+
"dry_run": true,
|
|
426
|
+
"method": "POST",
|
|
427
|
+
"path": "node/article_test",
|
|
428
|
+
"payload": {
|
|
429
|
+
"data": {
|
|
430
|
+
"type": "node--article_test",
|
|
431
|
+
"attributes": {
|
|
432
|
+
"title": "Hello",
|
|
433
|
+
"body": {
|
|
434
|
+
"value": "Text"
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
"relationships": {
|
|
438
|
+
"uid": {
|
|
439
|
+
"data": {
|
|
440
|
+
"type": "user--user",
|
|
441
|
+
"id": "c8e0b5d2-9e66-4caf-ac43-337f3b3af62d"
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
`update` works the same way and puts the target UUID into `data.id`, sending a
|
|
451
|
+
PATCH with only the fields you named:
|
|
452
|
+
|
|
453
|
+
```bash
|
|
454
|
+
dropsh update node/article_test/<uuid> --title "changed title"
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
The builder is also available programmatically — `buildPayloadFromParameters`,
|
|
458
|
+
`indexSchemaFields` and `propertiesOf` are exported from `dropsh/plugin`, so a
|
|
459
|
+
plugin can turn (schema, parameters) into the same document without invoking the
|
|
460
|
+
CLI.
|
|
461
|
+
|
|
309
462
|
### `--include` on `read` / `search`
|
|
310
463
|
|
|
311
464
|
Pass `--include` to embed related resources in the response's JSON:API
|
|
@@ -2,7 +2,12 @@ import type { JsonApiClient } from "../core/jsonapi/client.js";
|
|
|
2
2
|
export interface CreateArgs {
|
|
3
3
|
entityType: string;
|
|
4
4
|
bundle: string;
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* The JSON:API document to send. Resolving it — from `--data` or from field
|
|
7
|
+
* parameters — happens before this command runs, so nothing here knows or
|
|
8
|
+
* cares which route produced it.
|
|
9
|
+
*/
|
|
10
|
+
payload: unknown;
|
|
6
11
|
dryRun?: boolean;
|
|
7
12
|
noValidate?: boolean;
|
|
8
13
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { readDataArg } from "./_data.js";
|
|
2
1
|
export async function runCreate(args, deps) {
|
|
3
|
-
const payload =
|
|
2
|
+
const { payload } = args;
|
|
4
3
|
const target = `${args.entityType}/${args.bundle}`;
|
|
5
4
|
if (!args.noValidate && deps.validate) {
|
|
6
5
|
await deps.validate(payload, target);
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { JsonApiClient } from "../core/jsonapi/client.js";
|
|
2
2
|
export interface UpdateArgs {
|
|
3
3
|
target: string;
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The JSON:API document to send. Resolving it — from `--data` or from field
|
|
6
|
+
* parameters — happens before this command runs, so nothing here knows or
|
|
7
|
+
* cares which route produced it.
|
|
8
|
+
*/
|
|
9
|
+
payload: unknown;
|
|
5
10
|
dryRun?: boolean;
|
|
6
11
|
noValidate?: boolean;
|
|
7
12
|
}
|
|
@@ -10,4 +15,11 @@ export interface UpdateDeps {
|
|
|
10
15
|
emit: (v: unknown) => void | Promise<void>;
|
|
11
16
|
validate?: (payload: unknown, target: string) => void | Promise<void>;
|
|
12
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Reject a malformed target. Exported so the CLI can run this guard *before* it
|
|
20
|
+
* builds a payload from field parameters: without it, a bad target would surface
|
|
21
|
+
* as the builder's "update requires the entity id" instead of this clearer
|
|
22
|
+
* message.
|
|
23
|
+
*/
|
|
24
|
+
export declare function assertUpdateTarget(target: string): void;
|
|
13
25
|
export declare function runUpdate(args: UpdateArgs, deps: UpdateDeps): Promise<void>;
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { ValidationError } from "../errors.js";
|
|
2
|
-
import { readDataArg } from "./_data.js";
|
|
3
2
|
const TARGET_RE = /^[a-z0-9_]+\/[a-z0-9_]+\/[a-f0-9-]{8,}$/;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Reject a malformed target. Exported so the CLI can run this guard *before* it
|
|
5
|
+
* builds a payload from field parameters: without it, a bad target would surface
|
|
6
|
+
* as the builder's "update requires the entity id" instead of this clearer
|
|
7
|
+
* message.
|
|
8
|
+
*/
|
|
9
|
+
export function assertUpdateTarget(target) {
|
|
10
|
+
if (!TARGET_RE.test(target)) {
|
|
11
|
+
throw new ValidationError(`target must be <entity_type>/<bundle>/<uuid>, got "${target}"`);
|
|
7
12
|
}
|
|
8
|
-
|
|
13
|
+
}
|
|
14
|
+
export async function runUpdate(args, deps) {
|
|
15
|
+
assertUpdateTarget(args.target);
|
|
16
|
+
const { payload } = args;
|
|
9
17
|
const [entity, bundle] = args.target.split("/", 3);
|
|
10
18
|
const schemaTarget = `${entity}/${bundle}`;
|
|
11
19
|
if (!args.noValidate && deps.validate) {
|
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
import type { HttpClient, HttpRequest } from "../http.js";
|
|
2
|
+
/**
|
|
3
|
+
* Outcome of a reactive credential renewal. On failure the renewal cause is
|
|
4
|
+
* carried back so the caller can surface *why* renewal failed instead of the
|
|
5
|
+
* bare, unexplained 401 that first triggered it.
|
|
6
|
+
*/
|
|
7
|
+
export type RenewOutcome = {
|
|
8
|
+
ok: true;
|
|
9
|
+
} | {
|
|
10
|
+
ok: false;
|
|
11
|
+
cause?: unknown;
|
|
12
|
+
};
|
|
2
13
|
export interface AuthAdapter {
|
|
3
14
|
apply(req: HttpRequest): Promise<HttpRequest>;
|
|
4
15
|
/**
|
|
5
16
|
* Force a credential refresh after the server rejected the current token (401),
|
|
6
|
-
* then persist it.
|
|
7
|
-
* once)
|
|
8
|
-
*
|
|
17
|
+
* then persist it. Resolves `{ ok: true }` when renewal succeeded (caller
|
|
18
|
+
* retries the request once) or `{ ok: false, cause }` when it could not renew,
|
|
19
|
+
* where `cause` is the error explaining the failure (the caller surfaces it in
|
|
20
|
+
* place of the original 401). Providers that cannot renew (e.g. basic auth)
|
|
21
|
+
* omit this method.
|
|
9
22
|
*/
|
|
10
|
-
renew?(): Promise<
|
|
23
|
+
renew?(): Promise<RenewOutcome>;
|
|
11
24
|
}
|
|
12
25
|
/** Provider-specific opaque session payload persisted to the state dir. */
|
|
13
26
|
export type AuthSession = Record<string, unknown>;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { DrupalJsonApiParams } from "drupal-jsonapi-params";
|
|
2
2
|
import type { AuthAdapter } from "../auth/types.js";
|
|
3
|
-
import type { HttpClient } from "../http.js";
|
|
3
|
+
import type { HttpClient, HttpRequest } from "../http.js";
|
|
4
4
|
import { type Collection } from "./collection.js";
|
|
5
5
|
import { type Resource } from "./resource.js";
|
|
6
|
+
export type DropSHOperation = "read" | "search" | "create" | "update" | "delete" | "upload";
|
|
6
7
|
/** Attributes/relationships payload accepted by create/update/upsert. */
|
|
7
8
|
export interface ResourceWriteBody {
|
|
8
9
|
attributes?: Record<string, unknown>;
|
|
@@ -35,5 +36,6 @@ export interface JsonApiOptions {
|
|
|
35
36
|
prefix: string;
|
|
36
37
|
http: HttpClient;
|
|
37
38
|
auth: AuthAdapter;
|
|
39
|
+
alterRequest?: (req: HttpRequest, operation: DropSHOperation, entityType?: string, bundle?: string) => Promise<HttpRequest>;
|
|
38
40
|
}
|
|
39
41
|
export declare function createJsonApiClient(opts: JsonApiOptions): JsonApiClient;
|
|
@@ -12,27 +12,45 @@ const JSONAPI_HEADERS = {
|
|
|
12
12
|
Accept: "application/vnd.api+json",
|
|
13
13
|
"Content-Type": "application/vnd.api+json",
|
|
14
14
|
};
|
|
15
|
+
function targetFromPath(path) {
|
|
16
|
+
const [entityType, bundle] = path.split("/").filter(Boolean);
|
|
17
|
+
return {
|
|
18
|
+
...(entityType !== undefined ? { entityType } : {}),
|
|
19
|
+
...(bundle !== undefined ? { bundle } : {}),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function getOperation(path) {
|
|
23
|
+
const segments = path.split("/").filter(Boolean);
|
|
24
|
+
return segments.length === 0 || segments.length >= 3 ? "read" : "search";
|
|
25
|
+
}
|
|
15
26
|
export function createJsonApiClient(opts) {
|
|
16
|
-
async function send(method,
|
|
27
|
+
async function send(method, path, operation, body, extraHeaders = {}, queryString = "") {
|
|
17
28
|
const reqBase = {
|
|
18
29
|
method,
|
|
19
|
-
url,
|
|
30
|
+
url: joinUrl(opts.baseUrl, opts.prefix, path) + queryString,
|
|
20
31
|
headers: { ...JSONAPI_HEADERS, ...extraHeaders },
|
|
21
32
|
};
|
|
22
33
|
const req = body !== undefined ? { ...reqBase, body } : reqBase;
|
|
34
|
+
const authenticatedRequest = await opts.auth.apply(req);
|
|
35
|
+
const { entityType, bundle } = targetFromPath(path);
|
|
36
|
+
const alteredRequest = opts.alterRequest
|
|
37
|
+
? await opts.alterRequest(authenticatedRequest, operation, entityType, bundle)
|
|
38
|
+
: authenticatedRequest;
|
|
23
39
|
let res;
|
|
24
40
|
try {
|
|
25
|
-
res = await opts.http.send(
|
|
41
|
+
res = await opts.http.send(alteredRequest);
|
|
26
42
|
}
|
|
27
43
|
catch (err) {
|
|
28
44
|
// The server rejected the token (401). Ask the auth adapter to renew and,
|
|
29
|
-
// if it could, retry the request exactly once with a fresh token.
|
|
45
|
+
// if it could, retry the request exactly once with a fresh token. If it
|
|
46
|
+
// could not, surface the renewal cause (e.g. an unconfigured secret) so
|
|
47
|
+
// the caller sees *why* renewal failed, not just the original 401.
|
|
30
48
|
if (!(err instanceof HttpError) || err.status !== 401 || !opts.auth.renew)
|
|
31
49
|
throw err;
|
|
32
|
-
const
|
|
33
|
-
if (!
|
|
34
|
-
throw err;
|
|
35
|
-
res = await opts.http.send(await opts.auth.apply(
|
|
50
|
+
const outcome = await opts.auth.renew();
|
|
51
|
+
if (!outcome.ok)
|
|
52
|
+
throw outcome.cause ?? err;
|
|
53
|
+
res = await opts.http.send(await opts.auth.apply(alteredRequest));
|
|
36
54
|
}
|
|
37
55
|
if (res.status === 204 || res.body.length === 0)
|
|
38
56
|
return { ok: true };
|
|
@@ -41,19 +59,19 @@ export function createJsonApiClient(opts) {
|
|
|
41
59
|
const client = {
|
|
42
60
|
async get(path, params) {
|
|
43
61
|
const qs = params ? `?${params.getQueryString()}` : "";
|
|
44
|
-
return send("GET",
|
|
62
|
+
return send("GET", path, getOperation(path), undefined, {}, qs);
|
|
45
63
|
},
|
|
46
64
|
async post(path, body) {
|
|
47
|
-
return send("POST",
|
|
65
|
+
return send("POST", path, "create", JSON.stringify(body));
|
|
48
66
|
},
|
|
49
67
|
async patch(path, body) {
|
|
50
|
-
return send("PATCH",
|
|
68
|
+
return send("PATCH", path, "update", JSON.stringify(body));
|
|
51
69
|
},
|
|
52
70
|
async delete(path) {
|
|
53
|
-
return send("DELETE",
|
|
71
|
+
return send("DELETE", path, "delete");
|
|
54
72
|
},
|
|
55
73
|
async upload(path, filename, data) {
|
|
56
|
-
return send("POST",
|
|
74
|
+
return send("POST", path, "upload", data, {
|
|
57
75
|
"Content-Type": "application/octet-stream",
|
|
58
76
|
"Content-Disposition": `file; filename="${filename}"`,
|
|
59
77
|
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RawParameter } from "./parse-args.js";
|
|
2
|
+
export interface BuildPayloadInput {
|
|
3
|
+
/** The resolved operation schema, plugin extensions already applied. */
|
|
4
|
+
schema: unknown;
|
|
5
|
+
parameters: RawParameter[];
|
|
6
|
+
operation: "create" | "update";
|
|
7
|
+
/** Required for `update`: becomes `data.id`. */
|
|
8
|
+
id?: string;
|
|
9
|
+
/** Fallback resource type when the schema carries no `type` const/enum. */
|
|
10
|
+
resourceType?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function buildPayloadFromParameters(input: BuildPayloadInput): unknown;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { ValidationError } from "../../errors.js";
|
|
2
|
+
import { indexSchemaFields, propertiesOf, } from "./schema-fields.js";
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
function schemaTypeOf(node) {
|
|
7
|
+
if (!isRecord(node))
|
|
8
|
+
return undefined;
|
|
9
|
+
const t = node.type;
|
|
10
|
+
if (typeof t === "string")
|
|
11
|
+
return t;
|
|
12
|
+
if (Array.isArray(t))
|
|
13
|
+
return t.find((x) => typeof x === "string" && x !== "null");
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Convert one command-line string into the value the leaf schema node asks for.
|
|
18
|
+
* A non-convertible value is rejected here rather than left for Ajv, because
|
|
19
|
+
* `--no-validate` must not turn a wrong type into a silently wrong payload.
|
|
20
|
+
*/
|
|
21
|
+
function coerce(node, param, label) {
|
|
22
|
+
const type = schemaTypeOf(node);
|
|
23
|
+
if (!param.hasValue) {
|
|
24
|
+
if (type === "boolean")
|
|
25
|
+
return true;
|
|
26
|
+
throw new ValidationError(`missing value for --${label}${type !== undefined ? ` (expected ${type})` : ""}`);
|
|
27
|
+
}
|
|
28
|
+
if (type === "boolean") {
|
|
29
|
+
if (param.value === "true")
|
|
30
|
+
return true;
|
|
31
|
+
if (param.value === "false")
|
|
32
|
+
return false;
|
|
33
|
+
throw new ValidationError(`--${label} expects true or false, got "${param.value}"`);
|
|
34
|
+
}
|
|
35
|
+
if (type === "integer" || type === "number") {
|
|
36
|
+
const n = Number(param.value);
|
|
37
|
+
if (param.value.trim() === "" || !Number.isFinite(n)) {
|
|
38
|
+
throw new ValidationError(`--${label} expects ${type}, got "${param.value}"`);
|
|
39
|
+
}
|
|
40
|
+
if (type === "integer" && !Number.isInteger(n)) {
|
|
41
|
+
throw new ValidationError(`--${label} expects integer, got "${param.value}"`);
|
|
42
|
+
}
|
|
43
|
+
return n;
|
|
44
|
+
}
|
|
45
|
+
return param.value;
|
|
46
|
+
}
|
|
47
|
+
function parseJsonValue(param) {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(param.value);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
throw new ValidationError(`--json ${param.path} is not valid JSON: ${err.message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Re-emit an object with its keys in the order its subschema declares them, so
|
|
57
|
+
* the same fields always serialise to the same bytes.
|
|
58
|
+
*/
|
|
59
|
+
function ordered(node, value) {
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
const items = isRecord(node) ? node.items : undefined;
|
|
62
|
+
return value.map((entry) => ordered(items, entry));
|
|
63
|
+
}
|
|
64
|
+
if (!isRecord(value))
|
|
65
|
+
return value;
|
|
66
|
+
const props = propertiesOf(node);
|
|
67
|
+
const keys = Object.keys(value);
|
|
68
|
+
const declared = props ? Object.keys(props).filter((k) => keys.includes(k)) : [];
|
|
69
|
+
const extra = keys.filter((k) => !declared.includes(k)).sort();
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const key of [...declared, ...extra]) {
|
|
72
|
+
out[key] = ordered(props?.[key], value[key]);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Walk a dotted path into a field's subschema, rejecting a segment the schema
|
|
78
|
+
* does not declare. Returns the leaf node the value must satisfy.
|
|
79
|
+
*/
|
|
80
|
+
function resolveLeaf(field, segments) {
|
|
81
|
+
let node = field.node;
|
|
82
|
+
for (const segment of segments) {
|
|
83
|
+
const props = propertiesOf(node);
|
|
84
|
+
const next = props?.[segment];
|
|
85
|
+
if (next === undefined) {
|
|
86
|
+
throw new ValidationError(`unknown sub-property '${segment}' of '${field.name}'${props ? `; known: ${Object.keys(props).join(", ")}` : ""}`);
|
|
87
|
+
}
|
|
88
|
+
node = next;
|
|
89
|
+
}
|
|
90
|
+
return node;
|
|
91
|
+
}
|
|
92
|
+
function assign(target, segments, value, label) {
|
|
93
|
+
let cursor = target;
|
|
94
|
+
for (const segment of segments.slice(0, -1)) {
|
|
95
|
+
const existing = cursor[segment];
|
|
96
|
+
if (existing === undefined) {
|
|
97
|
+
const created = {};
|
|
98
|
+
cursor[segment] = created;
|
|
99
|
+
cursor = created;
|
|
100
|
+
}
|
|
101
|
+
else if (isRecord(existing)) {
|
|
102
|
+
cursor = existing;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
throw new ValidationError(`parameter '${label}' conflicts with an earlier value`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const leaf = segments[segments.length - 1];
|
|
109
|
+
if (leaf in cursor) {
|
|
110
|
+
throw new ValidationError(`parameter '${label}' was given more than once`);
|
|
111
|
+
}
|
|
112
|
+
cursor[leaf] = value;
|
|
113
|
+
}
|
|
114
|
+
function unknownParameter(name, index) {
|
|
115
|
+
const attributes = [];
|
|
116
|
+
const relationships = [];
|
|
117
|
+
for (const field of index.fields.values()) {
|
|
118
|
+
(field.kind === "attribute" ? attributes : relationships).push(field.name);
|
|
119
|
+
}
|
|
120
|
+
throw new ValidationError(`unknown parameter '${name}'. Known attributes: ${attributes.join(", ") || "(none)"}. ` +
|
|
121
|
+
`Known relationships: ${relationships.join(", ") || "(none)"}.`);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* A relationship parameter is a bare UUID whenever the schema allows exactly one
|
|
125
|
+
* target type, and `<type>:<uuid>` when it allows several. The UUID itself never
|
|
126
|
+
* contains a colon, so the split is unambiguous.
|
|
127
|
+
*/
|
|
128
|
+
function resourceIdentifier(field, param) {
|
|
129
|
+
if (!param.hasValue) {
|
|
130
|
+
throw new ValidationError(`missing value for --${field.name} (expected a UUID)`);
|
|
131
|
+
}
|
|
132
|
+
const allowed = field.targetTypes ?? [];
|
|
133
|
+
const colon = param.value.lastIndexOf(":");
|
|
134
|
+
if (colon > 0) {
|
|
135
|
+
const type = param.value.slice(0, colon);
|
|
136
|
+
const id = param.value.slice(colon + 1);
|
|
137
|
+
if (allowed.length > 0 && !allowed.includes(type)) {
|
|
138
|
+
throw new ValidationError(`'${type}' is not an allowed target type for '${field.name}'; allowed: ${allowed.join(", ")}`);
|
|
139
|
+
}
|
|
140
|
+
return { type, id };
|
|
141
|
+
}
|
|
142
|
+
if (allowed.length === 0) {
|
|
143
|
+
throw new ValidationError(`relationship '${field.name}' declares no target type in the schema; pass <type>:<uuid>`);
|
|
144
|
+
}
|
|
145
|
+
if (allowed.length > 1) {
|
|
146
|
+
throw new ValidationError(`relationship '${field.name}' allows several target types (${allowed.join(", ")}); ` +
|
|
147
|
+
"pass <type>:<uuid>");
|
|
148
|
+
}
|
|
149
|
+
return { type: allowed[0], id: param.value };
|
|
150
|
+
}
|
|
151
|
+
export function buildPayloadFromParameters(input) {
|
|
152
|
+
const index = indexSchemaFields(input.schema);
|
|
153
|
+
const attributes = {};
|
|
154
|
+
const relationships = new Map();
|
|
155
|
+
for (const param of input.parameters) {
|
|
156
|
+
const segments = param.path.split(".");
|
|
157
|
+
const name = segments[0];
|
|
158
|
+
const field = index.fields.get(name);
|
|
159
|
+
if (!field)
|
|
160
|
+
unknownParameter(name, index);
|
|
161
|
+
if (field.kind === "relationship") {
|
|
162
|
+
if (segments.length > 1) {
|
|
163
|
+
throw new ValidationError(`relationship '${name}' takes a UUID, not a sub-path ('${param.path}')`);
|
|
164
|
+
}
|
|
165
|
+
const collected = relationships.get(name) ?? [];
|
|
166
|
+
if (collected.length > 0 && field.multiple !== true) {
|
|
167
|
+
throw new ValidationError(`relationship '${name}' accepts a single value; it was given more than once`);
|
|
168
|
+
}
|
|
169
|
+
collected.push(resourceIdentifier(field, param));
|
|
170
|
+
relationships.set(name, collected);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const tail = segments.slice(1);
|
|
174
|
+
const leaf = resolveLeaf(field, tail);
|
|
175
|
+
const value = param.form === "json"
|
|
176
|
+
? ordered(leaf, parseJsonValue(param))
|
|
177
|
+
: coerce(leaf, param, param.path);
|
|
178
|
+
assign(attributes, segments, value, param.path);
|
|
179
|
+
}
|
|
180
|
+
const resourceType = index.resourceType ?? input.resourceType;
|
|
181
|
+
if (resourceType === undefined) {
|
|
182
|
+
throw new ValidationError("cannot determine the JSON:API resource type: the schema declares no type and no fallback was given");
|
|
183
|
+
}
|
|
184
|
+
const data = { type: resourceType };
|
|
185
|
+
if (input.operation === "update") {
|
|
186
|
+
if (input.id === undefined || input.id === "") {
|
|
187
|
+
throw new ValidationError("update requires the entity id for data.id");
|
|
188
|
+
}
|
|
189
|
+
data.id = input.id;
|
|
190
|
+
}
|
|
191
|
+
const attributeNode = propertiesOf(propertiesOf(input.schema)?.data)?.attributes;
|
|
192
|
+
if (Object.keys(attributes).length > 0) {
|
|
193
|
+
data.attributes = ordered(attributeNode, attributes);
|
|
194
|
+
}
|
|
195
|
+
if (relationships.size > 0) {
|
|
196
|
+
const out = {};
|
|
197
|
+
for (const name of index.order) {
|
|
198
|
+
const collected = relationships.get(name);
|
|
199
|
+
if (!collected)
|
|
200
|
+
continue;
|
|
201
|
+
const field = index.fields.get(name);
|
|
202
|
+
out[name] = {
|
|
203
|
+
data: field.multiple === true ? collected : collected[0],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
data.relationships = out;
|
|
207
|
+
}
|
|
208
|
+
return { data };
|
|
209
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `--help` block for the write commands. It documents the *forms* only and
|
|
3
|
+
* points at `--fields` for the bundle's actual field names: Commander renders
|
|
4
|
+
* help synchronously, so it cannot resolve a schema, while `--fields` runs in
|
|
5
|
+
* the action and goes through the normal fetch-and-cache pipeline.
|
|
6
|
+
*/
|
|
7
|
+
export declare const FIELD_PARAMETER_HELP = "\nField parameters (instead of --data):\n --<field> <value> set a field, e.g. --title \"Hello\"\n --<field>=<value> same; also the form for many fields in one call\n --<field>.<sub> <value> set a sub-property, e.g. --body.value \"Text\"\n --<relationship> <uuid> reference by UUID; repeat for a multi-valued field\n --json <field>=<json> raw JSON value, for arrays of objects\n\nAdd --fields to list this bundle's actual field parameters and exit without\nsending anything. An unknown field name is rejected, never ignored.\n\nA value starting with -- needs the --<field>=<value> form. A field whose name\ncollides with a reserved option (--bundle, --data, --dry-run, --no-validate,\n--fields, --format, --auth-profile, --config, --view-mode, --json) is reachable\nthrough --json <field>=<json> or --data, never as a bare --<field>.\n\nExample:\n dropsh create node --bundle article --fields\n dropsh create node --bundle article --title \"Hello\" --body.value \"Text\" --dry-run";
|
|
8
|
+
export interface AttributeDescription {
|
|
9
|
+
/** The complete command-line spelling, value placeholder included. */
|
|
10
|
+
parameter: string;
|
|
11
|
+
/** Dotted path into `data.attributes`. */
|
|
12
|
+
path: string;
|
|
13
|
+
type?: string;
|
|
14
|
+
/** The field's human label, from the schema's `title`. */
|
|
15
|
+
label?: string;
|
|
16
|
+
/** Present only when the schema requires the field. */
|
|
17
|
+
required?: true;
|
|
18
|
+
/** Present only when the field can be given as a bare `--flag`. */
|
|
19
|
+
bare_flag?: true;
|
|
20
|
+
}
|
|
21
|
+
export interface RelationshipDescription {
|
|
22
|
+
parameter: string;
|
|
23
|
+
path: string;
|
|
24
|
+
targets: string[];
|
|
25
|
+
multiple: boolean;
|
|
26
|
+
/** The relationship's human label, from the schema's `title`. */
|
|
27
|
+
label?: string;
|
|
28
|
+
/** Present only when several target types make `<type>:<uuid>` mandatory. */
|
|
29
|
+
requires_type_prefix?: true;
|
|
30
|
+
}
|
|
31
|
+
export interface FieldsDescription {
|
|
32
|
+
type?: string;
|
|
33
|
+
attributes: AttributeDescription[];
|
|
34
|
+
relationships: RelationshipDescription[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Turn a resolved operation schema into the machine-readable field listing that
|
|
38
|
+
* `--fields` prints: every settable parameter, in schema declaration order.
|
|
39
|
+
*/
|
|
40
|
+
export declare function describeFields(schema: unknown): FieldsDescription;
|
|
41
|
+
/**
|
|
42
|
+
* Render the field listing as an aligned, human-readable table — what `--fields`
|
|
43
|
+
* prints by default. `--format json` yields `describeFields` verbatim instead,
|
|
44
|
+
* for a caller that wants to consume it.
|
|
45
|
+
*/
|
|
46
|
+
export declare function renderFieldsTable(described: FieldsDescription): string;
|