-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathdefine-plugin.ts
More file actions
393 lines (368 loc) · 15.2 KB
/
Copy pathdefine-plugin.ts
File metadata and controls
393 lines (368 loc) · 15.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
/**
* Provides the `definePlugin()` utility function.
*
* @module @common-grants/sdk/extensions
*/
import { z } from "zod";
import type {
ExtensibleSchemaName,
HasCustomFields,
CustomFieldSpec,
SchemaInput,
SchemaMappings,
PluginMeta,
PluginRoutes,
TransformResult,
} from "./types";
import { EXTENSIBLE_SCHEMA_MAP, TransformError } from "./types";
import { withCustomFields, type WithCustomFieldsResult } from "./with-custom-fields";
import { buildTransforms } from "./transforms";
import { validateRoutes } from "./custom-filters";
import { buildClientForPlugin } from "../client/resources/builder";
import type { BuiltClient } from "../client/resources/builder";
import type { ClientConfig } from "../client/config";
import type { AuthMethod } from "../client/auth";
// ############################################################################
// Public types - PluginSchemasInput, DefinePluginOptions, Plugin
// ############################################################################
/**
* Per-object schemas input keyed by extensible model name.
*
* Plugin authors populate this with hand-written or `buildTransforms()`-generated
* `toCommon` / `fromCommon` callables, an optional `sourceSchema`, and optional
* `customFields` specs. Passed as `DefinePluginOptions.schemas`.
*/
export type PluginSchemasInput = Partial<Record<ExtensibleSchemaName, SchemaInput>>;
/**
* Options for `definePlugin()`.
*
* `schemas` is the single surface for all per-object declarations: custom
* fields, source schema, declarative `mappings`, and explicit transform
* callables. Inputs are declarative wherever possible; explicit callables are
* available when custom code-driven logic is needed.
*
* Structured as an options object for forward-compatibility with future
* properties like `namespace`.
*/
export interface DefinePluginOptions<T extends PluginSchemasInput = PluginSchemasInput> {
/** Optional plugin identity and capability declaration. */
meta?: PluginMeta;
/**
* Per-object input — `sourceSchema`, `customFields` specs, declarative
* `mappings`, and `toCommon` / `fromCommon` callables — for each extensible model.
*
* `definePlugin()` compiles this into runtime schemas: `commonSchema` is built via
* `withCustomFields()` when `customFields` are declared; `toCommon` / `fromCommon`
* are auto-wired from `schemas[Name].mappings` when `mappings` is used. Providing
* both `mappings` and explicit callables is a runtime error.
*/
schemas?: T;
/**
* Route-keyed custom filter declarations.
*
* Passed through unchanged to `Plugin.routes`. Filters attach to resource
* methods (e.g. `opportunities.search.filters`), not to a schema key — because
* filters vary asymmetrically across methods.
*
* Registration-time validation (`validateRoutes`) and call-time classification
* (`classifyFilters`) consume these declarations.
*
* @example
* ```typescript
* definePlugin({
* routes: {
* opportunities: {
* search: {
* filters: {
* agency: { filterType: "stringArray" },
* },
* },
* },
* },
* } as const)
* ```
*/
routes?: PluginRoutes;
}
/**
* Configuration object returned by `definePlugin()`.
*
* - `schemas` — per-object compiled output: `commonSchema` (extended Zod schema),
* `sourceSchema`, `toCommon`, and `fromCommon` for each extensible model
* - `meta` — plugin identity passed through from options
* - `routes` — route-keyed custom filter declarations; when defined `as const`, the
* literal `filterType` values are preserved so that `TypedConsumerFilters`
* can narrow call-site filter keys, operators, and value shapes.
*
* The second generic parameter `TRoutes` captures the literal routes type when the
* caller uses `as const`. Callers that do not care about typed narrowing can ignore it
* (the default is `PluginRoutes`).
*/
export interface Plugin<
T extends PluginSchemasInput = PluginSchemasInput,
TRoutes extends PluginRoutes = PluginRoutes,
> {
schemas: PluginSchemas<T>;
meta?: PluginMeta;
/** Route-keyed custom filter declarations, passed through unchanged from `DefinePluginOptions.routes`. */
routes?: TRoutes;
/**
* Builds a client pre-bound to this plugin: responses parse with the plugin's
* compiled schemas by default, and `search({ filters })` types the registered
* filter names — no constructor `routes` or per-call `schema` needed.
*/
getClient(config?: ClientConfig & { auth?: AuthMethod }): BuiltClient<T, TRoutes>;
}
// ############################################################################
// Internal helper - wrapWithSchemaValidation
// ############################################################################
/**
* Wrap a transform callable to validate its output against a Zod schema.
*
* If the callable returns errors, those are returned unchanged. Otherwise the
* output is passed through `schema.safeParse()`; any Zod issues are flattened
* into `TransformResult.errors`.
*
* @internal
*/
function wrapWithSchemaValidation<TIn, TOut>(
fn: (input: TIn) => TransformResult<TOut>,
// `any` lets this accept schemas with .transform() whose input type differs
// from the output type. `unknown` would reject those valid schemas here.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema: z.ZodType<TOut, z.ZodTypeDef, any>
): (input: TIn) => TransformResult<TOut> {
return (input: TIn): TransformResult<TOut> => {
const result = fn(input);
if (result.errors.length > 0) return result;
const parsed = schema.safeParse(result.result);
if (parsed.success) return { result: parsed.data, errors: [] };
const errors = parsed.error.issues.map(issue => {
const path = issue.path.length > 0 ? issue.path.map(p => String(p)).join(".") : undefined;
return new TransformError(issue.message, { path });
});
return { result: result.result, errors };
};
}
// ############################################################################
// Public function - definePlugin()
// ############################################################################
/**
* Creates a `Plugin` from the given options.
*
* Iterates over extensible schemas. For each model, looks up `customFields`
* specs from `schemas[name].customFields`. When specs are present, applies
* `withCustomFields()` to produce a typed `commonSchema`; otherwise the base
* schema passes through unchanged. The per-object result is wrapped under
* `.commonSchema` alongside any `sourceSchema`, `toCommon`, and `fromCommon` provided.
*
* @param options - Options containing `schemas` (per-object input) and optional `meta`
* @returns A `Plugin` with `.schemas` and optional `.meta`
*
* @example
* ```typescript
* const plugin = definePlugin({
* schemas: {
* Opportunity: {
* customFields: {
* legacyId: { fieldType: "string" },
* category: { fieldType: "string", description: "Grant category" },
* },
* toCommon,
* fromCommon,
* },
* },
* } as const);
*
* // Access the extended Zod schema:
* const opp = plugin.schemas.Opportunity.commonSchema.parse(rawData);
* // Access the transform callables:
* const result = plugin.schemas.Opportunity.toCommon?.(sourceData);
* ```
*/
export function definePlugin<
const T extends PluginSchemasInput,
const TRoutes extends PluginRoutes = PluginRoutes,
>(options: DefinePluginOptions<T> & { routes?: TRoutes }): Plugin<T, TRoutes> {
const { meta, schemas: schemasInput, routes } = options;
// Runtime backstop for plain-JS authors: a misspelled route or an invalid
// filter registration throws here, at the definition site.
if (routes) validateRoutes(routes);
const schemas: Record<string, object> = {};
for (const [name, extensibleSchema] of Object.entries(EXTENSIBLE_SCHEMA_MAP) as [
ExtensibleSchemaName,
HasCustomFields,
][]) {
const specs = schemasInput?.[name]?.customFields;
const commonSchema =
specs && Object.keys(specs).length > 0
? withCustomFields(extensibleSchema, specs)
: extensibleSchema;
const explicitToCommon = schemasInput?.[name]?.toCommon;
const explicitFromCommon = schemasInput?.[name]?.fromCommon;
const mappings = schemasInput?.[name]?.mappings;
const sourceSchema = schemasInput?.[name]?.sourceSchema;
// XOR: providing both mappings AND explicit callables is an error.
const hasMappings = mappings !== undefined;
const hasCallables = explicitToCommon !== undefined || explicitFromCommon !== undefined;
if (hasMappings && hasCallables) {
throw new Error(
`definePlugin: ${name} cannot specify both mappings and explicit toCommon/fromCommon. ` +
`Use mappings for declarative transforms or provide explicit callables, not both.`
);
}
let toCommon = explicitToCommon;
let fromCommon = explicitFromCommon;
if (hasMappings) {
// Mappings path: validate both directions are present, then auto-wire.
if (mappings!.toCommon === undefined) {
throw new Error(
`definePlugin: ${name}.mappings.toCommon is required when auto-generating transforms. ` +
`Either provide both mapping directions or pass explicit toCommon/fromCommon callables.`
);
}
if (mappings!.fromCommon === undefined) {
throw new Error(
`definePlugin: ${name}.mappings.fromCommon is required when auto-generating transforms. ` +
`Either provide both mapping directions or pass explicit toCommon/fromCommon callables.`
);
}
// Pass `commonSchema` so validateOutputPaths runs against the fully-resolved
// schema (base or extended with customFields). This catches typo'd output keys
// at definition time rather than at first invocation.
const built = buildTransforms(
mappings!.toCommon,
mappings!.fromCommon,
undefined,
commonSchema
);
// The specific (TSource, TCommon) types for this entry were erased when it
// was stored in PluginSchemasInput above. Cast needed so the compiled transform
// can be assigned back to the schema entry's callable slots.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
toCommon = built.toCommon as any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fromCommon = built.fromCommon as any;
} else if (hasCallables) {
// Explicit callables path: validate each direction's output. toCommon is
// checked against the commonSchema and fromCommon against the sourceSchema
if (toCommon !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
toCommon = wrapWithSchemaValidation(toCommon as any, commonSchema as any) as any;
}
if (fromCommon !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fromCommon = wrapWithSchemaValidation(fromCommon as any, sourceSchema as any) as any;
}
}
schemas[name] = {
commonSchema,
sourceSchema,
// Keep customFields and mappings on the compiled entry so consumers can
// inspect what was used to build the schema and transforms.
customFields: specs && Object.keys(specs).length > 0 ? specs : undefined,
mappings: hasMappings ? mappings : undefined,
toCommon,
fromCommon,
};
}
// Cast is safe — the runtime loop mirrors the PluginSchemas<T> mapped type,
// but TypeScript can't verify that from the dynamic Object.entries() iteration.
// The second generic TRoutes preserves the literal routes type from `as const` calls.
const plugin = {
schemas,
meta,
routes,
getClient(config?: ClientConfig & { auth?: AuthMethod }) {
return buildClientForPlugin(plugin, config);
},
} as Plugin<T, TRoutes>;
return plugin;
}
// ############################################################################
// Internal - type inference utilities
// ############################################################################
/** Looks up the base Zod schema for an extensible model name. */
type BaseZodSchema<K extends ExtensibleSchemaName> = (typeof EXTENSIBLE_SCHEMA_MAP)[K];
/**
* Extracts the `customFields` record from `T[K]`, or `never` if absent.
*
* Used to feed the custom-fields spec into `WithCustomFieldsResult` while
* keeping the base schema as the fallback when no specs are declared.
*/
type ExtractCustomFields<
K extends ExtensibleSchemaName,
T extends PluginSchemasInput,
> = K extends keyof T
? NonNullable<T[K]> extends { customFields?: infer CF }
? CF extends Record<string, CustomFieldSpec>
? CF
: never
: never
: never;
/** Resolves the `commonSchema` Zod schema for a single model. */
type ResolveCommonSchema<K extends ExtensibleSchemaName, T extends PluginSchemasInput> = [
ExtractCustomFields<K, T>,
] extends [never]
? BaseZodSchema<K>
: WithCustomFieldsResult<BaseZodSchema<K>, ExtractCustomFields<K, T>>;
/**
* Returns `true` when `T[K]` has transforms: either a `mappings` entry or an
* explicit `toCommon` callable. Used to produce the right callable type on the
* compiled output — the input's `toCommon` is `never` in the mappings branch
* of the `SchemaInput` XOR union, so we cannot just read `T[K]["toCommon"]`
* directly for mappings-based entries.
*/
type EntryHasTransforms<
K extends ExtensibleSchemaName,
T extends PluginSchemasInput,
> = K extends keyof T
? NonNullable<T[K]> extends { mappings: SchemaMappings }
? true
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
NonNullable<T[K]> extends { toCommon: (...args: any[]) => any }
? true
: false
: false;
/**
* Extracts the source `TSource` from the entry's `sourceSchema`, or `unknown`.
*/
type ExtractSourceType<
K extends ExtensibleSchemaName,
T extends PluginSchemasInput,
> = K extends keyof T
? NonNullable<T[K]>["sourceSchema"] extends z.ZodType<infer S>
? S
: unknown
: unknown;
/**
* Maps each extensible model to its compiled per-object output.
*
* When the entry has transforms (mappings or explicit callables), both
* `toCommon` and `fromCommon` are present and callable. When it is schema-only
* (no transforms configured) they are absent. `customFields` and `mappings`
* are kept for consumer inspection regardless of which path was used.
*/
type PluginSchemas<T extends PluginSchemasInput> = {
[K in ExtensibleSchemaName]: EntryHasTransforms<K, T> extends true
? {
commonSchema: ResolveCommonSchema<K, T>;
sourceSchema: K extends keyof T ? NonNullable<T[K]>["sourceSchema"] : undefined;
customFields: K extends keyof T ? NonNullable<T[K]>["customFields"] : undefined;
mappings: K extends keyof T ? NonNullable<T[K]>["mappings"] : undefined;
toCommon: (
source: ExtractSourceType<K, T>
) => TransformResult<z.infer<ResolveCommonSchema<K, T>>>;
fromCommon: (
common: z.infer<ResolveCommonSchema<K, T>>
) => TransformResult<ExtractSourceType<K, T>>;
}
: {
commonSchema: ResolveCommonSchema<K, T>;
sourceSchema?: undefined;
customFields: K extends keyof T ? NonNullable<T[K]>["customFields"] : undefined;
mappings?: undefined;
toCommon?: undefined;
fromCommon?: undefined;
};
};