dropsh 0.5.7 → 0.6.0

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.
@@ -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. Returns true if renewal succeeded (caller retries the request
7
- * once), false if it could not renew (caller surfaces the original 401).
8
- * Providers that cannot renew (e.g. basic auth) omit this.
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<boolean>;
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, url, body, extraHeaders = {}) {
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(await opts.auth.apply(req));
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 renewed = await opts.auth.renew();
33
- if (!renewed)
34
- throw err;
35
- res = await opts.http.send(await opts.auth.apply(req));
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", joinUrl(opts.baseUrl, opts.prefix, path) + qs);
62
+ return send("GET", path, getOperation(path), undefined, {}, qs);
45
63
  },
46
64
  async post(path, body) {
47
- return send("POST", joinUrl(opts.baseUrl, opts.prefix, path), JSON.stringify(body));
65
+ return send("POST", path, "create", JSON.stringify(body));
48
66
  },
49
67
  async patch(path, body) {
50
- return send("PATCH", joinUrl(opts.baseUrl, opts.prefix, path), JSON.stringify(body));
68
+ return send("PATCH", path, "update", JSON.stringify(body));
51
69
  },
52
70
  async delete(path) {
53
- return send("DELETE", joinUrl(opts.baseUrl, opts.prefix, path));
71
+ return send("DELETE", path, "delete");
54
72
  },
55
73
  async upload(path, filename, data) {
56
- return send("POST", joinUrl(opts.baseUrl, opts.prefix, path), data, {
74
+ return send("POST", path, "upload", data, {
57
75
  "Content-Type": "application/octet-stream",
58
76
  "Content-Disposition": `file; filename="${filename}"`,
59
77
  });
@@ -1,7 +1,8 @@
1
1
  import type { Command } from "commander";
2
2
  import type { AuthAdapter, AuthProvider } from "./auth/types.js";
3
3
  import type { AnyRenderer } from "./cli/render.js";
4
- import type { HttpClient } from "./http.js";
4
+ import type { HttpClient, HttpRequest } from "./http.js";
5
+ import type { DropSHOperation } from "./jsonapi/client.js";
5
6
  import type { Operation } from "./schema/to-jsonschema.js";
6
7
  export interface PluginContext {
7
8
  http: HttpClient;
@@ -21,6 +22,11 @@ export interface PluginContext {
21
22
  warn?: (message: string) => void;
22
23
  }
23
24
  export type SchemaOperation = Operation;
25
+ export interface RequestContext extends PluginContext {
26
+ operation: DropSHOperation;
27
+ entityType?: string;
28
+ bundle?: string;
29
+ }
24
30
  /**
25
31
  * Import-free descriptor naming a plugin package as a string. Same shape used
26
32
  * by the top-level `plugins[]` config; a plugin returns these in `dependencies`
@@ -50,6 +56,7 @@ export interface DropSHPlugin {
50
56
  */
51
57
  extendSchema?(entityType: string, bundle: string, baseSchema: unknown, ctx: PluginContext): Promise<unknown>;
52
58
  extendOperationSchema?(entityType: string, bundle: string, operation: SchemaOperation, operationSchema: unknown, ctx: PluginContext): Promise<unknown>;
59
+ alterRequest?(req: HttpRequest, ctx: RequestContext): Promise<HttpRequest>;
53
60
  registerCommands?(program: Command): void;
54
61
  renderers?: AnyRenderer[];
55
62
  }
@@ -0,0 +1,4 @@
1
+ import type { HttpRequest } from "./http.js";
2
+ import type { DropSHOperation } from "./jsonapi/client.js";
3
+ import type { DropSHPlugin, PluginContext } from "./plugin.js";
4
+ export declare function composeRequestHooks(plugins: DropSHPlugin[], ctx: PluginContext): (req: HttpRequest, operation: DropSHOperation, entityType?: string, bundle?: string) => Promise<HttpRequest>;
@@ -0,0 +1,26 @@
1
+ import { PluginError } from "../errors.js";
2
+ export function composeRequestHooks(plugins, ctx) {
3
+ return async (req, operation, entityType, bundle) => {
4
+ const requestContext = {
5
+ ...ctx,
6
+ operation,
7
+ ...(entityType !== undefined ? { entityType } : {}),
8
+ ...(bundle !== undefined ? { bundle } : {}),
9
+ };
10
+ let current = req;
11
+ for (const plugin of plugins) {
12
+ if (!plugin.alterRequest)
13
+ continue;
14
+ try {
15
+ const next = await plugin.alterRequest(current, requestContext);
16
+ if (next === undefined)
17
+ throw new Error("alterRequest must return an HttpRequest");
18
+ current = next;
19
+ }
20
+ catch (cause) {
21
+ throw new PluginError(plugin.id, "alterRequest", cause);
22
+ }
23
+ }
24
+ return current;
25
+ };
26
+ }
@@ -17,4 +17,7 @@ export declare class HttpError extends CliError {
17
17
  readonly body: unknown;
18
18
  constructor(status: number, message: string, body?: unknown);
19
19
  }
20
+ export declare class PluginError extends CliError {
21
+ constructor(pluginId: string, hook: string, cause: unknown);
22
+ }
20
23
  export declare function exitCodeFor(err: unknown): number;
@@ -36,6 +36,16 @@ export class HttpError extends CliError {
36
36
  this.body = body;
37
37
  }
38
38
  }
39
+ export class PluginError extends CliError {
40
+ constructor(pluginId, hook, cause) {
41
+ super("E_PLUGIN", `plugin '${pluginId}' failed in ${hook}`, {
42
+ pluginId,
43
+ hook,
44
+ cause: String(cause),
45
+ });
46
+ this.name = "PluginError";
47
+ }
48
+ }
39
49
  export function exitCodeFor(err) {
40
50
  if (err instanceof ConfigError)
41
51
  return 2;
@@ -45,5 +55,7 @@ export function exitCodeFor(err) {
45
55
  return 4;
46
56
  if (err instanceof HttpError)
47
57
  return 5;
58
+ if (err instanceof PluginError)
59
+ return 6;
48
60
  return 1;
49
61
  }
package/dist/src/index.js CHANGED
@@ -17,6 +17,7 @@ import { createPrompt } from "./core/cli/prompt.js";
17
17
  import { loadConfig } from "./core/config.js";
18
18
  import { createHttpClient } from "./core/http.js";
19
19
  import { createJsonApiClient } from "./core/jsonapi/client.js";
20
+ import { composeRequestHooks } from "./core/request-hooks.js";
20
21
  import { fetchJsonSchema } from "./core/schema/jsonschema-source.js";
21
22
  import { toOperationVariant } from "./core/schema/to-jsonschema.js";
22
23
  import { validatePayload } from "./core/schema/validate.js";
@@ -119,11 +120,18 @@ async function defaultContext(configPath, profile) {
119
120
  now: Date.now,
120
121
  ...(profile !== undefined ? { profile } : {}),
121
122
  });
123
+ const pluginContext = {
124
+ http,
125
+ auth,
126
+ baseUrl: cfg.site.base_url,
127
+ jsonapiPrefix: cfg.site.jsonapi_prefix,
128
+ };
122
129
  const client = createJsonApiClient({
123
130
  baseUrl: cfg.site.base_url,
124
131
  prefix: cfg.site.jsonapi_prefix,
125
132
  http,
126
133
  auth,
134
+ alterRequest: composeRequestHooks(cfg.plugins, pluginContext),
127
135
  });
128
136
  return {
129
137
  client,
@@ -7,12 +7,12 @@ export type { Config, SiteConfig } from "./core/config.js";
7
7
  export { loadConfig } from "./core/config.js";
8
8
  export type { HttpClient, HttpRequest } from "./core/http.js";
9
9
  export { createHttpClient } from "./core/http.js";
10
- export type { JsonApiClient, JsonApiOptions, ResourceWriteBody, } from "./core/jsonapi/client.js";
10
+ export type { DropSHOperation, JsonApiClient, JsonApiOptions, ResourceWriteBody, } from "./core/jsonapi/client.js";
11
11
  export { createJsonApiClient } from "./core/jsonapi/client.js";
12
12
  export type { Collection, FilterOp } from "./core/jsonapi/collection.js";
13
13
  export type { JsonApiResourceObject, Resource, } from "./core/jsonapi/resource.js";
14
14
  export type { JsonApiDocument, JsonApiResource } from "./core/jsonapi/types.js";
15
- export type { DropSHPlugin, PluginContext, PluginDescriptor, SchemaOperation, } from "./core/plugin.js";
15
+ export type { DropSHPlugin, PluginContext, PluginDescriptor, RequestContext, SchemaOperation, } from "./core/plugin.js";
16
16
  export { composePlugins } from "./core/plugin.js";
17
- export { AuthError, ConfigError, HttpError } from "./errors.js";
17
+ export { AuthError, ConfigError, HttpError, PluginError } from "./errors.js";
18
18
  export type { CommandContext } from "./index.js";
@@ -4,4 +4,4 @@ export { loadConfig } from "./core/config.js";
4
4
  export { createHttpClient } from "./core/http.js";
5
5
  export { createJsonApiClient } from "./core/jsonapi/client.js";
6
6
  export { composePlugins } from "./core/plugin.js";
7
- export { AuthError, ConfigError, HttpError } from "./errors.js";
7
+ export { AuthError, ConfigError, HttpError, PluginError } from "./errors.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dropsh",
3
- "version": "0.5.7",
3
+ "version": "0.6.0",
4
4
  "description": "Entity-agnostic helper CLI for Drupal 11 JSON:API, used by the dropsh Claude skill for editorial publishing workflows.",
5
5
  "type": "module",
6
6
  "bin": {