From ded1a017e0a9347fd9841a10b74c08ae636b0dba Mon Sep 17 00:00:00 2001 From: Karina Gonzalez Date: Tue, 13 Oct 2026 12:37:22 -0500 Subject: [PATCH 1/5] [#1246-T1] Support award recipient-individual schemas and validated transforms Forward sourceSchema to buildTransforms in the mappings path so fromCommon output is validated against the declared source schema. Skip validateOutputPaths for schemas with a non-never catchall (.loose()/.catchall(...)) so a loose source schema does not throw at build time for legitimately-accepted keys. Add AwdRecipientIndividual behavior tests covering custom-field validation, base fallback, both transform paths and directions, invalid-output errors, and resource independence. Refs #1246 --- .../extensions/define-plugin.spec.ts | 336 ++++++++++++++++++ lib/ts-sdk/src/extensions/define-plugin.ts | 3 +- lib/ts-sdk/src/extensions/transforms.ts | 12 + 3 files changed, 350 insertions(+), 1 deletion(-) diff --git a/lib/ts-sdk/__tests__/extensions/define-plugin.spec.ts b/lib/ts-sdk/__tests__/extensions/define-plugin.spec.ts index 373fe5e09..da5782758 100644 --- a/lib/ts-sdk/__tests__/extensions/define-plugin.spec.ts +++ b/lib/ts-sdk/__tests__/extensions/define-plugin.spec.ts @@ -6,8 +6,11 @@ import { type PluginMeta, type TransformResult, type PluginRoutes, + type ToCommon, + type FromCommon, } from "@/extensions"; import { OpportunityBaseSchema } from "@/schemas/zod/models"; +import { AwdRecipientIndividualSchema } from "@/schemas/zod/awards"; import { CustomFieldType } from "@/constants"; // ############################################################################ @@ -592,3 +595,336 @@ describe("definePlugin — auto-wiring from mappings", () => { expect(plugin.schemas.Opportunity.toCommon).toBeUndefined(); }); }); + +// ############################################################################ +// AwdRecipientIndividual: the nested award extension surface registered by #1156 +// ############################################################################ + +// A structured custom-field value schema for the recipient individual. +const ClearanceValueSchema = z.object({ + level: z.string(), + expiresOn: z.string(), +}); + +// A valid customFields payload matching the specs declared in the tests below. +const validRecipientCustomFields = { + vendorId: { name: "vendorId", fieldType: "string", value: "V-123" }, + clearance: { + name: "clearance", + fieldType: "object", + value: { level: "secret", expiresOn: "2030-01-01" }, + }, +} as const; + +// Source-system shape for the recipient transform tests. Only the two name +// parts are modeled; the recipient has no other required fields. +const recipientSourceSchema = z.object({ + given: z.string(), + family: z.string(), +}); +const recipientToCommonMapping = { + name: { + firstName: { field: "given" }, + lastName: { field: "family" }, + }, +}; +const recipientFromCommonMapping = { + given: { field: "name.firstName" }, + family: { field: "name.lastName" }, +}; + +describe("definePlugin: AwdRecipientIndividual", () => { + // ############################################################################ + // customFields: validation and inferred value types + // ############################################################################ + + it("validates recipient custom fields and preserves inferred value types", () => { + const plugin = definePlugin({ + schemas: { + AwdRecipientIndividual: { + customFields: { + vendorId: { fieldType: CustomFieldType.string }, + clearance: { + fieldType: CustomFieldType.object, + value: ClearanceValueSchema, + }, + }, + }, + }, + } as const); + + const result = plugin.schemas.AwdRecipientIndividual.commonSchema.parse({ + customFields: validRecipientCustomFields, + }); + + expect(result.customFields?.vendorId?.value).toBe("V-123"); + expect(result.customFields?.clearance?.value.level).toBe("secret"); + // Compile-time: structured custom-field value is typed, not `unknown`. + const level: string = result.customFields?.clearance?.value.level ?? ""; + expect(level).toBe("secret"); + }); + + it("rejects invalid recipient custom field values via safeParse", () => { + const plugin = definePlugin({ + schemas: { + AwdRecipientIndividual: { + customFields: { + clearance: { + fieldType: CustomFieldType.object, + value: ClearanceValueSchema, + }, + }, + }, + }, + } as const); + + const result = plugin.schemas.AwdRecipientIndividual.commonSchema.safeParse({ + customFields: { + clearance: { + name: "clearance", + fieldType: "object", + value: { level: "secret" }, // missing expiresOn + }, + }, + }); + + expect(result.success).toBe(false); + }); + + // ############################################################################ + // Base-schema fallback + // ############################################################################ + + it("falls back to AwdRecipientIndividualSchema when the entry is absent", () => { + const plugin = definePlugin({}); + expect(plugin.schemas.AwdRecipientIndividual.commonSchema).toBe(AwdRecipientIndividualSchema); + }); + + it("falls back to AwdRecipientIndividualSchema when customFields is empty", () => { + const plugin = definePlugin({ + schemas: { AwdRecipientIndividual: { customFields: {} } }, + }); + expect(plugin.schemas.AwdRecipientIndividual.commonSchema).toBe(AwdRecipientIndividualSchema); + }); + + // ############################################################################ + // Transforms: both authoring paths, both directions + // ############################################################################ + + it("runs both transform directions through the mappings path", () => { + const plugin = definePlugin({ + schemas: { + AwdRecipientIndividual: { + sourceSchema: recipientSourceSchema, + mappings: { + toCommon: recipientToCommonMapping, + fromCommon: recipientFromCommonMapping, + }, + }, + }, + }); + + const toCommon = plugin.schemas.AwdRecipientIndividual.toCommon?.({ + given: "Ada", + family: "Lovelace", + }); + expect(toCommon?.errors).toEqual([]); + expect(toCommon?.result).toMatchObject({ name: { firstName: "Ada", lastName: "Lovelace" } }); + + const fromCommon = plugin.schemas.AwdRecipientIndividual.fromCommon?.({ + name: { firstName: "Ada", lastName: "Lovelace" }, + }); + expect(fromCommon?.errors).toEqual([]); + expect(fromCommon?.result).toEqual({ given: "Ada", family: "Lovelace" }); + }); + + it("runs both transform directions through the explicit functions path", () => { + const plugin = definePlugin({ + schemas: { + AwdRecipientIndividual: { + sourceSchema: recipientSourceSchema, + toCommon: (source): TransformResult => ({ + result: { name: { firstName: source.given, lastName: source.family } }, + errors: [], + }), + fromCommon: (common): TransformResult => ({ + result: { given: common.name.firstName, family: common.name.lastName }, + errors: [], + }), + }, + }, + }); + + const toCommon = plugin.schemas.AwdRecipientIndividual.toCommon?.({ + given: "Ada", + family: "Lovelace", + }); + expect(toCommon?.errors).toEqual([]); + expect(toCommon?.result).toMatchObject({ name: { firstName: "Ada", lastName: "Lovelace" } }); + + const fromCommon = plugin.schemas.AwdRecipientIndividual.fromCommon?.({ + name: { firstName: "Ada", lastName: "Lovelace" }, + } as never); + expect(fromCommon?.errors).toEqual([]); + expect(fromCommon?.result).toEqual({ given: "Ada", family: "Lovelace" }); + }); + + // ############################################################################ + // Invalid output → TransformError against the appropriate schema + // ############################################################################ + + it("reports a TransformError when toCommon output fails the common schema", () => { + const plugin = definePlugin({ + schemas: { + AwdRecipientIndividual: { + sourceSchema: recipientSourceSchema, + mappings: { + // firstName is forced to a number, which NameSchema rejects. + toCommon: { name: { firstName: { const: 42 }, lastName: { field: "family" } } }, + fromCommon: recipientFromCommonMapping, + }, + }, + }, + }); + + const result = plugin.schemas.AwdRecipientIndividual.toCommon?.({ + given: "Ada", + family: "Lovelace", + }); + expect(result?.errors.length ?? 0).toBeGreaterThan(0); + expect(result?.errors[0]?.path).toBe("name.firstName"); + }); + + it("does not flag reverse-mapping keys a loose source schema accepts", () => { + // A loose source schema models only `given` but accepts any other key. The + // reverse mapping also produces `extra`, which the loose catchall permits, + // so validateOutputPaths must neither throw at build time nor report an + // error when fromCommon runs. + const looseSource = z.object({ given: z.string() }).loose(); + + const plugin = definePlugin({ + schemas: { + AwdRecipientIndividual: { + sourceSchema: looseSource, + mappings: { + toCommon: recipientToCommonMapping, + fromCommon: { + given: { field: "name.firstName" }, + extra: { const: "x" }, + }, + }, + }, + }, + }); + + const result = plugin.schemas.AwdRecipientIndividual.fromCommon?.({ + name: { firstName: "Ada", lastName: "Lovelace" }, + }); + expect(result?.errors).toEqual([]); + expect(result?.result).toMatchObject({ given: "Ada", extra: "x" }); + }); + + it("reports a TransformError when fromCommon output fails the source schema", () => { + // The source schema requires a `region` the reverse mapping never produces, + // so fromCommon output is validated against it and flagged. This exercises + // the sourceSchema now forwarded to buildTransforms in the mappings path. + const strictRecipientSource = z.object({ + given: z.string(), + family: z.string(), + region: z.string(), + }); + + const plugin = definePlugin({ + schemas: { + AwdRecipientIndividual: { + sourceSchema: strictRecipientSource, + mappings: { + toCommon: recipientToCommonMapping, + fromCommon: recipientFromCommonMapping, + }, + }, + }, + }); + + const result = plugin.schemas.AwdRecipientIndividual.fromCommon?.({ + name: { firstName: "Ada", lastName: "Lovelace" }, + }); + expect(result?.errors.length ?? 0).toBeGreaterThan(0); + expect(result?.errors.some(e => e.path === "region")).toBe(true); + }); + + // ############################################################################ + // Independence from the Opportunity entry + // ############################################################################ + + it("keeps recipient and opportunity schemas and transforms independent", () => { + const plugin = definePlugin({ + schemas: { + Opportunity: { + customFields: { + legacyId: { fieldType: CustomFieldType.object, value: LegacyIdValueSchema }, + }, + }, + AwdRecipientIndividual: { + customFields: { + vendorId: { fieldType: CustomFieldType.string }, + clearance: { fieldType: CustomFieldType.object, value: ClearanceValueSchema }, + }, + }, + }, + } as const); + + // The opportunity schema still parses its own custom field. + const opp = plugin.schemas.Opportunity.commonSchema.parse({ + ...validOpp, + customFields: { + legacyId: { name: "legacyId", fieldType: "object", value: { system: "legacy", id: 7 } }, + }, + }); + expect(opp.customFields?.legacyId?.value.id).toBe(7); + + // The recipient schema parses its own custom fields. + const recipient = plugin.schemas.AwdRecipientIndividual.commonSchema.parse({ + customFields: validRecipientCustomFields, + }); + expect(recipient.customFields?.vendorId?.value).toBe("V-123"); + + // A recipient custom field is not accepted as an opportunity top-level field, + // and vice versa; the two schemas do not share a shape. + expect("recipientIndividual" in opp).toBe(false); + expect("funding" in recipient).toBe(false); + }); + + // ############################################################################ + // Compile-time: ToCommon / FromCommon helpers resolve the recipient types + // ############################################################################ + + it("types hand-written recipient transforms via the ToCommon/FromCommon helpers", () => { + type RecipientTransform = { + model: "AwdRecipientIndividual"; + sourceSchema: typeof recipientSourceSchema; + customFields: { + vendorId: { fieldType: "string" }; + clearance: { fieldType: "object"; value: typeof ClearanceValueSchema }; + }; + }; + + const toCommon: ToCommon = source => ({ + // `source.given` is typed from recipientSourceSchema; the returned + // customFields value types are checked against the specs. + result: { + name: { firstName: source.given, lastName: source.family }, + customFields: validRecipientCustomFields, + }, + errors: [], + }); + + const fromCommon: FromCommon = common => ({ + result: { given: common.name?.firstName ?? "", family: common.name?.lastName ?? "" }, + errors: [], + }); + + expect(typeof toCommon).toBe("function"); + expect(typeof fromCommon).toBe("function"); + }); +}); diff --git a/lib/ts-sdk/src/extensions/define-plugin.ts b/lib/ts-sdk/src/extensions/define-plugin.ts index 73c37f59d..81ac4e230 100644 --- a/lib/ts-sdk/src/extensions/define-plugin.ts +++ b/lib/ts-sdk/src/extensions/define-plugin.ts @@ -250,7 +250,8 @@ export function definePlugin< mappings!.toCommon, mappings!.fromCommon, undefined, - commonSchema + commonSchema, + sourceSchema ); // The specific (TSource, TCommon) types for this entry were erased when it // was stored in PluginSchemasInput above. Cast needed so the compiled transform diff --git a/lib/ts-sdk/src/extensions/transforms.ts b/lib/ts-sdk/src/extensions/transforms.ts index 12fec227f..492efdcba 100644 --- a/lib/ts-sdk/src/extensions/transforms.ts +++ b/lib/ts-sdk/src/extensions/transforms.ts @@ -86,6 +86,14 @@ function validateMapping(mapping: unknown, knownHandlers: Set, path = "" * `ZodObject`s (`.extend()` preserves the concrete type), so the * fallback is a safety net, not an expected code path. * + * Also skips schemas that accept unknown keys via a non-`never` catchall + * (`.loose()` / `.catchall(...)`). Those keys are valid by definition, so + * flagging them would be a false positive, common for a source schema that + * only models the fields a transform maps and passes the rest through. This + * waives build-time key-typo detection for the whole mapping against such a + * schema: a mistyped *declared* field name is also accepted by the catchall, + * so it is no longer caught here (runtime validation still applies). + * * @internal */ function validateOutputPaths( @@ -98,6 +106,10 @@ function validateOutputPaths( commonSchema: z.ZodType ): void { if (!(commonSchema instanceof z.ZodObject)) return; + // A `.loose()` / `.catchall(...)` object accepts keys outside its shape; + // `.strict()` sets catchall to `z.never()`, which does not. Skip the former. + const catchall = commonSchema.def.catchall; + if (catchall !== undefined && !(catchall instanceof z.ZodNever)) return; const validNames = new Set(Object.keys(commonSchema.shape)); const outputKeys = Object.keys(mapping).filter(k => !knownHandlers.has(k)); const invalid = outputKeys.filter(k => !validNames.has(k)); From 225ef89bb16bf37a5b65aaf3f344f9d8b1aea64f Mon Sep 17 00:00:00 2001 From: Karina Gonzalez Date: Tue, 13 Oct 2026 13:10:22 -0500 Subject: [PATCH 2/5] [#1246-T2] Bind award schemas to plugin clients The award envelope has no top-level custom fields; its only extensible surface is the nested recipientIndividual. buildClientForPlugin now composes the plugin's compiled AwdRecipientIndividual schema into AwardBaseSchema at that key, so get/list/search parse and type the extension by default instead of always falling back to the fixed base schema. Per-call schema overrides still take precedence, and plugins with no award custom fields behave identically to the base schema. Refs #1246 --- .../__tests__/client/award-client-types.ts | 78 +++++++ .../__tests__/extensions/get-client.spec.ts | 196 +++++++++++++++++- lib/ts-sdk/src/client/resources/builder.ts | 26 ++- lib/ts-sdk/src/client/resources/registry.ts | 17 +- 4 files changed, 311 insertions(+), 6 deletions(-) create mode 100644 lib/ts-sdk/__tests__/client/award-client-types.ts diff --git a/lib/ts-sdk/__tests__/client/award-client-types.ts b/lib/ts-sdk/__tests__/client/award-client-types.ts new file mode 100644 index 000000000..e7b40c16d --- /dev/null +++ b/lib/ts-sdk/__tests__/client/award-client-types.ts @@ -0,0 +1,78 @@ +/** + * Compile-time assertions for the plugin-bound `awards` resource. + * + * Checked by `tsc --noEmit` only; there is no runtime test. Confirms that + * `get`/`list`/`search` type `recipientIndividual.customFields` through the + * plugin's compiled `AwdRecipientIndividual` schema by default, that a + * per-call `schema` override replaces that typing for a single call, and + * that award filter typing is retained alongside the typed item shape. + */ + +import { z } from "zod"; +import { definePlugin, F, withCustomFields } from "@/extensions"; +import { AwardBaseSchema, AwdRecipientIndividualSchema } from "@/schemas"; + +const plugin = definePlugin({ + schemas: { + AwdRecipientIndividual: { + customFields: { + tier: { fieldType: "string", value: z.string() }, + }, + }, + }, + routes: { + awards: { + search: { + filters: { recipientRegion: { filterType: "stringArray" } }, + }, + }, + }, +} as const); + +const client = plugin.getClient({ baseUrl: "http://localhost" }); + +// Compile-only; never executed. +async function _assertions(): Promise { + // Default binding: get(), list(), and search() all type the plugin's + // declared recipientIndividual custom field. + const got = await client.awards.get("id"); + const gotTier: string | undefined = got.recipientIndividual?.customFields?.tier?.value; + void gotTier; + + const list = await client.awards.list({ page: 1 }); + const listTier: string | undefined = + list.items[0]?.recipientIndividual?.customFields?.tier?.value; + void listTier; + + const search = await client.awards.search({ + page: 1, + filters: { recipientRegion: F.in(["US-CA"]) }, + }); + const searchTier: string | undefined = + search.items[0]?.recipientIndividual?.customFields?.tier?.value; + void searchTier; + + // Per-call override: a different recipient-individual schema replaces the + // plugin's default typing for this call only. + const overrideSchema = AwardBaseSchema.extend({ + recipientIndividual: withCustomFields(AwdRecipientIndividualSchema, { + tier: { fieldType: "number", value: z.number() }, + } as const).optional(), + }); + const overridden = await client.awards.get("id", { schema: overrideSchema }); + const overriddenTier: number | undefined = + overridden.recipientIndividual?.customFields?.tier?.value; + void overriddenTier; + + // @ts-expect-error: the override types tier as a number, not a string. + const wrongType: string = overridden.recipientIndividual?.customFields?.tier?.value; + void wrongType; + + // A later call with no override reverts to the plugin's default (string) typing. + const afterOverride = await client.awards.get("id"); + const afterOverrideTier: string | undefined = + afterOverride.recipientIndividual?.customFields?.tier?.value; + void afterOverrideTier; +} + +void _assertions; diff --git a/lib/ts-sdk/__tests__/extensions/get-client.spec.ts b/lib/ts-sdk/__tests__/extensions/get-client.spec.ts index 1365a04f5..12324de49 100644 --- a/lib/ts-sdk/__tests__/extensions/get-client.spec.ts +++ b/lib/ts-sdk/__tests__/extensions/get-client.spec.ts @@ -9,7 +9,11 @@ import type { CustomFilterBag, ListResult, } from "../../src/client/index.js"; -import { AwardBaseSchema, OpportunityBaseSchema } from "../../src/schemas/index.js"; +import { + AwardBaseSchema, + AwdRecipientIndividualSchema, + OpportunityBaseSchema, +} from "../../src/schemas/index.js"; import { withCustomFields } from "../../src/extensions/index.js"; const server = setupServer(); @@ -40,6 +44,20 @@ const mockOpp = { }, }; +const mockAward = { + id: "550e8400-e29b-41d4-a716-446655440002", + title: "Community Health Center Grant", + description: "Supports community health center improvements", + status: { value: "awarded" }, + createdAt: "2024-01-15T10:30:00Z", + lastModifiedAt: "2024-06-01T14:22:00Z", + recipientIndividual: { + customFields: { + tier: { name: "tier", fieldType: "string", value: "gold" }, + }, + }, +}; + const searchEnvelope = (items: unknown[]) => ({ status: 200, message: "Success", @@ -294,4 +312,180 @@ describe("plugin.getClient", () => { expect(result.items).toHaveLength(1); expect(result.items[0].customFields?.legacyId?.value).toBeUndefined(); }); + + it("binds the plugin's award schema so get, list, and search parse recipientIndividual custom fields", async () => { + const awardPlugin = definePlugin({ + schemas: { + AwdRecipientIndividual: { + customFields: { + tier: { fieldType: "string", value: z.string() }, + }, + }, + }, + } as const); + + server.use( + http.get("/common-grants/awards/:id", () => + HttpResponse.json({ status: 200, message: "Success", data: mockAward }) + ), + http.get("/common-grants/awards", () => + HttpResponse.json({ + status: 200, + message: "Success", + items: [mockAward], + paginationInfo: { page: 1, pageSize: 25, totalItems: 1, totalPages: 1 }, + }) + ), + http.post("/common-grants/awards/search", () => + HttpResponse.json(searchEnvelope([mockAward])) + ) + ); + + const client = awardPlugin.getClient({ baseUrl: "https://api.example.org" }); + + const got = await client.awards.get(mockAward.id); + expect(got.recipientIndividual?.customFields?.tier?.value).toBe("gold"); + + const list = await client.awards.list({ page: 1 }); + expect(list.items[0].recipientIndividual?.customFields?.tier?.value).toBe("gold"); + + const search = await client.awards.search({ page: 1 }); + expect(search.items[0].recipientIndividual?.customFields?.tier?.value).toBe("gold"); + }); + + it("rejects an award whose recipientIndividual custom field violates the plugin's declared type", async () => { + const awardPlugin = definePlugin({ + schemas: { + AwdRecipientIndividual: { + customFields: { + tier: { fieldType: "string", value: z.string() }, + }, + }, + }, + } as const); + + const mistypedTierAward = { + ...mockAward, + recipientIndividual: { + customFields: { + // Declared as a string field; the response sends a number instead. + tier: { name: "tier", fieldType: "string", value: 7 }, + }, + }, + }; + + server.use( + http.get("/common-grants/awards/:id", () => + HttpResponse.json({ status: 200, message: "Success", data: mistypedTierAward }) + ), + http.get("/common-grants/awards", () => + HttpResponse.json({ + status: 200, + message: "Success", + items: [mistypedTierAward], + paginationInfo: { page: 1, pageSize: 25, totalItems: 1, totalPages: 1 }, + }) + ), + http.post("/common-grants/awards/search", () => + HttpResponse.json(searchEnvelope([mistypedTierAward])) + ) + ); + + const client = awardPlugin.getClient({ baseUrl: "https://api.example.org" }); + + // get() fails hard on a single invalid item. + await expect(client.awards.get(mistypedTierAward.id)).rejects.toThrow(); + + // list() and search() collect per-item parse errors rather than throwing. + const list = await client.awards.list({ page: 1 }); + expect(list.errors).toMatchObject([{ index: 0, raw: mistypedTierAward }]); + expect(list.items).toHaveLength(0); + + const search = await client.awards.search({ page: 1 }); + expect(search.errors).toMatchObject([{ index: 0, raw: mistypedTierAward }]); + expect(search.items).toHaveLength(0); + }); + + it("falls back to the base award schema when the plugin declares no award custom fields", async () => { + server.use( + http.get("/common-grants/awards/:id", () => + HttpResponse.json({ status: 200, message: "Success", data: mockAward }) + ) + ); + + // No AwdRecipientIndividual entry at all. + const client = plugin.getClient({ baseUrl: "https://api.example.org" }); + const got = await client.awards.get(mockAward.id); + + // The base schema still parses the untyped customFields record, but it is + // not a statically-known member; only presence/shape is guaranteed. + expect(got.recipientIndividual?.customFields?.tier).toMatchObject({ value: "gold" }); + }); + + it("keeps award and opportunity custom-field schemas independent on the same plugin", async () => { + const mixedPlugin = definePlugin({ + schemas: { + Opportunity: { + customFields: { programCode: { fieldType: "string", value: z.string() } }, + }, + AwdRecipientIndividual: { + customFields: { tier: { fieldType: "string", value: z.string() } }, + }, + }, + } as const); + + server.use( + http.get("/common-grants/opportunities/:id", () => + HttpResponse.json({ status: 200, message: "Success", data: mockOpp }) + ), + http.get("/common-grants/awards/:id", () => + HttpResponse.json({ status: 200, message: "Success", data: mockAward }) + ) + ); + + const client = mixedPlugin.getClient({ baseUrl: "https://api.example.org" }); + + const opp = await client.opportunities.get(mockOpp.id); + const award = await client.awards.get(mockAward.id); + + expect(opp.customFields?.programCode?.value).toBe("STEM-ED"); + expect(award.recipientIndividual?.customFields?.tier?.value).toBe("gold"); + }); + + it("per-call schema override wins over the plugin-bound award default", async () => { + const awardPlugin = definePlugin({ + schemas: { + AwdRecipientIndividual: { + customFields: { tier: { fieldType: "string", value: z.string() } }, + }, + }, + } as const); + + const numericTierAward = { + ...mockAward, + recipientIndividual: { + customFields: { + tier: { name: "tier", fieldType: "number", value: 7 }, + }, + }, + }; + + server.use( + http.get("/common-grants/awards/:id", () => + HttpResponse.json({ status: 200, message: "Success", data: numericTierAward }) + ) + ); + + const overrideSchema = AwardBaseSchema.extend({ + recipientIndividual: withCustomFields(AwdRecipientIndividualSchema, { + tier: { fieldType: "number", value: z.number() }, + } as const).optional(), + }); + + const client = awardPlugin.getClient({ baseUrl: "https://api.example.org" }); + const result = await client.awards.get(mockAward.id, { schema: overrideSchema }); + + // The override's numeric tier wins, not the plugin's default string typing. + expect(result.recipientIndividual?.customFields?.tier?.value).toBe(7); + }); }); diff --git a/lib/ts-sdk/src/client/resources/builder.ts b/lib/ts-sdk/src/client/resources/builder.ts index 982169da7..dac609a24 100644 --- a/lib/ts-sdk/src/client/resources/builder.ts +++ b/lib/ts-sdk/src/client/resources/builder.ts @@ -21,6 +21,18 @@ type ResolvedItemType = z.infer< > & OpportunityBase; +/** + * Award item type: the base award envelope with its `recipientIndividual` key + * replaced by the plugin's compiled recipient-individual schema, the only + * extensible surface on `AwardBase`. + */ +type ResolvedAwardItemType = Omit< + AwardBase, + "recipientIndividual" +> & { + recipientIndividual?: z.infer["schemas"]["AwdRecipientIndividual"]["commonSchema"]>; +}; + /** * A `Client` whose resource slots carry the plugin's item and filter types: * `search({ filters })` autocompletes the registered filter names and @@ -36,7 +48,7 @@ export type BuiltClient & { // resource-slot: typed projection per resource opportunities: Opportunities>; - awards: Awards; + awards: Awards>; }; /** @@ -62,10 +74,18 @@ export function buildClientForPlugin; (client as unknown as { awards: Awards }).awards = new AwardResource( client, - baseSchema, + awardSchema, plugin.routes ); diff --git a/lib/ts-sdk/src/client/resources/registry.ts b/lib/ts-sdk/src/client/resources/registry.ts index 9f6d89a86..29e6e07a4 100644 --- a/lib/ts-sdk/src/client/resources/registry.ts +++ b/lib/ts-sdk/src/client/resources/registry.ts @@ -11,12 +11,25 @@ import { AwardBaseSchema } from "../../schemas/index.js"; type ResourceRegistryEntry = | { resourceClass: typeof Opportunities; schemaName: ExtensibleSchemaName } - | { resourceClass: typeof Awards; baseSchema: typeof AwardBaseSchema }; + | { + resourceClass: typeof Awards; + baseSchema: typeof AwardBaseSchema; + /** + * Extensible model composed into the base envelope's `recipientIndividual` + * key. The award envelope has no top-level custom fields, so unlike + * `schemaName` above, this does not replace the bound schema itself. + */ + nestedSchemaName: ExtensibleSchemaName; + }; // resource-slot: add an entry per API resource export const RESOURCE_REGISTRY = { opportunities: { resourceClass: Opportunities, schemaName: "Opportunity" }, - awards: { resourceClass: Awards, baseSchema: AwardBaseSchema }, + awards: { + resourceClass: Awards, + baseSchema: AwardBaseSchema, + nestedSchemaName: "AwdRecipientIndividual", + }, } as const satisfies Record; /** Compile-time seam guard: `ResourceName` and this registry's keys must not drift. */ From d96e3a6123079875ac38bf079d73f2143755c5e7 Mon Sep 17 00:00:00 2001 From: Karina Gonzalez Date: Tue, 13 Oct 2026 13:39:36 -0500 Subject: [PATCH 3/5] [#1246-T3] Document award extensions and client behavior Add an AwdRecipientIndividual custom-field example and the expanded extensible-model reference to the extensions guide, document plugin-bound award client calls and schema precedence in the client guide, and make explicit in both guides that client parsing does not invoke source-system transforms and that declaring a custom field does not register a search filter. Fix the grants.gov transform example to accept the Date values the declarative field handler passes through for created_at/last_modified_at. Refs #1246 --- lib/ts-sdk/examples/transforms.ts | 18 ++++++++- lib/ts-sdk/src/client/README.md | 27 +++++++++++++ lib/ts-sdk/src/extensions/README.md | 62 ++++++++++++++++++++++++----- 3 files changed, 96 insertions(+), 11 deletions(-) diff --git a/lib/ts-sdk/examples/transforms.ts b/lib/ts-sdk/examples/transforms.ts index 872af2891..6bff37de4 100644 --- a/lib/ts-sdk/examples/transforms.ts +++ b/lib/ts-sdk/examples/transforms.ts @@ -44,8 +44,17 @@ const GrantsGovOpportunity = z.object({ // apply" (three-state null). The transforms preserve it as `null` end to end // rather than collapsing it to absent. source_url: z.url().nullish(), - created_at: z.string(), - last_modified_at: z.string(), + // The declarative `field` handler (Scenario 1) has no date-to-string + // conversion, so it plucks `createdAt`/`lastModifiedAt` off the common + // side as the `Date` objects the common schema coerced them into. Accept + // either shape here and normalize to a string, since that's what a real + // grants.gov payload would carry on the wire. + created_at: z + .union([z.string(), z.date()]) + .transform(v => (v instanceof Date ? v.toISOString() : v)), + last_modified_at: z + .union([z.string(), z.date()]) + .transform(v => (v instanceof Date ? v.toISOString() : v)), }), }); type GrantsGovSource = z.infer; @@ -224,6 +233,11 @@ const noCustomFieldsPlugin = definePlugin({ created_at: { field: "createdAt" }, last_modified_at: { field: "lastModifiedAt" }, opportunity_status: { const: "posted" }, + // No custom field carries the legacy ID in this scenario, but the + // source schema requires the field; send a placeholder. A plugin + // without this data available would instead make the field + // optional on its own source schema. + opportunity_id: { const: 0 }, }, }, }, diff --git a/lib/ts-sdk/src/client/README.md b/lib/ts-sdk/src/client/README.md index 81305f0e5..f350bb423 100644 --- a/lib/ts-sdk/src/client/README.md +++ b/lib/ts-sdk/src/client/README.md @@ -265,6 +265,33 @@ const awards = await client.awards.list({ schema: AwardWithLabelSchema }); The corresponding public option types are `AwardGetOptions` and `AwardListOptions`. +#### Plugin-bound award calls + +Build a client from a plugin with `plugin.getClient()` to get award methods pre-bound to the plugin's compiled `AwdRecipientIndividual` schema, with no per-call `schema` needed. `get`, `list`, and `search` all parse the nested `recipientIndividual` through that schema by default: + +```ts +import { definePlugin } from "@common-grants/sdk/extensions"; + +const awardPlugin = definePlugin({ + schemas: { + AwdRecipientIndividual: { + customFields: { + tier: { fieldType: "string", description: "Recipient's award tier" }, + }, + }, + }, +} as const); + +const client = awardPlugin.getClient({ baseUrl: "https://api.example.org" }); + +const award = await client.awards.get(awardId); +award.recipientIndividual?.customFields?.tier?.value; // typed as string +``` + +When the plugin declares no `AwdRecipientIndividual` custom fields, award methods fall back to `AwardBaseSchema`. The per-call `schema` option (see [Get a single award](#get-a-single-award) above) takes precedence over the plugin-bound default for that call only. Later calls on the same client keep using the bound schema. + +**Client parsing does not invoke source-system transforms.** `get`, `list`, and `search` call `.parse()` on the bound (or per-call) schema directly; they never run a plugin's `toCommon` / `fromCommon`. If a plugin declares transforms, invoke them explicitly on the raw payload. See [Plugin transformations](../extensions/README.md#plugin-transformations) in the extensions guide. + ### Pagination All list and search methods auto-paginate by default, fetching up to `maxItems` (default: 1000) across multiple pages. To control this behavior: diff --git a/lib/ts-sdk/src/extensions/README.md b/lib/ts-sdk/src/extensions/README.md index 737b96012..869064dfb 100644 --- a/lib/ts-sdk/src/extensions/README.md +++ b/lib/ts-sdk/src/extensions/README.md @@ -133,6 +133,40 @@ opportunity.customFields?.legacyId?.value.id; // number See the [Plugins](#plugins) section below for full details on defining, composing, and publishing plugins. +#### The nested award model + +`Award` has no top-level `customFields`. The only extensible surface on an award is the nested `recipientIndividual`. Award plugins register custom fields under `AwdRecipientIndividual`, not `Award`: + +```typescript +import { definePlugin } from "@common-grants/sdk/extensions"; + +const awardPlugin = definePlugin({ + schemas: { + AwdRecipientIndividual: { + customFields: { + tier: { + fieldType: "string", + description: "Recipient's award tier", + }, + }, + }, + }, +} as const); + +// Parse a recipient-individual payload directly... +const recipient = awardPlugin.schemas.AwdRecipientIndividual.commonSchema.parse( + data.recipientIndividual +); +recipient.customFields?.tier?.value; // string + +// ...or, more commonly, bind a client and let `client.awards.get/list/search` +// compose this schema into the full award envelope automatically; see +// "Using plugins with the API client" below and the client README's Award +// methods section. +``` + +`AwdRecipientIndividual` custom fields compose independently of `Opportunity` custom fields declared on the same plugin. + ## Extracting custom field values There are two ways to access custom field values: direct dot notation and the `getCustomFieldValue()` helper. @@ -414,6 +448,8 @@ for (const err of results.errors) { The per-call `schema` option is still accepted by `get()`, `list()`, and `search()` and overrides the plugin-bound default. A plain `new Client(...)` (no plugin) keeps working with `OpportunityBaseSchema` defaults (untyped `customFields`, ad-hoc filters only). +`client.awards` binds the same way, composing the plugin's compiled `AwdRecipientIndividual` schema into the base award envelope's `recipientIndividual` field instead of swapping a top-level schema (see [The nested award model](#the-nested-award-model)). See the [client guide's Award methods](../client/README.md#award-methods) for the full `get`/`list`/`search` behavior, schema precedence, and award filters. + ## Plugin transformations Plugins can declare bidirectional transforms that convert between a source system's native shape and the CommonGrants protocol. `toCommon` maps `native → CommonGrants`; `fromCommon` reverses it. Both directions are author-provided — the SDK does not invert one into the other, because many-to-one handlers (like `match`) are not reversible. @@ -562,6 +598,8 @@ const out = toCommon(sourceData); There are two ways to wire transforms into a plugin entry. These are mutually exclusive — you cannot provide both `mappings` and explicit callables on the same entry. +Both paths work identically for any extensible model, keyed by name under `schemas`. The examples below use `Opportunity`; an award's nested recipient wires up the same way under `schemas.AwdRecipientIndividual`, with `data.recipientIndividual` (or the equivalent native field) as the mapping source. + **Option A: Declarative mappings** — pass a `mappings` object and `definePlugin()` compiles `toCommon` / `fromCommon` for you: ```typescript @@ -666,12 +704,18 @@ Plugins can declare custom filter specs per route-method. At search time, a sing > > ```typescript > definePlugin({ -> routes: { opportunities: { search: { filters: { ... } } } }, // custom filters (this section) -> schemas: { Opportunity: { customFields: { ... } } }, // custom fields (see "Build-time with plugins") +> routes: { +> opportunities: { search: { filters: { ... } } }, // custom filters (this section) +> awards: { search: { filters: { ... } } }, // custom filters, same rule for awards +> }, +> schemas: { +> Opportunity: { customFields: { ... } }, // custom fields (see "Build-time with plugins") +> AwdRecipientIndividual: { customFields: { ... } }, // custom fields, nested under Award.recipientIndividual +> }, > } as const); > ``` > -> The reason: filter declarations vary per route-method (e.g. `opportunities.search` may support different filters than `applications.list`), whereas custom fields are schema-level concerns shared across all operations that return the model. +> The reason: filter declarations vary per route-method (e.g. `opportunities.search` may support different filters than `applications.list`), whereas custom fields are schema-level concerns shared across all operations that return the model. Declaring `schemas.AwdRecipientIndividual.customFields` does not register a search filter, and declaring `routes.awards.search.filters` does not add a parsed field to award responses. They are independent extension points that happen to share the `awards` resource name. ### Declaring custom filters on a route @@ -939,9 +983,9 @@ The tables below list everything exported from `@common-grants/sdk/extensions`, ### Shared types -| Export | Kind | Description | Demonstrated in | -| ------------------------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | -| [`CustomFieldSpec`](./types.ts) | interface | Describes a single custom field: its `fieldType`, optional `value`, and optional `name`/`description`. | [Key concepts](#key-concepts) | -| [`ExtensibleSchemaName`](./types.ts) | type | Union of model names that support `customFields` extensions. Currently: `"Opportunity"`. | | -| [`HasCustomFields`](./types.ts) | type | A Zod object schema whose shape includes a `customFields` property. Constrains `withCustomFields()` inputs at compile time. | | -| [`ExtensibleObject`](./types.ts) | interface | An object with an optional `customFields` property. Constrains `getCustomFieldValue()` inputs at compile time. | | +| Export | Kind | Description | Demonstrated in | +| ------------------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| [`CustomFieldSpec`](./types.ts) | interface | Describes a single custom field: its `fieldType`, optional `value`, and optional `name`/`description`. | [Key concepts](#key-concepts) | +| [`ExtensibleSchemaName`](./types.ts) | type | Union of model names that support `customFields` extensions. Currently: `"Opportunity"` and `"AwdRecipientIndividual"` (nested under `Award.recipientIndividual`; see [The nested award model](#the-nested-award-model)). | | +| [`HasCustomFields`](./types.ts) | type | A Zod object schema whose shape includes a `customFields` property. Constrains `withCustomFields()` inputs at compile time. | | +| [`ExtensibleObject`](./types.ts) | interface | An object with an optional `customFields` property. Constrains `getCustomFieldValue()` inputs at compile time. | | From 9a935d93273959fa65d75da00d26e0557fd2f118 Mon Sep 17 00:00:00 2001 From: Karina Gonzalez Date: Tue, 13 Oct 2026 13:56:33 -0500 Subject: [PATCH 4/5] [#1246-T1] Clarify catchall-skip docs in validateOutputPaths Note that .passthrough() also lands in the catchall-skip branch alongside .loose()/.catchall(...), and that .def is Zod v4's public introspection API (undefined catchall for a plain z.object(), so plain objects stay validated). Comments only; no behavior change. Refs #1246 --- lib/ts-sdk/src/extensions/transforms.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/ts-sdk/src/extensions/transforms.ts b/lib/ts-sdk/src/extensions/transforms.ts index 492efdcba..ff409fc07 100644 --- a/lib/ts-sdk/src/extensions/transforms.ts +++ b/lib/ts-sdk/src/extensions/transforms.ts @@ -87,7 +87,8 @@ function validateMapping(mapping: unknown, knownHandlers: Set, path = "" * fallback is a safety net, not an expected code path. * * Also skips schemas that accept unknown keys via a non-`never` catchall - * (`.loose()` / `.catchall(...)`). Those keys are valid by definition, so + * (`.loose()` / `.passthrough()` / `.catchall(...)`). Those keys are valid by + * definition, so * flagging them would be a false positive, common for a source schema that * only models the fields a transform maps and passes the rest through. This * waives build-time key-typo detection for the whole mapping against such a @@ -106,8 +107,11 @@ function validateOutputPaths( commonSchema: z.ZodType ): void { if (!(commonSchema instanceof z.ZodObject)) return; - // A `.loose()` / `.catchall(...)` object accepts keys outside its shape; - // `.strict()` sets catchall to `z.never()`, which does not. Skip the former. + // A `.loose()` / `.passthrough()` / `.catchall(...)` object accepts keys + // outside its shape; `.strict()` sets catchall to `z.never()`, which does + // not. Skip the former. `.def` is Zod v4's public schema-introspection API + // (not a private field); `.def.catchall` is `undefined` for a plain + // `z.object()`, so the `!== undefined` guard keeps plain objects validated. const catchall = commonSchema.def.catchall; if (catchall !== undefined && !(catchall instanceof z.ZodNever)) return; const validNames = new Set(Object.keys(commonSchema.shape)); From fb33b10edff32dd8c43baa3466bd942b18fea11a Mon Sep 17 00:00:00 2001 From: Karina Gonzalez Date: Tue, 13 Oct 2026 19:38:04 -0500 Subject: [PATCH 5/5] [#1246-T1] Make validateOutputPaths error direction-aware Thread the mapping direction into validateOutputPaths so the thrown error labels fromCommon failures as fromCommonMapping (not toCommonMapping) and only shows the customFields hint for toCommon output, since custom fields are a CommonGrants concept that does not apply to source-schema output. Addresses PR #1299 review feedback. Refs #1246 --- .../__tests__/extensions/transforms.spec.ts | 25 +++++++++++++++++++ lib/ts-sdk/src/extensions/transforms.ts | 23 +++++++++++------ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/lib/ts-sdk/__tests__/extensions/transforms.spec.ts b/lib/ts-sdk/__tests__/extensions/transforms.spec.ts index d518da19e..d2f2247ed 100644 --- a/lib/ts-sdk/__tests__/extensions/transforms.spec.ts +++ b/lib/ts-sdk/__tests__/extensions/transforms.spec.ts @@ -592,6 +592,12 @@ describe("buildTransforms — output key validation (commonSchema provided)", () ).toThrow(/unknown output fields.*"titlee"/); }); + it("labels the error for the toCommon direction and includes the customFields hint", () => { + expect(() => + buildTransforms({ titlee: { field: "data.title" } }, {}, undefined, OpportunityBaseSchema) + ).toThrow(/buildTransforms \(toCommonMapping\):.*map them under the top-level "customFields"/s); + }); + it("accepts all valid top-level schema field names", () => { expect(() => buildTransforms({ title: { field: "data.title" } }, {}, undefined, OpportunityBaseSchema) @@ -646,6 +652,25 @@ describe("buildTransforms — output key validation (commonSchema provided)", () ).toThrow(/unknown output fields.*"notANativeField"/); }); + it("labels the error for the fromCommon direction and omits the customFields hint", () => { + const SourceSchema = z.object({ native_title: z.string() }); + let message = ""; + try { + buildTransforms( + {}, + { notANativeField: { field: "title" } }, + undefined, + undefined, + SourceSchema + ); + } catch (exc) { + message = (exc as Error).message; + } + // Custom fields are a CommonGrants concept, so the hint is toCommon-only. + expect(message).toMatch(/buildTransforms \(fromCommonMapping\):/); + expect(message).not.toMatch(/customFields/); + }); + it("does not validate fromCommonMapping output keys when sourceSchema is absent", () => { expect(() => buildTransforms({}, { notAField: { field: "title" } })).not.toThrow(); }); diff --git a/lib/ts-sdk/src/extensions/transforms.ts b/lib/ts-sdk/src/extensions/transforms.ts index ff409fc07..a48a70275 100644 --- a/lib/ts-sdk/src/extensions/transforms.ts +++ b/lib/ts-sdk/src/extensions/transforms.ts @@ -104,7 +104,12 @@ function validateOutputPaths( // output type (e.g. schemas that use .transform()). `unknown` would reject // those valid schemas here. // eslint-disable-next-line @typescript-eslint/no-explicit-any - commonSchema: z.ZodType + commonSchema: z.ZodType, + // Which mapping is being validated. Drives the error label and whether the + // `customFields` hint applies. Custom fields are a CommonGrants concept, so + // the hint only makes sense for `toCommon` output (validated against the + // common schema), not `fromCommon` output (validated against the source). + direction: "toCommon" | "fromCommon" ): void { if (!(commonSchema instanceof z.ZodObject)) return; // A `.loose()` / `.passthrough()` / `.catchall(...)` object accepts keys @@ -118,11 +123,15 @@ function validateOutputPaths( const outputKeys = Object.keys(mapping).filter(k => !knownHandlers.has(k)); const invalid = outputKeys.filter(k => !validNames.has(k)); if (invalid.length === 0) return; + const label = direction === "toCommon" ? "toCommonMapping" : "fromCommonMapping"; + const hint = + direction === "toCommon" + ? `If these are custom fields, map them under the top-level "customFields" key ` + + `(e.g. { customFields: { yourField: ... } }), not as top-level keys, and declare them in schemas[Object].customFields. ` + + `Otherwise check the field name.` + : `Check the field name.`; throw new Error( - `buildTransforms (toCommonMapping): unknown output fields ${JSON.stringify(invalid.sort())} for schema. ` + - `If these are custom fields, map them under the top-level "customFields" key ` + - `(e.g. { customFields: { yourField: ... } }), not as top-level keys, and declare them in schemas[Object].customFields. ` + - `Otherwise check the field name.` + `buildTransforms (${label}): unknown output fields ${JSON.stringify(invalid.sort())} for schema. ${hint}` ); } @@ -236,10 +245,10 @@ export function buildTransforms( validateMapping(toCommonMapping, known); validateMapping(fromCommonMapping, known); if (commonSchema !== undefined) { - validateOutputPaths(toCommonMapping, known, commonSchema); + validateOutputPaths(toCommonMapping, known, commonSchema, "toCommon"); } if (sourceSchema !== undefined) { - validateOutputPaths(fromCommonMapping, known, sourceSchema); + validateOutputPaths(fromCommonMapping, known, sourceSchema, "fromCommon"); } const runMapping = (